From cc8fd6a4b076594f8f3572046ec84c1180e8442f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 16:18:01 +0800 Subject: [PATCH 1/4] Define the view capability contract (#472) * Define the view capability contract View.Capabilities declares what each agent-host view runs, and admission follows it with no second mask: environment none, resolved Skills, function tools and results, tool search and stdio MCP. Every adapter declares them unsupported until the agent host builds and qualifies them. Whatever a view declares, the agent host rejects a Session without strict resume, with a restricted network, with a credentialed stdio binding, with installed Capabilities that no preparation resolved, or with neither a workspace nor environment none. The contract fixes the stdio alias, the empty-root view and the environment rule. * Leave capability and network admission to the agent host --- apps/daemon/internal/agent/claudesdk/view.go | 14 +-- .../internal/agent/claudesdk/view_test.go | 4 +- apps/daemon/internal/agent/codex/view.go | 11 ++- apps/daemon/internal/agent/codex/view_test.go | 4 +- apps/daemon/internal/agent/harness.go | 98 +++++++++++++++--- apps/daemon/internal/agent/mcode/view.go | 16 +-- apps/daemon/internal/agent/view_test.go | 15 ++- .../agent/viewloader/loader_linux_test.go | 3 + apps/daemon/internal/agenthost/admit.go | 33 ++++--- .../internal/agenthost/admit_linux_test.go | 99 ++++++++++++++----- .../agenthost/agenthost_linux_test.go | 5 + apps/daemon/internal/agenthost/doc.go | 12 ++- .../internal/agenthost/executor_linux.go | 17 ++-- .../internal/agenthost/view_linux_test.go | 11 ++- contracts/agents-api/harness-onboarding.md | 32 +++++- contracts/agents-api/zh/harness-onboarding.md | 34 ++++++- 16 files changed, 307 insertions(+), 101 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index b0a85689b..3716771ad 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -78,6 +78,14 @@ func declareView(probe Config, node, root, bridge, native string, loader viewloa Shims: []string{"bash", "rg", "git"}, ForwardEnv: []string{"CLAUDECODE", "GIT_EDITOR"}, Proxy: agent.ViewProxyEnv, + Capabilities: agent.ViewCapabilities{ + EnvironmentNone: proto.CapabilityUnsupported, + Skills: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilityUnsupported, + FunctionResultImages: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilityUnsupported, + }, } loader.AddTo(view) view.Executor = newViewExecutorFactory(probe, layout) @@ -111,12 +119,6 @@ func prepareView(layout viewLayout, req proto.PromptRequestPayload, view agent.V if environment == nil || !workspacePathSyntax(environment.WorkspaceRoot) || req.DisableExecutionEnvironment || view.Launch == nil || view.Proxy == "" { return startRequest{}, nil, errors.New("claudesdk: a view Executor requires the sandbox workspace, Launch and the gateway proxy") } - if environment.Capabilities || len(environment.Skills) != 0 || environment.CapabilityRoot != "" { - return startRequest{}, nil, fmt.Errorf("%w: installed Capabilities in an agent-host view", agent.ErrUnsupportedOperation) - } - if (environment.NetworkAccess != "" && environment.NetworkAccess != "enabled") || len(environment.AllowedDomains) != 0 { - return startRequest{}, nil, fmt.Errorf("%w: restricted network in an agent-host view", agent.ErrUnsupportedOperation) - } servers, err := viewMCP(view.MCP) if err != nil { return startRequest{}, nil, err diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index d0eadf0f6..1d2a567b2 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -23,6 +23,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/viewloader" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -105,7 +106,8 @@ func TestViewExecutorLaunchesAClosedGatewayEnvironment(t *testing.T) { t.Fatal("the real key reached the view") } - session.MCP = []agent.MCPBinding{{ServerLabel: "local", Transport: "stdio", Stdio: &proto.EnvironmentMCP{}}} + session.MCP = []agent.MCPBinding{{ServerLabel: "local", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ + Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} if _, err := view.Executor(t.Context(), req, session); !errors.Is(err, agent.ErrUnsupportedOperation) { t.Fatalf("stdio MCP = %v, want ErrUnsupportedOperation", err) } diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go index 0cd9ea312..144b16ed6 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -86,6 +86,14 @@ func newView(binary string, codeModeHost bool) agent.View { ShimPaths: []string{"/bin/bash"}, ForwardEnv: slices.Clone(viewForwardEnv), Proxy: agent.ViewProxyEnv, + Capabilities: agent.ViewCapabilities{ + EnvironmentNone: proto.CapabilityUnsupported, + Skills: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilityUnsupported, + FunctionResultImages: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilityUnsupported, + }, Executor: func(ctx context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (agent.Executor, error) { cfg := defaultSessionConfig() cfg.codexBinary = binary @@ -129,9 +137,6 @@ func prepareViewPlan(ctx context.Context, req proto.PromptRequestPayload, cfg se if local == nil || req.DisableExecutionEnvironment || !path.IsAbs(local.WorkspaceRoot) { return SessionPlan{}, fmt.Errorf("%w: codex: a view runs in an Environment workspace", agent.ErrUnsupportedOperation) } - if local.Capabilities || len(local.Skills) > 0 { - return SessionPlan{}, fmt.Errorf("%w: codex: Capabilities and skills in a view", agent.ErrUnsupportedOperation) - } if !filepath.IsAbs(view.Home.Host) || !path.IsAbs(view.Home.View) { return SessionPlan{}, errors.New("codex: view home must be absolute") } diff --git a/apps/daemon/internal/agent/codex/view_test.go b/apps/daemon/internal/agent/codex/view_test.go index 4dc2e3c85..326490432 100644 --- a/apps/daemon/internal/agent/codex/view_test.go +++ b/apps/daemon/internal/agent/codex/view_test.go @@ -12,6 +12,7 @@ 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/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -119,7 +120,8 @@ func TestViewExecutorLaunchesInTheSessionView(t *testing.T) { t.Fatalf("outside file changed: %q, %v", body, err) } - session.MCP = []agent.MCPBinding{{ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &proto.EnvironmentMCP{}}} + session.MCP = []agent.MCPBinding{{ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ + Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} if _, err := view.Executor(t.Context(), req, session); !errors.Is(err, agent.ErrUnsupportedOperation) || len(launched) != 1 { t.Fatalf("stdio MCP: launches %d, err %v", len(launched), err) } diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 13448f800..c0d9e3c6f 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -34,11 +34,14 @@ import ( "net/url" "path" "path/filepath" + "reflect" "slices" + "strconv" "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -92,7 +95,26 @@ func (r *Registry) Register(declaration Declaration, runtime Runtime) { // view: the sandbox world at /, the closure, home and shims under // ViewPrivateRoot, and a loopback-only network whose model, MCP and proxy // endpoints belong to the Session's credential gateway. The declaration is -// data; the agent host builds each view from it and the Session. +// data; the agent host builds each view from it and the Session, and admits a +// request only when the view declares each capability the request uses. +// +// Environment none. A request with DisableExecutionEnvironment runs in an +// empty-root view: a read-only, noexec tmpfs root that holds only the +// mountpoints for the closure, the home, the agent host's runtime files, +// ViewProcRoot, ViewDevRoot and the overlays. It has no world, no shims, no +// Link attachment and no sandbox network, so the generic proxy refuses every +// request; the cgroup, the isolation and the gateway stay. The Harness runs in +// ViewPrivateRoot/ViewHomeName/ViewWorkName. A request with neither +// LocalEnvironment nor DisableExecutionEnvironment is an incomplete binding, +// and the agent host rejects it. +// +// Environment. The Harness's environment is exactly the Env the adapter +// passes to ViewSession.Launch, which it derives from its installation and the +// request's typed fields; the request carries no environment values. A process +// in the sandbox keeps only the Harness variables that View.ForwardEnv +// declares, and the daemon's own environment reaches neither. Model and MCP +// credentials stay in the gateway's protected configuration: the agent host +// adds none to either environment or to a capability tree the view exposes. // The view layout. This is its one definition: sessionview builds views from // it, and View.Validate keeps declarations out of the trees it reserves. @@ -109,11 +131,23 @@ const ( // ViewRelayName is the process relay's name in the shim directory, which // no shim takes. ViewRelayName = "oac-process-shim" + // ViewWorkName is the working directory under the home in an empty-root + // view. + ViewWorkName = "work" // ViewProcRoot and ViewDevRoot are the view's own /proc and minimal /dev. ViewProcRoot = "/proc" ViewDevRoot = "/dev" ) +// viewAliasPrefix starts every stdio MCP alias name, which no shim takes. +const viewAliasPrefix = "oac-mcp-" + +// ViewAlias is the view path of the alias of the stdio binding at index i of +// ViewSession.MCP, in the shim directory. +func ViewAlias(i int) string { + return ViewPrivateRoot + "/" + ViewShimName + "/" + viewAliasPrefix + strconv.Itoa(i) +} + // ViewReserved reports whether the view path p is at or beneath a tree the // view builds itself: ViewPrivateRoot, ViewProcRoot or ViewDevRoot. func ViewReserved(p string) bool { @@ -137,7 +171,8 @@ var ( var ErrInvalidView = errors.New("agent: invalid view declaration") // ErrViewHandoff rejects a view request that carries a model provider other -// than the Session's gateway, MCP outside ViewSession.MCP or an MCP credential. +// than the Session's gateway, MCP outside ViewSession.MCP, an MCP credential +// that the gateway does not hold, or a stdio binding other than its alias. var ErrViewHandoff = errors.New("agent: view request carries a connection outside the Session's gateway") // ViewSession.Launch and ViewSession.Spawn outcomes. @@ -176,7 +211,28 @@ type View struct { // environment wins over a forwarded variable of the same name. ForwardEnv []string Proxy ViewProxy - Executor ViewExecutorFactory + // Capabilities declares what the view supports. + Capabilities ViewCapabilities + Executor ViewExecutorFactory +} + +// ViewCapabilities declares, field by field, what a view supports. Each field +// is set explicitly. +type ViewCapabilities struct { + // EnvironmentNone runs a request with DisableExecutionEnvironment in an + // empty-root view. + EnvironmentNone proto.CapabilitySupport + // Skills runs a request with resolved Skills (LocalEnvironment.Skills). + Skills proto.CapabilitySupport + // FunctionTools, FunctionResultImages and ToolSearch mean what the + // proto.AgentKindCapabilities fields of the same names mean. + FunctionTools proto.CapabilitySupport + FunctionResultImages proto.CapabilitySupport + ToolSearch proto.CapabilitySupport + // StdioMCP runs stdio MCP bindings under their aliases. A stdio binding + // whose CredentialAuthority is not "none" is rejected with ErrViewHandoff + // whatever the view declares. + StdioMCP proto.CapabilitySupport } // ViewMount presents HostDir at ViewPrivateRoot/. @@ -236,13 +292,19 @@ type ViewSession struct { // MCP is the Session's effective MCP, resolved once from the public // declarations and the installed Environment MCP. Each HTTP binding's // ServerURL is its loopback gateway URL, and it carries no BearerToken and - // no HTTPHeaders; the gateway adds them. A stdio binding is as resolved and - // runs in the sandbox through the declared shims. A view Executor takes MCP - // only from here. + // no HTTPHeaders; the gateway adds them. The stdio binding at index i runs + // in the sandbox under its alias: + // its Stdio is exactly {Server: {Name: ServerLabel, Type: "stdio", + // Command: ViewAlias(i)}}, and the Harness runs the alias without + // arguments. The process broker runs the binding's frozen command, args + // and CWD for it, a relative CWD in the installation's package root, as it + // runs a shim's process and with nothing from the Harness's argv, working + // directory or environment. A view Executor takes MCP only from here. MCP []MCPBinding // Launch replaces clirunner.Start. Each call builds one view and runs - // Binary, which must be a LocalExec path, in it. Dir is a world path, - // OwnProcessGroup is true, and Env is the complete Harness environment. + // Binary, which must be a LocalExec path, in it. Dir is a world path, or + // the work directory in an empty-root view; OwnProcessGroup is true, and + // Env is the complete Harness environment. // Cancel sends TERM to every process in the view and closes the view after // KillTimeout; a Cancel after the Harness exited leaves its exit as it was. // When the Harness exits while other processes remain, the view sends them @@ -269,8 +331,8 @@ type ViewSession struct { // checkViewHandoff enforces, before the factory runs, that the view request // reaches the network only through the Session's gateway: the model provider -// is the gateway with the placeholder key, and MCP arrives only in session.MCP -// and without credentials. +// is the gateway with the placeholder key, and MCP arrives only in session.MCP, +// without credentials and with stdio only under its alias. func checkViewHandoff(req proto.PromptRequestPayload, prepared harnessconfig.PreparedConfiguration, session ViewSession) error { if provider := prepared.Provider; provider == nil || provider.APIKey != modelprovider.Placeholder || !isGatewayURL(provider.BaseURL, false) { return fmt.Errorf("%w: the model provider is not the Session's gateway", ErrViewHandoff) @@ -278,9 +340,11 @@ func checkViewHandoff(req proto.PromptRequestPayload, prepared harnessconfig.Pre if req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) > 0) { return fmt.Errorf("%w: MCP outside ViewSession.MCP", ErrViewHandoff) } - for _, binding := range session.MCP { - if binding.BearerToken != nil || len(binding.HTTPHeaders) > 0 || (binding.Transport == "http" && !isGatewayURL(binding.ServerURL, true)) { - return fmt.Errorf("%w: MCP binding %q is not a credential-free gateway endpoint", ErrViewHandoff, binding.ServerLabel) + for i, binding := range session.MCP { + alias := proto.EnvironmentMCP{Server: agentplugin.MCPServer{Name: binding.ServerLabel, Type: "stdio", Command: ViewAlias(i)}} + if binding.BearerToken != nil || len(binding.HTTPHeaders) > 0 || (binding.Transport == "http" && !isGatewayURL(binding.ServerURL, true)) || + (binding.Transport == "stdio" && (binding.Stdio == nil || !reflect.DeepEqual(*binding.Stdio, alias))) { + return fmt.Errorf("%w: MCP binding %q is not a credential-free gateway endpoint or alias", ErrViewHandoff, binding.ServerLabel) } } return nil @@ -312,6 +376,12 @@ func (v View) Validate() error { if v.Proxy != ViewProxyNone && v.Proxy != ViewProxyEnv { return invalidView("proxy %d", v.Proxy) } + c := reflect.ValueOf(v.Capabilities) + for i := range c.NumField() { + if s := c.Field(i).Interface().(proto.CapabilitySupport); s != proto.CapabilitySupported && s != proto.CapabilityUnsupported { + return invalidView("capability %s is not declared", c.Type().Field(i).Name) + } + } names := map[string]bool{ViewShimName: true, ViewHomeName: true, ViewRunName: true} for _, m := range v.Closure { if !isPathComponent(m.Name) || names[m.Name] { @@ -348,7 +418,7 @@ func (v View) Validate() error { } } for i, n := range v.Shims { - if !isPathComponent(n) || n == ViewRelayName || slices.Contains(v.Shims[:i], n) { + if !isPathComponent(n) || n == ViewRelayName || strings.HasPrefix(n, viewAliasPrefix) || slices.Contains(v.Shims[:i], n) { return invalidView("shim %q", n) } } diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index 9fdb8f112..6e32f6b8d 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -130,7 +130,15 @@ func (i viewInstall) view() agent.View { Shims: []string{"git", "rg"}, ShimPaths: []string{"/bin/bash"}, Proxy: agent.ViewProxyNone, - Executor: i.executor, + Capabilities: agent.ViewCapabilities{ + EnvironmentNone: proto.CapabilityUnsupported, + Skills: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilityUnsupported, + FunctionResultImages: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilityUnsupported, + }, + Executor: i.executor, } i.loader.AddTo(&view) return view @@ -150,12 +158,6 @@ func (i viewInstall) prepare(_ context.Context, req proto.PromptRequestPayload, if !req.StrictResume || local == nil || req.DisableExecutionEnvironment || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view runs Agents API execution in a writable Environment workspace", agent.ErrUnsupportedOperation) } - if local.NetworkAccess != "enabled" || len(local.AllowedDomains) != 0 { - return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view requires unrestricted workspace network", agent.ErrUnsupportedOperation) - } - if len(local.Skills) != 0 { - return launchOptions{}, fmt.Errorf("%w: a MiniMax Code view does not install Capabilities", agent.ErrUnsupportedOperation) - } workspace := local.WorkspaceRoot if !path.IsAbs(workspace) || path.Clean(workspace) != workspace || workspace == "/" { return launchOptions{}, errors.New("mcode: the workspace is not a canonical absolute path") diff --git a/apps/daemon/internal/agent/view_test.go b/apps/daemon/internal/agent/view_test.go index fa243d4f8..70f5ae267 100644 --- a/apps/daemon/internal/agent/view_test.go +++ b/apps/daemon/internal/agent/view_test.go @@ -3,12 +3,14 @@ package agent_test import ( "context" "errors" + "path" "slices" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -33,6 +35,8 @@ func TestViewValidate(t *testing.T) { "unclean view path": func(v *agent.View) { v.Masks[0].Path = "/etc/../etc/harness" }, "duplicate shim": func(v *agent.View) { v.Shims = append(v.Shims, "git") }, "shim named as the relay": func(v *agent.View) { v.Shims = append(v.Shims, agent.ViewRelayName) }, + "shim named as an alias": func(v *agent.View) { v.Shims = append(v.Shims, path.Base(agent.ViewAlias(0))) }, + "undeclared capability": func(v *agent.View) { v.Capabilities.StdioMCP = proto.CapabilityUnspecified }, "forwarded assignment": func(v *agent.View) { v.ForwardEnv = append(v.ForwardEnv, "A=B") }, "forwarded broker variable": func(v *agent.View) { v.ForwardEnv = append(v.ForwardEnv, "PATH") }, "forwarded proxy variable": func(v *agent.View) { v.ForwardEnv = append(v.ForwardEnv, "https_proxy") }, @@ -90,11 +94,17 @@ func TestViewExecutorReceivesOnlyGatewayConnections(t *testing.T) { withBearer.BearerToken = &token withHeaders.HTTPHeaders = map[string]string{"X-Api-Key": token} remote.ServerURL = "https://mcp.example.com/docs" + alias := agent.MCPBinding{ServerLabel: "tools", Transport: "stdio", Stdio: &proto.EnvironmentMCP{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: agent.ViewAlias(1)}}} + command, misplaced := alias, alias + command.Stdio = &proto.EnvironmentMCP{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "node", Args: []string{"tools.js"}}} + misplaced.Stdio = &proto.EnvironmentMCP{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: agent.ViewAlias(0)}} for name, c := range map[string]struct { req proto.PromptRequestPayload mcp []agent.MCPBinding }{ - "gateway": {req: gatewayRequest, mcp: []agent.MCPBinding{gateway}}, + "gateway": {req: gatewayRequest, mcp: []agent.MCPBinding{gateway, alias}}, + "stdio command": {req: gatewayRequest, mcp: []agent.MCPBinding{gateway, command}}, + "another alias": {req: gatewayRequest, mcp: []agent.MCPBinding{gateway, misplaced}}, "model key": {req: request("http://127.0.0.1:4101", token)}, "model endpoint": {req: request("https://api.example.com", modelprovider.Placeholder)}, "request MCP": {req: requestMCP}, @@ -129,6 +139,9 @@ func validView(t *testing.T) agent.View { ShimPaths: []string{"/bin/sh"}, ForwardEnv: []string{"GIT_EDITOR"}, Proxy: agent.ViewProxyEnv, + Capabilities: agent.ViewCapabilities{EnvironmentNone: proto.CapabilityUnsupported, Skills: proto.CapabilitySupported, + FunctionTools: proto.CapabilitySupported, FunctionResultImages: proto.CapabilityUnsupported, ToolSearch: proto.CapabilityUnsupported, + StdioMCP: proto.CapabilityUnsupported}, Executor: func(context.Context, proto.PromptRequestPayload, agent.ViewSession) (agent.Executor, error) { return nil, errors.New("not started") }, diff --git a/apps/daemon/internal/agent/viewloader/loader_linux_test.go b/apps/daemon/internal/agent/viewloader/loader_linux_test.go index 23832f780..b44d1cbd6 100644 --- a/apps/daemon/internal/agent/viewloader/loader_linux_test.go +++ b/apps/daemon/internal/agent/viewloader/loader_linux_test.go @@ -35,7 +35,10 @@ func TestForPresentsTheHostLoader(t *testing.T) { if !slices.Equal(fragment.Masks, []agent.ViewMask{{Path: "/etc/ld.so.preload"}, {Path: "/etc/ld.so.cache"}}) { t.Fatalf("masks = %+v", fragment.Masks) } + unsupported := proto.CapabilityUnsupported view := agent.View{Proxy: agent.ViewProxyNone, LocalExec: []string{fragment.Overlays[0].Path}, + Capabilities: agent.ViewCapabilities{EnvironmentNone: unsupported, Skills: unsupported, FunctionTools: unsupported, + FunctionResultImages: unsupported, ToolSearch: unsupported, StdioMCP: unsupported}, Executor: func(context.Context, proto.PromptRequestPayload, agent.ViewSession) (agent.Executor, error) { return nil, nil }} diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index 1eab1094d..04b9f0bde 100644 --- a/apps/daemon/internal/agenthost/admit.go +++ b/apps/daemon/internal/agenthost/admit.go @@ -95,22 +95,26 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env if err != nil { return nil, fmt.Errorf("%w: admit: %w", ErrUnsupported, err) } - local := req.LocalEnvironment + caps, local := view.Capabilities, req.LocalEnvironment switch { - case req.DisableExecutionEnvironment: - return nil, unsupported("a Session without an execution environment") - case local == nil: - return nil, unsupported("a Session without a workspace") - case !isViewPath(local.WorkspaceDirectory): + case local == nil && !req.DisableExecutionEnvironment: + return nil, invalidSession("a Session with neither a workspace nor environment none is an incomplete binding") + case req.DisableExecutionEnvironment && !caps.EnvironmentNone.IsSupported(): + return nil, unsupported("environment none") + case local != nil && !isViewPath(local.WorkspaceDirectory): return nil, invalidSession("workspace %q is not absolute and clean", local.WorkspaceDirectory) case !req.StrictResume: return nil, unsupported("a Session without strict resume") - case local.Capabilities || len(local.Skills) > 0 || local.CapabilityRoot != "": - return nil, unsupported("installed Capabilities and skills") - case local.NetworkAccess != "enabled" || len(local.AllowedDomains) > 0: + case local != nil && local.Capabilities && local.CapabilityRoot == "": + return nil, unsupported("installed Capabilities that no preparation resolved") + case local != nil && len(local.Skills) > 0 && !caps.Skills.IsSupported(): + return nil, unsupported("Skills") + case local != nil && (local.NetworkAccess != "enabled" || len(local.AllowedDomains) > 0): return nil, unsupported("a restricted workspace network") - case len(req.FunctionTools) > 0 || req.ToolSearch: - return nil, unsupported("function tools and their discovery") + case len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported(): + return nil, unsupported("function tools") + case req.ToolSearch && !caps.ToolSearch.IsSupported(): + return nil, unsupported("tool search") case len(view.Shims) > 0 && !hasPATH(env): return nil, invalidSession("the view's shims run names on the sandbox PATH, and the Environment sets no PATH") } @@ -126,8 +130,11 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env return nil, invalidSession("MCP: %v", err) } for _, b := range bindings { - if b.Transport != "http" { - return nil, unsupported("%s MCP server %q", b.Transport, b.ServerLabel) + switch { + case b.Transport == "stdio" && b.CredentialAuthority != "none": + return nil, fmt.Errorf("%w: admit: %w: stdio MCP server %q needs a credential", ErrUnsupported, agent.ErrViewHandoff, b.ServerLabel) + case b.Transport == "stdio" && !caps.StdioMCP.IsSupported(): + return nil, unsupported("stdio MCP server %q", b.ServerLabel) } } if err := checkLayout(cfg, view); err != nil { diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index de52668fa..c9b466705 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -15,6 +15,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" @@ -23,10 +24,11 @@ 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, "shimmed", -// whose view runs a shim name on the sandbox PATH, and "plain", which -// declares no view. +// viewFixture registers "viewed", whose factory records what it receives and +// whose view supports no capability, "supporting", whose view supports every +// capability but environment none and stdio MCP, "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 @@ -40,9 +42,10 @@ func newViewFixture(t *testing.T) *viewFixture { f := &viewFixture{} reg := agent.NewRegistry() view := agent.View{ - Closure: []agent.ViewMount{{Name: "harness", HostDir: t.TempDir()}}, - LocalExec: []string{"/.oac/harness/harness"}, - Proxy: agent.ViewProxyEnv, + Closure: []agent.ViewMount{{Name: "harness", HostDir: t.TempDir()}}, + LocalExec: []string{"/.oac/harness/harness"}, + Proxy: agent.ViewProxyEnv, + Capabilities: declared(proto.CapabilityUnsupported), Executor: func(_ context.Context, req proto.PromptRequestPayload, s agent.ViewSession) (agent.Executor, error) { f.req, f.session = req, s info, err := os.Stat(s.Home.Host) @@ -51,6 +54,10 @@ func newViewFixture(t *testing.T) *viewFixture { }, } register(reg, "viewed", &view) + supporting := view + supporting.Capabilities = declared(proto.CapabilitySupported) + supporting.Capabilities.EnvironmentNone, supporting.Capabilities.StdioMCP = proto.CapabilityUnsupported, proto.CapabilityUnsupported + register(reg, "supporting", &supporting) masked := view masked.Masks = []agent.ViewMask{{Path: "/etc/passwd"}} register(reg, "masked", &masked) @@ -64,28 +71,28 @@ func newViewFixture(t *testing.T) *viewFixture { func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { f := newViewFixture(t) + unsupported := []error{ErrUnsupported, agent.ErrUnsupportedOperation} for name, c := range map[string]struct { + kind string change func(*proto.PromptRequestPayload) want []error }{ - "kind without a view": {func(r *proto.PromptRequestPayload) { r.AgentKind = "plain" }, []error{ErrUnsupported, agent.ErrUnsupportedOperation}}, - "view meeting the agent host's /etc": {func(r *proto.PromptRequestPayload) { r.AgentKind = "masked" }, []error{ErrUnsupported, agent.ErrInvalidView}}, - "environment none": {func(r *proto.PromptRequestPayload) { - r.DisableExecutionEnvironment, r.LocalEnvironment = true, nil - }, []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.WorkspaceDirectory = "workspace" }, []error{ErrInvalidSession}}, - "no model provider": {func(r *proto.PromptRequestPayload) { r.ModelProvider = nil }, []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}}, + "kind without a view": {"plain", func(*proto.PromptRequestPayload) {}, unsupported}, + "view meeting the agent host's /etc": {"masked", func(*proto.PromptRequestPayload) {}, []error{ErrUnsupported, agent.ErrInvalidView}}, + "incomplete binding": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment = nil }, []error{ErrInvalidSession}}, + "shim name without PATH": {"shimmed", func(*proto.PromptRequestPayload) {}, []error{ErrInvalidSession}}, + "relative workspace": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.WorkspaceDirectory = "workspace" }, []error{ErrInvalidSession}}, + "no model provider": {"viewed", func(r *proto.PromptRequestPayload) { r.ModelProvider = nil }, []error{ErrUnsupported}}, + // The typed rejections that hold whatever the view declares. + "no strict resume": {"supporting", func(r *proto.PromptRequestPayload) { r.StrictResume = false }, unsupported}, + "restricted network": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.NetworkAccess = "disabled" }, unsupported}, + "allowed domains only": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.AllowedDomains = []string{"example.com"} }, unsupported}, + "unprepared Capabilities": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities = true }, unsupported}, + "credentialed stdio MCP": {"supporting", func(r *proto.PromptRequestPayload) { + r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools", EnvVars: []string{"TOKEN"}}}} + }, []error{ErrUnsupported, agent.ErrViewHandoff}}, } { - req := request("viewed", "/workspace", "https://model.test", "sk-test") + req := request(c.kind, "/workspace", "https://model.test", "sk-test") c.change(&req) var dials atomic.Int32 e, err := open(context.Background(), f.cfg, req, bindTo(newBinding(newResource())), deps{dial: countingDial(&dials), tasks: noTasks}) @@ -120,19 +127,57 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { } } +func TestAdmissionFollowsDeclarations(t *testing.T) { + f := newViewFixture(t) + roots, err := checkConfig(f.cfg) + if err != nil { + t.Fatal(err) + } + network := func(context.Context) (sandboxlink.Stream, error) { return nil, errors.New("not dialled") } + for name, c := range map[string]struct { + use func(*proto.PromptRequestPayload) + viewed, supporting bool + }{ + "environment none": {func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment, r.LocalEnvironment = true, nil }, false, false}, + "Skills": {func(r *proto.PromptRequestPayload) { + r.LocalEnvironment.Capabilities, r.LocalEnvironment.CapabilityRoot = true, "/capabilities" + r.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "skills/review"}} + }, false, true}, + "function tools": {func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "lookup"}} }, false, true}, + "tool search": {func(r *proto.PromptRequestPayload) { r.ToolSearch = true }, false, true}, + "stdio MCP": {func(r *proto.PromptRequestPayload) { + r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools"}}} + }, false, false}, + // An installation with only HTTP MCP needs no Skill support. + "installed HTTP MCP": {func(r *proto.PromptRequestPayload) { + r.LocalEnvironment.Capabilities, r.LocalEnvironment.CapabilityRoot = true, "/capabilities" + r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "docs", Type: "http", URL: "https://mcp.test/docs"}}} + }, true, true}, + } { + for kind, admitted := range map[string]bool{"viewed": c.viewed, "supporting": c.supporting} { + req := request(kind, "/workspace", "https://model.test", "sk-test") + c.use(&req) + if _, err := admit(f.cfg, roots, req, Environment{}, network); admitted != (err == nil) || (err != nil && !errors.Is(err, ErrUnsupported)) { + t.Errorf("%s on %s: admit = %v, want admitted %v or ErrUnsupported", name, kind, err, admitted) + } + } + } +} + func TestRegistryDescribesTheViewPath(t *testing.T) { f := newViewFixture(t) var kinds []string for _, info := range (&Host{cfg: f.cfg}).Registry(nil).SupportedAgentKinds() { kinds = append(kinds, info.Kind) - c := info.Capabilities + c, declared := info.Capabilities, info.Kind == "supporting" if !c.Preparation.IsSupported() || !c.LocalEnvironment.IsSupported() || !c.MCPHTTPTools.IsSupported() || c.EnvironmentNone.IsSupported() || - c.FunctionTools.IsSupported() || c.WorkspaceOutputExport.IsSupported() || c.WorkspaceReadPreparation.IsSupported() { + c.FunctionTools.IsSupported() != declared || c.FunctionResultImages.IsSupported() != declared || c.ToolSearch.IsSupported() != declared || + c.WorkspaceOutputExport.IsSupported() || c.WorkspaceReadPreparation.IsSupported() { t.Errorf("%s: capabilities %+v do not describe the view path", info.Kind, c) } } slices.Sort(kinds) - if !slices.Equal(kinds, []string{"masked", "shimmed", "viewed"}) { + if !slices.Equal(kinds, []string{"masked", "shimmed", "supporting", "viewed"}) { t.Errorf("kinds %v, want those that declare a view", kinds) } } diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index a5c2448df..41879e82b 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -80,6 +80,11 @@ func register(reg *agent.Registry, kind string, view *agent.View) { }}) } +// declared declares every view capability as s. +func declared(s proto.CapabilitySupport) agent.ViewCapabilities { + return agent.ViewCapabilities{EnvironmentNone: s, Skills: s, FunctionTools: s, FunctionResultImages: s, ToolSearch: s, StdioMCP: s} +} + // request is a Session request the agent host admits. func request(kind, workspace, baseURL, key string) proto.PromptRequestPayload { return proto.PromptRequestPayload{ diff --git a/apps/daemon/internal/agenthost/doc.go b/apps/daemon/internal/agenthost/doc.go index 067dacc17..2aaad44e7 100644 --- a/apps/daemon/internal/agenthost/doc.go +++ b/apps/daemon/internal/agenthost/doc.go @@ -34,11 +34,13 @@ // Host.Registry's Executor factory prepares an Executor of the Session that // its bind function binds the request to. It admits the request before any // effect: the kind must declare an agent.View, the request must use only -// what a view runs and no function tools, and when the view declares shim -// names, which run on the sandbox PATH, the Session's Environment must set -// PATH. The registry's Info marks what admission rejects, and what needs a -// local workspace, unsupported. The factory then allocates the Executor's -// uid, skipping each uid that a running thread holds as its real, +// what the view's agent.ViewCapabilities declare, and when the view declares +// shim names, which run on the sandbox PATH, the Session's Environment must +// set PATH. A Session without strict resume, with a restricted network, or +// with a stdio MCP server that needs a credential is rejected whatever the +// view declares. The registry's Info follows the declarations and marks what +// needs a local workspace unsupported. The factory then allocates the +// Executor's 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 prepares the Session directory under // Config.StateDir, rewrites the request so the model provider and HTTP MCP diff --git a/apps/daemon/internal/agenthost/executor_linux.go b/apps/daemon/internal/agenthost/executor_linux.go index 51f76db81..80c005cf9 100644 --- a/apps/daemon/internal/agenthost/executor_linux.go +++ b/apps/daemon/internal/agenthost/executor_linux.go @@ -45,10 +45,11 @@ func registry(harnesses *agent.Registry, factory agent.ExecutorFactory) *agent.R reg := agent.NewRegistry() for _, info := range harnesses.SupportedAgentKinds() { configuration, err := harnesses.Configuration(info.Kind) - if _, viewErr := harnesses.ResolveView(info.Kind); err != nil || viewErr != nil { + view, viewErr := harnesses.ResolveView(info.Kind) + if err != nil || viewErr != nil { continue } - reg.RegisterKind(viewInfo(info), configuration, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + reg.RegisterKind(viewInfo(info, view.Capabilities), configuration, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { return nil, unsupported("a direct prompt run") }) reg.RegisterExecutor(info.Kind, factory) @@ -56,14 +57,12 @@ func registry(harnesses *agent.Registry, factory agent.ExecutorFactory) *agent.R return reg } -// viewInfo is info as views run the kind: in the Session's Environment, and -// without what admission rejects or what needs a local workspace. -func viewInfo(info proto.SupportedAgentKind) proto.SupportedAgentKind { +// viewInfo is info as views run the kind: in the Session's Environment, with +// what caps admits, and without what needs a local workspace. +func viewInfo(info proto.SupportedAgentKind, caps agent.ViewCapabilities) proto.SupportedAgentKind { c := &info.Capabilities - c.LocalEnvironment = proto.CapabilitySupported - for _, field := range []*proto.CapabilitySupport{&c.EnvironmentNone, &c.ToolSearch, &c.FunctionTools, &c.FunctionResultImages, &c.WorkspaceOutputExport} { - *field = proto.CapabilityUnsupported - } + c.LocalEnvironment, c.WorkspaceOutputExport = proto.CapabilitySupported, proto.CapabilityUnsupported + c.EnvironmentNone, c.FunctionTools, c.FunctionResultImages, c.ToolSearch = caps.EnvironmentNone, caps.FunctionTools, caps.FunctionResultImages, caps.ToolSearch return info } diff --git a/apps/daemon/internal/agenthost/view_linux_test.go b/apps/daemon/internal/agenthost/view_linux_test.go index 418d2aa27..6b8b8c2d6 100644 --- a/apps/daemon/internal/agenthost/view_linux_test.go +++ b/apps/daemon/internal/agenthost/view_linux_test.go @@ -94,11 +94,12 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { } copyExecutable(t, filepath.Join(closure, "harness")) 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}}, - LocalExec: []string{harnessPath}, - ShimPaths: []string{"/bin/sh"}, - Proxy: agent.ViewProxyNone, + 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, + Capabilities: declared(proto.CapabilityUnsupported), Executor: func(_ context.Context, req proto.PromptRequestPayload, s agent.ViewSession) (agent.Executor, error) { return &testExecutor{session: s, dir: req.LocalEnvironment.WorkspaceRoot, env: []string{harnessEnv + "=1", modelEnv + "=" + req.ModelProvider.BaseURL, caEnv + "=" + cfg.CADir}}, nil diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 03ebf4a52..c028c6248 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -274,6 +274,7 @@ An agent host runs the Harness outside the sandbox, in a per-Session view. The v | `ShimPaths` | View paths the shim is bound over; each runs the same path in the sandbox | | `ForwardEnv` | Harness variables that a process run in the sandbox keeps | | `Proxy` | `ViewProxyEnv` or `ViewProxyNone` | +| `Capabilities` | What the view runs ([Capabilities](#capabilities)) | | `Executor` | The `ViewExecutorFactory` that prepares the Session's Executor in its view | `View.Validate` checks the declaration without touching the host: @@ -282,11 +283,29 @@ An agent host runs the Harness outside the sandbox, in a per-Session view. The v - closure names are single path components other than `bin`, `home` and `run`, which the agent host uses for the shims, the Session home and the process relay; - shim paths, overlays and masks do not overlap each other or `/`, and stay out of the trees the view builds itself: `/.oac`, `/proc` and `/dev` (`ViewReserved`); - each `LocalExec` entry lies in a closure directory or an `Exec` overlay; -- shim names and `ForwardEnv` names are unique, no shim is named `oac-process-shim`, which is the process relay's, a variable name contains no `=`, and `ForwardEnv` names no variable the view or the broker sets ([Environment](#environment)); +- shim names and `ForwardEnv` names are unique, no shim is named `oac-process-shim`, which is the process relay's, or starts with `oac-mcp-`, which [stdio aliases](#stdio-mcp) use, a variable name contains no `=`, and `ForwardEnv` names no variable the view or the broker sets ([Environment](#environment)); +- every `Capabilities` field is `proto.CapabilitySupported` or `proto.CapabilityUnsupported`; - `Proxy` is one of the two values and `Executor` is non-nil. `harness.go` defines the view layout once, and `sessionview` builds views from it. The agent host checks its own overlays, such as `/etc/passwd`, against the declaration when it builds the view. +### Capabilities + +`View.Capabilities` declares each feature the view runs, and the agent host admits a request before any effect only when the view supports each feature the request uses. The registry the agent host gives dispatch derives `EnvironmentNone`, `FunctionTools`, `FunctionResultImages` and `ToolSearch` from it. + +| Field | A request that uses it | +| --- | --- | +| `EnvironmentNone` | Sets `DisableExecutionEnvironment` ([Environment none](#environment-none)) | +| `Skills` | Has resolved Skills (`LocalEnvironment.Skills`) | +| `FunctionTools`, `FunctionResultImages`, `ToolSearch` | Uses the feature of the same `AgentKindCapabilities` name | +| `StdioMCP` | Has a stdio MCP binding ([Stdio MCP](#stdio-mcp)) | + +Whatever the view declares, the agent host rejects with `ErrUnsupportedOperation` a request without strict resume, one whose installed Capabilities no preparation resolved, and one with a restricted network, because only the Provider's workload network boundary can contain a process's own sockets. It rejects a stdio binding that needs a credential with `ErrViewHandoff`. + +### Environment none + +A request with `DisableExecutionEnvironment` runs in an empty-root view: a read-only, noexec tmpfs at `/` that holds only the mountpoints for the closure, the Session home, the agent host's runtime files, `/proc`, `/dev` and the overlays. It has no sandbox files, no shims, no Link attachment and no sandbox network, so the generic proxy refuses every request; the cgroup, the isolation and the gateway stay. The request carries no `LocalEnvironment`, and the Harness runs in `/.oac/home/work` (`ViewWorkName`). The request already expresses the profile, so the wire has no field for it. A request with neither `LocalEnvironment` nor `DisableExecutionEnvironment` is an incomplete binding, and the agent host rejects it. + ### Executables Only mount flags grant execution. The closure, `Exec` overlays and the shim are read-only and are the only executable mounts; the sandbox's files and the home are noexec. `Launch` and `Spawn` accept only a `LocalExec` path as `Binary` and otherwise return `ErrNotLocalExec`. A dynamic binary, such as `node`, needs its ELF interpreter as an `Exec` overlay at its `PT_INTERP` path, and every library it loads in the closure, reached through `LD_LIBRARY_PATH`. Nothing loads from the sandbox's files. `viewloader.For` builds this from the binaries' ELF headers: the interpreter's host directory as the `lib` closure mount, the interpreter overlay, empty masks over `/etc/ld.so.preload` and `/etc/ld.so.cache`, and the `LD_LIBRARY_PATH` value. A layout it cannot present, such as a library outside the interpreter's directory, returns `ErrUnsupportedOperation`. @@ -297,20 +316,24 @@ The agent host derives the process broker's table from the declaration: `/.oac/b ### Environment -`Launch` takes the complete Harness environment in `StartOptions.Env`. The agent host's own environment never passes through, so a view adapter does not start from `os.Environ()`. A process run in the sandbox gets the broker's environment: the `ForwardEnv` variables from the Harness, the Environment's fixed sandbox values (`HOME`, `PATH`, `TMPDIR` and `LANG`) and the Environment's tool environment. The broker is the only home of the tool environment, and a view adapter passes none of it to the Harness. +`Launch` takes the complete Harness environment in `StartOptions.Env`, which the adapter derives from its installation and the request's typed fields; the request carries no environment values. The agent host's own environment never passes through, so a view adapter does not start from `os.Environ()`. A process run in the sandbox gets the broker's environment: the `ForwardEnv` variables from the Harness, the Environment's fixed sandbox values (`HOME`, `PATH`, `TMPDIR` and `LANG`) and the Environment's tool environment. The broker is the only home of the tool environment, and a view adapter passes none of it to the Harness. The agent host keeps model and MCP credentials only in the gateway's protected configuration and adds none to the Harness's environment, a Process spec or a capability tree the view exposes. `ForwardEnv` never names a variable the view or the broker sets: `HOME`, `PATH`, `TMPDIR`, `LANG`, `LD_LIBRARY_PATH`, or `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` and `NO_PROXY` in any case. When the Environment's tool environment also sets a forwarded variable, the tool environment's value wins. ### Endpoints and proxy -Before it calls the factory, the agent host points the request's `model_provider` at the Session's [credential gateway](./model-execution.md#credential-gateway): `base_url` is `http://127.0.0.1:` with no path and `api_key` is `modelprovider.Placeholder`. It resolves the Session's MCP once, from the public declarations and the installed Environment MCP, into `ViewSession.MCP`, and removes both from the request. Each HTTP binding points at its gateway URL and carries no bearer and no headers; the gateway adds the declared credential and headers. A stdio binding is as resolved and runs in the sandbox through the declared shims. A view Executor takes MCP only from `ViewSession.MCP` and never resolves the request. The adapter renders the provider and the bindings as it does for a local Harness and never sees a real credential. +Before it calls the factory, the agent host points the request's `model_provider` at the Session's [credential gateway](./model-execution.md#credential-gateway): `base_url` is `http://127.0.0.1:` with no path and `api_key` is `modelprovider.Placeholder`. It resolves the Session's MCP once, from the public declarations and the installed Environment MCP, into `ViewSession.MCP`, and removes both from the request. Only HTTP bindings go to the gateway: each points at its gateway URL and carries no bearer and no headers, and the gateway adds the declared credential and headers. A stdio binding runs under its [alias](#stdio-mcp). A view Executor takes MCP only from `ViewSession.MCP` and never resolves the request. The adapter renders the provider and the bindings as it does for a local Harness and never sees a real credential. -The Registry checks each view request once, before the factory, and rejects it with `ErrViewHandoff` when its model provider is missing or is not the gateway with the placeholder, when it carries MCP outside `ViewSession.MCP`, or when an HTTP binding is not a credential-free loopback endpoint. +The Registry checks each view request once, before the factory, and rejects it with `ErrViewHandoff` when its model provider is missing or is not the gateway with the placeholder, when it carries MCP outside `ViewSession.MCP`, when an HTTP binding is not a credential-free loopback endpoint, or when a stdio binding is not its alias. With `ViewProxyEnv`, `ViewSession.Proxy` is the gateway's proxy URL. The adapter sets `HTTPS_PROXY` and `HTTP_PROXY` to it and `NO_PROXY` to `127.0.0.1,localhost`, each in upper and lower case. Declare `ViewProxyEnv` only after qualifying that every request the Harness makes locally honours these variables. A request that ignores them fails to connect, because the view has no route out. With `ViewProxyNone`, `ViewSession.Proxy` is empty and the view has no generic proxy. Admission rejects a request that enables a feature needing one with `ErrUnsupportedOperation`. Web tools that the provider executes keep provider origin. +### Stdio MCP + +A stdio binding runs in the sandbox under its alias. The binding at index `i` of `ViewSession.MCP` has exactly `Stdio: {Server: {Name: ServerLabel, Type: "stdio", Command: agent.ViewAlias(i)}}`, a name under `/.oac/bin` with the `oac-mcp-` prefix, and the Harness runs that path without arguments. The process broker maps the alias to the binding's frozen command, args and `CWD`, a relative `CWD` resolving against the installation's package root, and runs it as it runs a shim's process, with nothing from the Harness's argv, working directory or environment. A stdio binding whose credential authority is not `none` is rejected with `ErrViewHandoff`. + ### Home `ViewSession.Home` is the per-Session native home. The adapter writes at `Home.Host`, and the Harness sees the same directory at `Home.View` (`/.oac/home`), read-write and noexec. It persists across the Session's Executors. Lay out native directories and write configuration under it before calling `Launch`. `Launch` gives the tree to the Session user without following links; after that, read the home without following links. @@ -344,6 +367,7 @@ Run the adapter's Turns, cancellation and continuation in a view, then qualify e | `ForwardEnv` | A process run in the sandbox keeps each declared variable and no other Harness variable. | | `Proxy` | With `ViewProxyEnv`, every local request, such as web fetches, downloads and update checks, goes through the proxy. With `ViewProxyNone`, a request enabling a feature that needs it is rejected. | | `Home` | Native history and configuration stay under `/.oac/home`, and a later Executor in the same Session continues from them. | +| `Capabilities` | Each supported feature runs a Turn through dispatch: environment none in the empty-root view, Skills, function calls and results, tool search, and each stdio binding under its alias. | `scripts/qualify-agent-host.sh` runs one Turn per Harness through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. Each Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 57c28f9b5..6e3c61c9b 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "将原生 Harness 添加到 OpenAgentCore" source: contracts/agents-api/harness-onboarding.md -source_hash: 3d74f674c1cc68fbf40c2fe15b81c30f7fe69b6731da779fb75003b5f438e5f4 +source_hash: a8ad85b32c64437d4cf46bd6366ade5e145d018ecdec9f65c7c77bd40558112a --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -276,6 +276,7 @@ agent host 在沙箱之外、在每个 Session 一个的视图中运行 Harness | `ShimPaths` | 绑定 shim 的视图路径;每个路径在沙箱中运行相同路径 | | `ForwardEnv` | 在沙箱中运行的进程保留的 Harness 变量 | | `Proxy` | `ViewProxyEnv` 或 `ViewProxyNone` | +| `Capabilities` | 视图运行的功能([能力](#capabilities)) | | `Executor` | 在 Session 的视图中准备其 Executor 的 `ViewExecutorFactory` | `View.Validate` 在不访问主机的情况下检查声明: @@ -284,11 +285,29 @@ agent host 在沙箱之外、在每个 Session 一个的视图中运行 Harness - closure 名称是单个路径分量,且不是 `bin`、`home` 和 `run`,这三个由 agent host 用于 shim、Session home 和进程 relay; - shim 路径、overlay 和 mask 互不重叠,也不与 `/` 重叠,并且不进入视图自己构建的树:`/.oac`、`/proc` 和 `/dev`(`ViewReserved`); - 每个 `LocalExec` 条目都位于某个 closure 目录或某个 `Exec` overlay 中; -- shim 名称和 `ForwardEnv` 名称各自唯一,没有 shim 名为 `oac-process-shim`(该名称属于进程 relay),变量名不含 `=`,且 `ForwardEnv` 不指定视图或 broker 设置的变量([环境](#environment)); +- shim 名称和 `ForwardEnv` 名称各自唯一,没有 shim 名为 `oac-process-shim`(该名称属于进程 relay)或以 `oac-mcp-` 开头(该前缀属于 [stdio 别名](#stdio-mcp)),变量名不含 `=`,且 `ForwardEnv` 不指定视图或 broker 设置的变量([环境](#environment)); +- 每个 `Capabilities` 字段都是 `proto.CapabilitySupported` 或 `proto.CapabilityUnsupported`; - `Proxy` 是两个取值之一,且 `Executor` 非 nil。 `harness.go` 只定义一次视图布局,`sessionview` 据此构建视图。agent host 在构建视图时,用声明检查它自己的 overlay,例如 `/etc/passwd`。 +### 能力 {#capabilities} + +`View.Capabilities` 声明视图运行的每项功能。只有当视图支持请求使用的每项功能时,agent host 才会在产生任何副作用之前准入该请求。agent host 交给 dispatch 的 registry 据此推导 `EnvironmentNone`、`FunctionTools`、`FunctionResultImages` 和 `ToolSearch`。 + +| 字段 | 使用该功能的请求 | +| --- | --- | +| `EnvironmentNone` | 设置了 `DisableExecutionEnvironment`([Environment none](#environment-none)) | +| `Skills` | 带有已解析的 Skills(`LocalEnvironment.Skills`) | +| `FunctionTools`、`FunctionResultImages`、`ToolSearch` | 使用 `AgentKindCapabilities` 中同名的功能 | +| `StdioMCP` | 带有 stdio MCP 绑定([Stdio MCP](#stdio-mcp)) | + +无论视图如何声明,agent host 都以 `ErrUnsupportedOperation` 拒绝没有严格恢复的请求、已安装的 Capabilities 未经任何准备解析的请求,以及带受限网络的请求,因为只有 Provider 的工作负载网络边界才能约束进程自己的 socket。它以 `ErrViewHandoff` 拒绝需要凭据的 stdio 绑定。 + +### Environment none {#environment-none} + +设置了 `DisableExecutionEnvironment` 的请求在空根视图中运行:`/` 是只读、noexec 的 tmpfs,只包含 closure、Session home、agent host 运行时文件、`/proc`、`/dev` 和 overlay 的挂载点。它没有沙箱文件、没有 shim、没有 Link 附着,也没有沙箱网络,因此通用代理拒绝每个请求;cgroup、隔离和网关保持不变。请求不携带 `LocalEnvironment`,Harness 在 `/.oac/home/work`(`ViewWorkName`)中运行。请求本身已经表达了这一配置,因此线协议没有对应字段。既没有 `LocalEnvironment` 也没有 `DisableExecutionEnvironment` 的请求是不完整的绑定,agent host 会拒绝它。 + ### 可执行文件 {#executables} 只有挂载标志授予执行权限。closure、`Exec` overlay 和 shim 是只读的,也是仅有的可执行挂载;沙箱的文件和 home 都是 noexec。`Launch` 和 `Spawn` 只接受 `LocalExec` 路径作为 `Binary`,否则返回 `ErrNotLocalExec`。动态二进制(例如 `node`)需要把它的 ELF 解释器作为 `Exec` overlay 放在其 `PT_INTERP` 路径上,并且它加载的每个库都要在 closure 中,通过 `LD_LIBRARY_PATH` 找到。任何内容都不从沙箱的文件加载。`viewloader.For` 根据二进制的 ELF header 构建这些内容:解释器所在的主机目录作为 `lib` closure 挂载、解释器 overlay、覆盖 `/etc/ld.so.preload` 和 `/etc/ld.so.cache` 的空 mask,以及 `LD_LIBRARY_PATH` 的值。它无法呈现的布局(例如位于解释器目录之外的库)返回 `ErrUnsupportedOperation`。 @@ -299,20 +318,24 @@ agent host 根据声明推导进程 broker 的映射表:`/.oac/bin/` 在 ### 环境 {#environment} -`Launch` 在 `StartOptions.Env` 中接收完整的 Harness 环境。agent host 自身的环境从不传入,因此视图适配器不从 `os.Environ()` 开始构造。在沙箱中运行的进程获得 broker 的环境:来自 Harness 的 `ForwardEnv` 变量、Environment 固定的沙箱值(`HOME`、`PATH`、`TMPDIR` 和 `LANG`)以及 Environment 的工具环境。broker 是工具环境的唯一归属,视图适配器不向 Harness 传递任何工具环境。 +`Launch` 在 `StartOptions.Env` 中接收完整的 Harness 环境,适配器根据自己的安装和请求的类型化字段推导该环境;请求不携带任何环境值。agent host 自身的环境从不传入,因此视图适配器不从 `os.Environ()` 开始构造。在沙箱中运行的进程获得 broker 的环境:来自 Harness 的 `ForwardEnv` 变量、Environment 固定的沙箱值(`HOME`、`PATH`、`TMPDIR` 和 `LANG`)以及 Environment 的工具环境。broker 是工具环境的唯一归属,视图适配器不向 Harness 传递任何工具环境。agent host 只把模型和 MCP 凭据保存在网关受保护的配置中,从不把它们加入 Harness 的环境、Process spec 或视图暴露的能力树。 `ForwardEnv` 从不指定视图或 broker 设置的变量:`HOME`、`PATH`、`TMPDIR`、`LANG`、`LD_LIBRARY_PATH`,以及任意大小写的 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 和 `NO_PROXY`。当 Environment 的工具环境也设置了某个转发变量时,以工具环境的值为准。 ### 端点与代理 {#endpoints-and-proxy} -调用工厂之前,agent host 将请求的 `model_provider` 指向 Session 的[凭据网关](./model-execution.md#credential-gateway):`base_url` 是不带路径的 `http://127.0.0.1:`,`api_key` 是 `modelprovider.Placeholder`。它把公开声明和已安装的 Environment MCP 一次性解析为 Session 的 MCP,放入 `ViewSession.MCP`,并从请求中移除这两者。每个 HTTP 绑定指向其网关 URL,不携带 bearer,也不携带 header;网关添加声明的凭据和 header。stdio 绑定保持解析结果,并通过声明的 shim 在沙箱中运行。视图 Executor 只从 `ViewSession.MCP` 获取 MCP,从不解析请求。适配器像对待本地 Harness 一样渲染提供商和绑定,从不接触真实凭据。 +调用工厂之前,agent host 将请求的 `model_provider` 指向 Session 的[凭据网关](./model-execution.md#credential-gateway):`base_url` 是不带路径的 `http://127.0.0.1:`,`api_key` 是 `modelprovider.Placeholder`。它把公开声明和已安装的 Environment MCP 一次性解析为 Session 的 MCP,放入 `ViewSession.MCP`,并从请求中移除这两者。只有 HTTP 绑定进入网关:每个 HTTP 绑定指向其网关 URL,不携带 bearer,也不携带 header,网关添加声明的凭据和 header。stdio 绑定在其[别名](#stdio-mcp)下运行。视图 Executor 只从 `ViewSession.MCP` 获取 MCP,从不解析请求。适配器像对待本地 Harness 一样渲染提供商和绑定,从不接触真实凭据。 -Registry 在调用工厂之前对每个视图请求检查一次,并在以下情况下以 `ErrViewHandoff` 拒绝:模型提供商缺失或不是带占位凭据的网关、请求在 `ViewSession.MCP` 之外携带 MCP,或 HTTP 绑定不是不含凭据的 loopback 端点。 +Registry 在调用工厂之前对每个视图请求检查一次,并在以下情况下以 `ErrViewHandoff` 拒绝:模型提供商缺失或不是带占位凭据的网关、请求在 `ViewSession.MCP` 之外携带 MCP、HTTP 绑定不是不含凭据的 loopback 端点,或 stdio 绑定不是其别名。 使用 `ViewProxyEnv` 时,`ViewSession.Proxy` 是网关的代理 URL。适配器将 `HTTPS_PROXY` 和 `HTTP_PROXY` 设为该值,将 `NO_PROXY` 设为 `127.0.0.1,localhost`,每个变量都设置大写和小写两种形式。只有在确认 Harness 在本地发出的每个请求都遵循这些变量之后,才声明 `ViewProxyEnv`。忽略这些变量的请求会连接失败,因为视图没有出站路由。 使用 `ViewProxyNone` 时,`ViewSession.Proxy` 为空,视图没有通用代理。准入以 `ErrUnsupportedOperation` 拒绝启用了需要代理的功能的请求。由提供商执行的 Web 工具保持提供商来源。 +### Stdio MCP {#stdio-mcp} + +stdio 绑定在沙箱中以其别名运行。`ViewSession.MCP` 中索引为 `i` 的绑定恰好是 `Stdio: {Server: {Name: ServerLabel, Type: "stdio", Command: agent.ViewAlias(i)}}`,即 `/.oac/bin` 下带 `oac-mcp-` 前缀的名称,Harness 不带参数运行该路径。进程 broker 把别名映射到绑定冻结的 command、args 和 `CWD`(相对 `CWD` 以安装的 package 根目录为基准),并像运行 shim 的进程一样运行它,不使用 Harness 的 argv、工作目录或环境中的任何内容。凭据权限不是 `none` 的 stdio 绑定会以 `ErrViewHandoff` 被拒绝。 + ### Home {#home} `ViewSession.Home` 是每个 Session 的原生 home。适配器在 `Home.Host` 写入,Harness 在 `Home.View`(`/.oac/home`)看到同一目录,可读写且 noexec。它在 Session 的各个 Executor 之间保留。调用 `Launch` 之前,在其中布置原生目录并写入配置。`Launch` 在不跟随链接的情况下把该目录树交给 Session 用户;此后读取 home 时也不跟随链接。 @@ -346,6 +369,7 @@ Registry 在调用工厂之前对每个视图请求检查一次,并在以下 | `ForwardEnv` | 在沙箱中运行的进程保留每个声明的变量,且不保留任何其他 Harness 变量。 | | `Proxy` | 使用 `ViewProxyEnv` 时,每个本地请求(例如网页抓取、下载和更新检查)都经过代理。使用 `ViewProxyNone` 时,启用需要代理的功能的请求会被拒绝。 | | `Home` | 原生历史和配置保存在 `/.oac/home` 下,同一 Session 中后续的 Executor 从中继续。 | +| `Capabilities` | 每项受支持的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、Skills、函数调用及其结果、工具搜索,以及每个以别名运行的 stdio 绑定。 | `scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 为每个 Harness 运行一个 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。每个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 From 91007efa6a3b570ce3d1263a995736d07d107d49 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 17:03:11 +0800 Subject: [PATCH 2/4] Delete the read-only preparation factories and the unenforced initialization network (#474) * Delete the unenforced Runtime initialization network field Core always sent network "enabled" for package and setup initialization, and the Runtime only checked the value without enforcing it. Initialization uses the host's existing network, as the Environment contract states. * Delete the read-only preparation factories Read-only execution_prepare is always served from the Runtime's bound local workspace, so the per-Harness preparation factories only acted as a registration gate. Dispatch now readies the local preparation directly, gated by the declared WorkspaceReadPreparation capability. Delete agent.PreparationFactory, Prepared, PreparedCancellation, Runtime.Preparation, Runtime.WorkspaceReadPreparation, Registry.RegisterPreparation, ResolvePreparation and the Codex, Claude SDK and MiniMax Code factories with their wrappers and tests. Adapters declare WorkspaceReadPreparation beside LocalEnvironment. Executor tests that went through the wrappers now use the Executor directly. * Delete the Claude session's unused drain * Restore the Claude cancellation-wait fixture --- .../agent/claudesdk/commands_session_test.go | 10 +- .../internal/agent/claudesdk/contracts.go | 5 - .../internal/agent/claudesdk/declaration.go | 4 - .../agent/claudesdk/declaration_test.go | 2 +- .../internal/agent/claudesdk/preparation.go | 116 --------- .../claudesdk/preparation_fixture_test.go | 4 - .../agent/claudesdk/preparation_test.go | 186 ++------------ .../internal/agent/claudesdk/session.go | 17 -- .../internal/agent/claudesdk/unsupported.go | 4 - .../agent/claudesdk/unsupported_test.go | 2 +- .../agent/claudesdk/workspace_directory.go | 15 -- .../claudesdk/workspace_directory_test.go | 64 +++-- .../claudesdk/workspace_live_linux_test.go | 93 +------ .../agent/claudesdk/workspace_read.go | 15 -- .../agent/claudesdk/workspace_read_test.go | 62 +++-- .../claudesdk/workspace_structured_test.go | 7 +- apps/daemon/internal/agent/codex/contracts.go | 5 - .../internal/agent/codex/declaration.go | 11 +- .../internal/agent/codex/declaration_test.go | 4 +- .../internal/agent/codex/preparation.go | 9 - apps/daemon/internal/agent/codex/prepared.go | 43 +--- .../agent/codex/prepared_cancel_test.go | 228 ------------------ .../internal/agent/codex/prepared_test.go | 7 +- .../internal/agent/codex/recovery_test.go | 2 +- .../internal/agent/codex/unsupported.go | 12 - .../internal/agent/codex/unsupported_test.go | 6 +- .../internal/agent/configuration_test.go | 8 +- .../agent/contract_declarations_test.go | 8 +- apps/daemon/internal/agent/harness.go | 66 +---- apps/daemon/internal/agent/mcode/contracts.go | 5 - .../internal/agent/mcode/declaration.go | 4 - .../internal/agent/mcode/declaration_test.go | 2 +- .../agent/mcode/environment_mcp_test.go | 16 +- .../internal/agent/mcode/executor_test.go | 20 ++ .../internal/agent/mcode/preparation.go | 87 ------- .../internal/agent/mcode/preparation_test.go | 165 ------------- .../internal/agent/mcode/unsupported.go | 12 - .../internal/agent/mcode/unsupported_test.go | 6 +- apps/daemon/internal/agent/registry.go | 12 - .../agenthost/agenthost_linux_test.go | 2 +- .../internal/agenthost/executor_linux.go | 3 +- .../internal/dispatch/local_directory.go | 17 +- .../internal/dispatch/local_directory_test.go | 8 +- apps/daemon/internal/dispatch/preparation.go | 45 +--- .../dispatch/preparation_cancel_test.go | 43 +--- .../dispatch/preparation_cleanup_test.go | 10 +- .../preparation_executor_fixture_test.go | 29 ++- .../internal/dispatch/preparation_test.go | 17 +- .../prepared_handoff_mutation_test.go | 6 +- .../dispatch/prepared_handoff_test.go | 14 +- .../runtime_preparation_execution_test.go | 3 +- .../dispatch/workspace_directory_test.go | 2 +- .../workspace_preparation_failure_test.go | 81 ------- .../workspace_preparation_status_test.go | 3 - .../localworkspace/runtime_initialization.go | 3 - .../runtime_initialization_test.go | 8 +- contracts/agents-api/harness-onboarding.md | 16 +- contracts/agents-api/zh/harness-onboarding.md | 18 +- docs/runtime-protocol.md | 2 +- docs/zh/runtime-protocol.md | 4 +- internal/agentdaemon/proto/runtime_prepare.go | 8 +- .../agentdaemon/proto/runtime_prepare_test.go | 21 +- .../core/internal/execution/runtime_setup.go | 5 +- .../runtimegateway/runtime_prepare_test.go | 2 +- 64 files changed, 266 insertions(+), 1448 deletions(-) delete mode 100644 apps/daemon/internal/agent/claudesdk/preparation.go delete mode 100644 apps/daemon/internal/agent/codex/prepared_cancel_test.go delete mode 100644 apps/daemon/internal/agent/mcode/preparation.go delete mode 100644 apps/daemon/internal/agent/mcode/preparation_test.go delete mode 100644 apps/daemon/internal/dispatch/workspace_preparation_failure_test.go diff --git a/apps/daemon/internal/agent/claudesdk/commands_session_test.go b/apps/daemon/internal/agent/claudesdk/commands_session_test.go index de9454556..12becd34a 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_session_test.go @@ -21,7 +21,7 @@ func TestWorkspaceCommandsRequirePackagedFeatureOnlyWhenRequested(t *testing.T) config := preparationFixture(t, "old-command-runtime") req := preparationRequest() req.ObserveToolObservations = observed - resource, err := NewPreparationFactory(config)(t.Context(), req) + resource, err := NewExecutorFactory(config)(t.Context(), req) if observed { if err == nil || !strings.Contains(err.Error(), "workspace command observations") { t.Fatal("old bridge accepted requested command observations", err) @@ -33,7 +33,7 @@ func TestWorkspaceCommandsRequirePackagedFeatureOnlyWhenRequested(t *testing.T) if err != nil { t.Fatal("old bridge changed opt-out behavior", err) } - if err := resource.Close(); err != nil { + if err := resource.Close(t.Context()); err != nil { t.Fatal(err) } } @@ -45,16 +45,16 @@ func TestWorkspaceCommandFramesKeepStartIdentityAndObservedOutput(t *testing.T) config := preparationFixture(t, "commands-success") req := preparationRequest() req.ObserveToolObservations = observed - resource, err := NewPreparationFactory(config)(t.Context(), req) + resource, err := NewExecutorFactory(config)(t.Context(), req) if err != nil { t.Fatal(err) } - defer resource.Close() + defer resource.Close(context.Background()) if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { t.Fatal("preparation submitted a command") } out := make(chan proto.Envelope, 16) - s, err := resource.Start(t.Context(), "actual-command-run", proto.TextInput("hello"), out) + s, err := resource.StartTurn(t.Context(), "actual-command-run", proto.TextInput("hello"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/contracts.go b/apps/daemon/internal/agent/claudesdk/contracts.go index 4683fd3aa..c432e9b34 100644 --- a/apps/daemon/internal/agent/claudesdk/contracts.go +++ b/apps/daemon/internal/agent/claudesdk/contracts.go @@ -16,12 +16,7 @@ var ( _ agent.WorkspaceReader = (*session)(nil) _ agent.WorkspaceDirectoryLister = (*session)(nil) _ agent.WorkspaceWriter = (*session)(nil) - _ agent.Prepared = (*prepared)(nil) - _ agent.PreparedCancellation = (*prepared)(nil) _ agent.WorkspaceReader = (*executor)(nil) _ agent.WorkspaceDirectoryLister = (*executor)(nil) _ agent.WorkspaceWriter = (*executor)(nil) - _ agent.WorkspaceReader = (*prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*prepared)(nil) - _ agent.WorkspaceWriter = (*prepared)(nil) ) diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index 9970097c6..840861b26 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -143,10 +143,6 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d } out.Session = NewFactory(config) out.Executor = NewExecutorFactory(config) - if out.Info.Capabilities.LocalEnvironment.IsSupported() { - out.Preparation = NewPreparationFactory(config) - out.WorkspaceReadPreparation = true - } // The view runs the same install; its probe stays on this host. if view, err := newView(Config{Node: node, Entrypoint: entrypoint}, info); err != nil { fmt.Fprintf(options.Stderr, "oac-daemon: Claude SDK agent-host view unavailable: %v\n", err) diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index 149ef8f65..525a2b65f 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -116,7 +116,7 @@ func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { } continue } - if calls != 1 || runtime.Info.Available != ready || (runtime.Executor != nil) != ready || runtime.Preparation != nil { + if calls != 1 || runtime.Info.Available != ready || (runtime.Executor != nil) != ready || runtime.Info.Capabilities.WorkspaceReadPreparation.IsSupported() { t.Fatalf("runtime: %+v", runtime) } registry := agent.NewRegistry() diff --git a/apps/daemon/internal/agent/claudesdk/preparation.go b/apps/daemon/internal/agent/claudesdk/preparation.go deleted file mode 100644 index af9df8e01..000000000 --- a/apps/daemon/internal/agent/claudesdk/preparation.go +++ /dev/null @@ -1,116 +0,0 @@ -package claudesdk - -import ( - "context" - "errors" - "sync" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// prepared is a single admission for direct adapter callers. It uses the same -// Executor as pooled Runtime execution; workspace reads retain the owner. -type prepared struct { - mu sync.Mutex - executor *executor - session *session - binding *preparedStart - closed bool -} - -type preparedStart struct { - turn agent.Turn - done chan struct{} -} - -func NewPreparationFactory(config Config) agent.PreparationFactory { - factory := NewExecutorFactory(config) - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if config.Workspace == nil { - return nil, errors.New("claudesdk: workspace preparation requires a bound workspace") - } - resource, err := factory(ctx, req) - if resource == nil { - return nil, err - } - e := resource.(*executor) - return &prepared{executor: e, session: e.base}, err - } -} - -func (p *prepared) Start(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return nil, errors.New("claudesdk: admission is unavailable") - } - binding := &preparedStart{done: make(chan struct{})} - p.binding = binding - p.mu.Unlock() - turn, err := p.executor.StartTurn(ctx, run, input, out) - p.mu.Lock() - binding.turn = turn - if turn == nil { - p.binding = nil - } - close(binding.done) - p.mu.Unlock() - if turn != nil { - go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = p.executor.Close(context.Background()) }() - } - return turn, err -} - -func (p *prepared) Close() error { - p.mu.Lock() - if p.binding != nil { - p.mu.Unlock() - return nil - } - p.closed = true - p.mu.Unlock() - return p.executor.Close(context.Background()) -} - -func (p *prepared) Cancel(ctx context.Context) error { - if ctx == nil { - ctx = context.Background() - } - p.mu.Lock() - p.closed = true - binding := p.binding - p.mu.Unlock() - if binding == nil { - return p.executor.Close(ctx) - } - select { - case <-binding.done: - default: - if err := p.executor.Close(ctx); err != nil { - return err - } - select { - case <-binding.done: - case <-ctx.Done(): - return ctx.Err() - } - } - if binding.turn == nil { - return p.executor.Close(ctx) - } - if err := binding.turn.Cancel(ctx); err != nil { - return err - } - p.executor.retire() - return nil -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - p.mu.Lock() - defer p.mu.Unlock() - if p.binding == nil || p.binding.turn == nil { - return proto.DonePayload{} - } - return p.binding.turn.CancellationOutcome() -} diff --git a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 224a25feb..081a199b9 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -106,10 +106,6 @@ func runPreparationHelper() { _ = json.Unmarshal(fields["turn_id"], &turnID) emit, finish := helperTurnOutput(scanner, turnID) defer finish() - if mode == "cancellation" { - runCancellationHelper(request, "cancellation-wait", scanner, emit) - return - } if strings.HasPrefix(mode, "commands") { runCommandsHelper(request, mode, scanner, emit) return diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index fbd58f088..434d1dcbc 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -5,12 +5,9 @@ package claudesdk import ( "context" "encoding/json" - "errors" "os" "path/filepath" - "reflect" "strings" - "sync" "syscall" "testing" "time" @@ -24,15 +21,15 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { req := preparationRequest() ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() - result := make(chan agent.Prepared, 1) + result := make(chan agent.Executor, 1) failed := make(chan error, 1) go func() { - p, err := NewPreparationFactory(config)(ctx, req) + e, err := NewExecutorFactory(config)(ctx, req) if err != nil { failed <- err return } - result <- p + result <- e }() raw := waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")) select { @@ -45,17 +42,17 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { if err := os.WriteFile(filepath.Join(config.StateDir, "ready"), nil, 0o600); err != nil { t.Fatal(err) } - var preparedResource agent.Prepared + var resource agent.Executor select { - case preparedResource = <-result: + case resource = <-result: case err := <-failed: t.Fatal(err) case <-ctx.Done(): t.Fatal(ctx.Err()) } - p := preparedResource.(*prepared) - defer p.Cancel(context.Background()) - pid := p.session.process.Cmd.Process.Pid + e := resource.(*executor) + defer e.Close(context.Background()) + pid := e.base.process.Cmd.Process.Pid if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { t.Fatal("preparation submitted input") } @@ -68,24 +65,17 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" config.Workspace.Directory = "/changed" - config.Workspace.Directory = "/changed" req.Model = "changed" req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) operation, stopOperation := context.WithCancel(ctx) - s, err := p.Start(operation, "actual-run", proto.TextInput("hello"), out) + turn, err := e.StartTurn(operation, "actual-run", proto.TextInput("hello"), out) stopOperation() if err != nil { t.Fatal(err) } - if s.(*session).owner != p.executor || s.(*session).process.Cmd.Process.Pid != pid { - t.Fatal("Start replaced the prepared native process") - } - if err := p.Close(); err != nil { - t.Fatal(err) - } - if _, err := p.Start(ctx, "duplicate", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("duplicate Start was accepted") + if turn.(*session).owner != e || turn.(*session).process.Cmd.Process.Pid != pid { + t.Fatal("StartTurn replaced the prepared native process") } var done proto.DonePayload for event := range out { @@ -107,7 +97,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) { - for _, name := range []string{"run", "prompt", "conversation", "attachments", "subagents", "workspace-missing", "none", "functions", "mcp", "controls", "old-runtime"} { + for _, name := range []string{"run", "prompt", "conversation", "attachments", "subagents", "none", "functions", "mcp", "controls", "old-runtime"} { t.Run(name, func(t *testing.T) { config := preparationFixture(t, name) req := preparationRequest() @@ -122,8 +112,6 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) req.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} case "subagents": req.ObserveSubagentIdentities = true - case "workspace-missing": - config.Workspace = nil case "none": req.DisableExecutionEnvironment = true case "functions": @@ -133,7 +121,7 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) case "controls": req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} } - if _, err := NewPreparationFactory(config)(t.Context(), req); err == nil { + if _, err := NewExecutorFactory(config)(t.Context(), req); err == nil { t.Fatal("invalid preparation was accepted") } if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { @@ -149,7 +137,7 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { config := preparationFixture(t, mode) owner, stop := context.WithCancel(t.Context()) defer stop() - resource, err := NewPreparationFactory(config)(owner, preparationRequest()) + resource, err := NewExecutorFactory(config)(owner, preparationRequest()) if mode == "history-missing" || mode == "invalid-receipt" { if err == nil || mode == "history-missing" && !strings.Contains(err.Error(), "history_unavailable") { t.Fatal("preparation failure was lost", err) @@ -159,15 +147,15 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { if err != nil { t.Fatal(err) } - p := resource.(*prepared) - defer p.Cancel(context.Background()) + e := resource.(*executor) + defer e.Close(context.Background()) switch mode { case "close": - err = p.Close() + err = e.Close(t.Context()) case "owner-cancel": stop() case "native-exit": - err = p.session.process.Cmd.Process.Signal(syscall.SIGKILL) + err = e.base.process.Cmd.Process.Signal(syscall.SIGKILL) case "invalid-start", "cancelled-start": operation, cancel := context.WithCancel(t.Context()) prompt := "" @@ -175,150 +163,24 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { prompt = "hello" cancel() } - _, startErr := p.Start(operation, "run", proto.TextInput(prompt), make(chan proto.Envelope, 8)) + _, startErr := e.StartTurn(operation, "run", proto.TextInput(prompt), make(chan proto.Envelope, 8)) cancel() if startErr == nil { - t.Fatal("invalid or cancelled Start succeeded") + t.Fatal("invalid or cancelled StartTurn succeeded") } - err = p.Close() + err = e.Close(t.Context()) } if err != nil { t.Fatal(err) } select { - case <-p.executor.done: + case <-e.done: case <-time.After(5 * time.Second): t.Fatal("unused process was not settled") } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("released preparation was reusable") - } - if got := p.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { - t.Fatal("unstarted process fabricated an execution outcome", got) + if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("released Executor was reusable") } }) } } - -func TestPreparedCancellationKeepsOwnershipUntilOutputDrain(t *testing.T) { - config := preparationFixture(t, "cancellation") - owner, stop := context.WithTimeout(t.Context(), 10*time.Second) - defer stop() - resource, err := NewPreparationFactory(config)(owner, preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - defer p.Cancel(context.Background()) - out := make(chan proto.Envelope) - operation, stopOperation := context.WithCancel(owner) - if _, err := p.Start(operation, "run", proto.TextInput("hello"), out); err != nil { - t.Fatal(err) - } - stopOperation() - if err := p.Close(); err != nil { - t.Fatal(err) - } - if event := <-out; event.Type != proto.TypeDelta { - t.Fatal("operation cancellation or Close cancelled the Session") - } - short, cancel := context.WithTimeout(owner, 30*time.Millisecond) - err = p.Cancel(short) - cancel() - if !errors.Is(err, context.DeadlineExceeded) || !reflect.DeepEqual(p.CancellationOutcome(), proto.DonePayload{}) { - t.Fatal("pending output drain reported settled ownership", err) - } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { - t.Fatal(err) - } - if err := p.Cancel(owner); err != nil { - t.Fatal(err) - } - got := p.CancellationOutcome() - if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil { - t.Fatal("cancellation across transfer lost observed output", got) - } - for range out { - } -} - -func TestPreparedStartRacesCloseAndCancellation(t *testing.T) { - for _, cancelResource := range []bool{false, true} { - for range 6 { - config := preparationFixture(t, "success") - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - out := make(chan proto.Envelope, 16) - var running agent.Session - var startErr error - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - running, startErr = p.Start(t.Context(), "run", proto.TextInput("hello"), out) - }() - go func() { - defer wg.Done() - if cancelResource { - _ = p.Cancel(t.Context()) - } else { - _ = p.Close() - } - }() - wg.Wait() - if startErr == nil { - if running == nil { - t.Fatal("successful transfer lost its Session") - } - for range out { - } - } - if err := p.Cancel(t.Context()); err != nil { - // A racing Close can confirm resource cleanup without proving the Turn result. - if closeErr := p.executor.Close(t.Context()); closeErr != nil { - t.Fatal(closeErr) - } - } - select { - case <-p.session.process.Done(): - default: - t.Fatal("race left the owned process alive") - } - } - } -} - -func TestPreparedConcurrentStartTransfersOnlyOnce(t *testing.T) { - config := preparationFixture(t, "success") - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - defer p.Cancel(context.Background()) - results := make(chan bool, 2) - var wg sync.WaitGroup - for range 2 { - wg.Add(1) - go func() { - defer wg.Done() - out := make(chan proto.Envelope, 16) - _, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) - results <- err == nil - if err == nil { - for event := range out { - if event.Type == proto.TypeError { - t.Error("losing Start cancelled the transferred Session") - } - } - } - }() - } - wg.Wait() - if first, second := <-results, <-results; first == second { - t.Fatal("concurrent Start did not produce exactly one owner") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 31d1619dd..78003305a 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -99,23 +99,6 @@ func startSession(start func(clirunner.StartOptions) (*clirunner.Process, error) return &session{process: process, writeMu: &sync.Mutex{}, functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{})}, nil } -func (s *session) drain(scanner *bridgeOutput, stderrDone <-chan struct{}, failure error) (error, bool) { - for scanner.Scan() { - } - if scanner.Err() != nil { - failure = fmt.Errorf("claudesdk: SDK bridge output read failed") - s.process.Cancel() - } - <-stderrDone - s.stopWorkspaceReads() - s.stopWorkspaceDirectories() - waitErr := s.process.Wait() - if waitErr != nil && failure == nil { - failure = fmt.Errorf("claudesdk: SDK process failed") - } - return failure, scanner.Err() == nil && waitErr == nil -} - func bridgeFailure(code string) error { switch code { case "invalid_request", "history_unavailable", "execution_failed", "cancelled": diff --git a/apps/daemon/internal/agent/claudesdk/unsupported.go b/apps/daemon/internal/agent/claudesdk/unsupported.go index 091e6d167..0b4da8274 100644 --- a/apps/daemon/internal/agent/claudesdk/unsupported.go +++ b/apps/daemon/internal/agent/claudesdk/unsupported.go @@ -22,7 +22,3 @@ func (s *executor) WriteWorkspaceFile(context.Context, string, []byte) (agent.Wo func (s *session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } - -func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/claudesdk/unsupported_test.go b/apps/daemon/internal/agent/claudesdk/unsupported_test.go index 82ee6cae2..d57a25856 100644 --- a/apps/daemon/internal/agent/claudesdk/unsupported_test.go +++ b/apps/daemon/internal/agent/claudesdk/unsupported_test.go @@ -28,7 +28,7 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { var turn *session check(turn.SubmitPermission(ctx, secret, proto.PermissionDecisionPayload{})) check(turn.SubmitPromptForUserChoice(ctx, secret, proto.PromptForUserChoiceDecisionPayload{})) - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*session)(nil)} { result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) check(err) if result.SizeBytes != 0 { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory.go b/apps/daemon/internal/agent/claudesdk/workspace_directory.go index 5b4150cba..1bde6cbe5 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_directory.go @@ -39,23 +39,8 @@ type workspaceDirectoryEvent struct { Error string `json:"error"` } -var _ agent.WorkspaceDirectoryLister = (*prepared)(nil) var _ agent.WorkspaceDirectoryLister = (*session)(nil) -func (p *prepared) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - read, err := p.session.admitWorkspaceDirectory(ctx, path, maxEntries) - p.mu.Unlock() - if err != nil { - return agent.WorkspaceDirectoryResult{}, err - } - return p.session.awaitWorkspaceDirectory(ctx, read) -} - func (s *session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { if s.owner != nil { return s.owner.base.ListWorkspaceDirectory(ctx, path, maxEntries) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go index 1c052d736..e073cffae 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go @@ -84,70 +84,64 @@ func runWorkspaceDirectoryHelper(scanner *bufio.Scanner, state, mode string) { } } -func directoryPreparation(t *testing.T, mode string) (*prepared, Config) { +func directoryExecutor(t *testing.T, mode string) (*executor, Config) { t.Helper() config := preparationFixture(t, mode) - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) if err != nil { t.Fatal(err) } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - return p, config + e := resource.(*executor) + t.Cleanup(func() { _ = e.Close(context.Background()) }) + return e, config } -func TestWorkspaceDirectoryPreparedBoundsAndMetadata(t *testing.T) { - p, _ := directoryPreparation(t, "directory-normal") +func TestWorkspaceDirectoryBoundsAndMetadata(t *testing.T) { + e, _ := directoryExecutor(t, "directory-normal") for _, path := range []string{"/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { t.Fatalf("accepted %q: %v", path, err) } } for _, limit := range []int{0, -1, workspaceDirectoryMaxEntries + 1} { - if _, err := p.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { + if _, err := e.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { t.Fatal(limit, err) } } - result, err := p.ListWorkspaceDirectory(t.Context(), "", 4) + result, err := e.ListWorkspaceDirectory(t.Context(), "", 4) if err != nil || result.Truncated || len(result.Entries) != 1 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 3 { t.Fatal(result, err) } - empty, err := p.ListWorkspaceDirectory(t.Context(), "empty", 4) + empty, err := e.ListWorkspaceDirectory(t.Context(), "empty", 4) if err != nil || len(empty.Entries) != 0 || empty.Entries == nil { t.Fatal(empty, err) } for path, expected := range map[string]error{"not_found": fs.ErrNotExist, "permission": fs.ErrPermission, "invalid": agent.ErrWorkspaceReadInvalid} { - if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { + if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { t.Fatal(path, err) } } - if _, err := p.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { + if _, err := e.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { t.Fatal(err) } } -func TestWorkspaceDirectoryDetachAndTransfer(t *testing.T) { - p, config := directoryPreparation(t, "directory-held") +func TestWorkspaceDirectoryDetachAndTurnStart(t *testing.T) { + e, config := directoryExecutor(t, "directory-held") ctx, detach := context.WithCancel(t.Context()) defer detach() result := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() + go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) detach() - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { + if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { t.Fatal(err) } out := make(chan proto.Envelope, 16) - running, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) + running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) if err != nil { t.Fatal(err) } - if p.Close() != nil { - t.Fatal("transferred Close failed") - } - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } select { case err := <-result: t.Fatal("caller detach discarded native wait", err) @@ -168,24 +162,24 @@ func TestWorkspaceDirectoryDetachAndTransfer(t *testing.T) { func TestWorkspaceDirectoryUnknownAndRelease(t *testing.T) { for _, path := range []string{"uncertain", "wrong-id", "bad-kind", "bad-size", "missing-size", "duplicate", "escape-name", "null", "extra"} { t.Run(path, func(t *testing.T) { - p, _ := directoryPreparation(t, "directory-normal") - result, err := p.ListWorkspaceDirectory(t.Context(), path, 4) + e, _ := directoryExecutor(t, "directory-normal") + result, err := e.ListWorkspaceDirectory(t.Context(), path, 4) if err != agent.ErrWorkspaceReadUncertain || len(result.Entries) != 0 { t.Fatal(result, err) } - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { + if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { t.Fatal(err) } }) } for _, mode := range []string{"directory-held", "directory-exit"} { t.Run(mode, func(t *testing.T) { - p, config := directoryPreparation(t, mode) + e, config := directoryExecutor(t, mode) done := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() + go func() { _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) if mode == "directory-held" { - if err := p.Close(); err != nil { + if err := e.Close(t.Context()); err != nil { t.Fatal(err) } } @@ -202,19 +196,19 @@ func TestWorkspaceDirectoryUnknownAndRelease(t *testing.T) { } func TestWorkspaceDirectoryDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - p, config := directoryPreparation(t, "directory-held") + e, config := directoryExecutor(t, "directory-held") ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) defer cancel() done := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() + go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) if err := <-done; err != agent.ErrWorkspaceReadUncertain { t.Fatal(err) } - if p.session.process.Context().Err() == nil { + if e.base.process.Context().Err() == nil { t.Fatal("uncertain deadline returned before owner cancellation") } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Start") + if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("unknown owner accepted a new Turn") } } diff --git a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go index 26d2c479a..f391878a9 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) @@ -21,14 +20,6 @@ import ( // Run only inside a separately qualified outer placement, with its pinned native // dependencies. This fixture does not create isolation or public admission. func TestLiveClaudeWorkspaceFactory(t *testing.T) { - testLiveClaudeWorkspace(t, false) -} - -func TestLiveClaudePreparedWorkspace(t *testing.T) { - testLiveClaudeWorkspace(t, true) -} - -func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { configFile := os.Getenv("OAC_TEST_CLAUDE_WORKSPACE_LIVE_CONFIG") if configFile == "" { t.Skip("requires explicit qualified placement and real provider configuration") @@ -72,20 +63,16 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { heartbeat := filepath.Join(config.Workspace.Directory, "heartbeat.txt") artifact := filepath.Join(config.Workspace.Directory, "value.txt") type evidence struct { - Reads []liveWorkspaceRead `json:"reads,omitempty"` - RunID string `json:"run_id"` - Events []proto.Envelope `json:"events"` - Done proto.DonePayload `json:"done"` - Failure string `json:"failure,omitempty"` - Cancelled bool `json:"cancelled"` - CancelMS int64 `json:"cancel_ms,omitempty"` - BridgePID int `json:"bridge_pid"` - Terminals int `json:"terminals"` - Heartbeats []string `json:"heartbeats,omitempty"` - PreparedPID int `json:"prepared_pid,omitempty"` - NativeBefore string `json:"native_before,omitempty"` - NativeAfter string `json:"native_after,omitempty"` - StartContextCancelled bool `json:"start_context_cancelled,omitempty"` + Reads []liveWorkspaceRead `json:"reads,omitempty"` + RunID string `json:"run_id"` + Events []proto.Envelope `json:"events"` + Done proto.DonePayload `json:"done"` + Failure string `json:"failure,omitempty"` + Cancelled bool `json:"cancelled"` + CancelMS int64 `json:"cancel_ms,omitempty"` + BridgePID int `json:"bridge_pid"` + Terminals int `json:"terminals"` + Heartbeats []string `json:"heartbeats,omitempty"` } writeEvidence := func(name string, proof evidence) { t.Helper() @@ -107,39 +94,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { req.StrictResume, req.ReleaseOnCompletion, req.ObserveMessages, req.ObserveToolObservations = true, true, true, true req.Model, req.SystemPrompt = "MiniMax-M3", "Follow the exact verification instructions using the requested native tools. Preserve conversation facts. No other files, network operations or background work." proof := evidence{RunID: req.RunID} - var running agent.Session - var owner agent.PreparedCancellation - if explicitPreparation { - preparation := req - preparation.RunID, preparation.Input = "", nil - var resource agent.Prepared - resource, err = NewPreparationFactory(config)(ctx, preparation) - if err == nil { - defer resource.Close() - owner = resource.(agent.PreparedCancellation) - proof.PreparedPID = resource.(*prepared).session.process.Cmd.Process.Pid - proof.NativeBefore = liveWorkspaceNativeIdentity(t, proof.PreparedPID) - before, _ := os.ReadFile(artifact) - time.Sleep(500 * time.Millisecond) - after, _ := os.ReadFile(artifact) - if !bytes.Equal(before, after) || liveWorkspaceNativeIdentity(t, proof.PreparedPID) != proof.NativeBefore || len(out) != 0 { - t.Fatal("prepared resource changed before initial input") - } - proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, resource.(agent.WorkspaceReader), config.Workspace.Directory, "prepared", "read-binary.bin", "read-empty.bin", "read-large.bin")...) - operation, stopOperation := context.WithCancel(ctx) - running, err = resource.Start(operation, req.RunID, req.Input, out) - stopOperation() - proof.StartContextCancelled = true - if err == nil { - proof.NativeAfter = liveWorkspaceNativeIdentity(t, proof.PreparedPID) - if proof.NativeBefore != proof.NativeAfter || resource.Close() != nil { - t.Fatal("Start replaced the native process or Close affected its transfer") - } - } - } - } else { - running, err = NewFactory(config)(ctx, req, out) - } + running, err := NewFactory(config)(ctx, req, out) if err != nil { if name == "missing-history" && running == nil && strings.Contains(err.Error(), "history_unavailable") { proof.Failure = err.Error() @@ -152,13 +107,6 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { defer s.Cancel(context.Background()) proof.BridgePID = s.process.Cmd.Process.Pid proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "active", "read-binary.bin", "read-empty.bin", "read-large.bin")...) - if explicitPreparation && proof.BridgePID != proof.PreparedPID { - t.Fatal("Start replaced the prepared bridge") - } - cancelOwned, outcome := s.Cancel, s.CancellationOutcome - if owner != nil { - cancelOwned, outcome = owner.Cancel, owner.CancellationOutcome - } ticker := time.NewTicker(80 * time.Millisecond) defer ticker.Stop() for out != nil { @@ -170,7 +118,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { if cancelOnEffect && !proof.Cancelled && len(value) > 0 && string(value) != "0" && string(value) != "1" { proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "effect", "value.txt")...) started := time.Now() - if err := cancelOwned(ctx); err != nil { + if err := s.Cancel(ctx); err != nil { t.Fatal("factory cancellation failed", err) } proof.CancelMS = time.Since(started).Milliseconds() @@ -201,7 +149,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { if len(a) == 0 || !bytes.Equal(a, b) { t.Fatal("native command effects continued after Cancel") } - settled, _ := json.Marshal(outcome()) + settled, _ := json.Marshal(s.CancellationOutcome()) done, _ := json.Marshal(proof.Done) if !bytes.Equal(settled, done) { t.Fatal("cancellation outcome differs from terminal Done") @@ -278,18 +226,3 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { t.Fatal(err) } } - -func liveWorkspaceNativeIdentity(t *testing.T, bridgePID int) string { - t.Helper() - raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", bridgePID, bridgePID)) - children := strings.Fields(string(raw)) - if err != nil || len(children) != 1 { - t.Fatal("expected exactly one retained native child", err) - } - status, err := os.ReadFile("/proc/" + children[0] + "/stat") - fields := strings.Fields(string(status)[strings.LastIndex(string(status), ")")+1:]) - if err != nil || len(fields) < 20 { - t.Fatal("native process identity unavailable", err) - } - return children[0] + ":" + fields[19] -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read.go b/apps/daemon/internal/agent/claudesdk/workspace_read.go index 9d8f4a568..ec5ea4a56 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_read.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_read.go @@ -39,23 +39,8 @@ type workspaceReadEvent struct { Error string `json:"error"` } -var _ agent.WorkspaceReader = (*prepared)(nil) var _ agent.WorkspaceReader = (*session)(nil) -func (p *prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnavailable - } - read, err := p.session.admitWorkspaceRead(ctx, path, maxBytes) - p.mu.Unlock() - if err != nil { - return agent.WorkspaceReadResult{}, err - } - return p.session.awaitWorkspaceRead(ctx, read) -} - func (s *session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { if s.owner != nil { return s.owner.base.ReadWorkspaceFile(ctx, path, maxBytes) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go index 083c2387a..72d32a943 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go @@ -78,32 +78,32 @@ func runWorkspaceReadHelper(scanner *bufio.Scanner, state, mode string) { } } -func readPreparation(t *testing.T, mode string) (*prepared, Config) { +func readExecutor(t *testing.T, mode string) (*executor, Config) { t.Helper() config := preparationFixture(t, mode) - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) if err != nil { t.Fatal(err) } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - return p, config + e := resource.(*executor) + t.Cleanup(func() { _ = e.Close(context.Background()) }) + return e, config } -func TestWorkspaceReadPreparedBoundsAndBinary(t *testing.T) { - p, _ := readPreparation(t, "read-normal") +func TestWorkspaceReadBoundsAndBinary(t *testing.T) { + e, _ := readExecutor(t, "read-normal") for _, path := range []string{"", "/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := p.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + if _, err := e.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { t.Fatalf("accepted %q: %v", path, err) } } for _, limit := range []int{0, -1, workspaceReadMaxBytes + 1} { - if _, err := p.ReadWorkspaceFile(t.Context(), "file", limit); err != agent.ErrWorkspaceReadInvalid { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", limit); err != agent.ErrWorkspaceReadInvalid { t.Fatal(limit, err) } } for _, path := range []string{"file", "prefix", "empty"} { - result, err := p.ReadWorkspaceFile(t.Context(), path, workspaceReadMaxBytes) + result, err := e.ReadWorkspaceFile(t.Context(), path, workspaceReadMaxBytes) expected := bytes.Repeat([]byte{0, 255, 1, 128}, workspaceReadMaxBytes/4) if path == "empty" { expected = nil @@ -112,36 +112,30 @@ func TestWorkspaceReadPreparedBoundsAndBinary(t *testing.T) { t.Fatal(path, err, len(result.Data), result.Truncated) } } - if _, err := p.ReadWorkspaceFile(t.Context(), "invalid", 4); err != agent.ErrWorkspaceReadInvalid { + if _, err := e.ReadWorkspaceFile(t.Context(), "invalid", 4); err != agent.ErrWorkspaceReadInvalid { t.Fatal(err) } - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { t.Fatal(err) } } -func TestWorkspaceReadDetachAndTransfer(t *testing.T) { - p, config := readPreparation(t, "read-held") +func TestWorkspaceReadDetachAndTurnStart(t *testing.T) { + e, config := readExecutor(t, "read-held") ctx, detach := context.WithCancel(t.Context()) defer detach() result := make(chan error, 1) - go func() { _, err := p.ReadWorkspaceFile(ctx, "file", 4); result <- err }() + go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); result <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) detach() - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { t.Fatal(err) } out := make(chan proto.Envelope, 16) - running, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) + running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) if err != nil { t.Fatal(err) } - if p.Close() != nil { - t.Fatal("transferred Close failed") - } - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } select { case err := <-result: t.Fatal("caller detach discarded native wait", err) @@ -162,24 +156,24 @@ func TestWorkspaceReadDetachAndTransfer(t *testing.T) { func TestWorkspaceReadUnknownAndRelease(t *testing.T) { for _, path := range []string{"uncertain", "wrong-id", "bad-base64", "oversize", "extra"} { t.Run(path, func(t *testing.T) { - p, _ := readPreparation(t, "read-normal") - result, err := p.ReadWorkspaceFile(t.Context(), path, 4) + e, _ := readExecutor(t, "read-normal") + result, err := e.ReadWorkspaceFile(t.Context(), path, 4) if err != agent.ErrWorkspaceReadUncertain || len(result.Data) != 0 { t.Fatal(result, err) } - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { t.Fatal(err) } }) } for _, mode := range []string{"read-held", "read-exit"} { t.Run(mode, func(t *testing.T) { - p, config := readPreparation(t, mode) + e, config := readExecutor(t, mode) done := make(chan error, 1) - go func() { _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); done <- err }() + go func() { _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) if mode == "read-held" { - if err := p.Close(); err != nil { + if err := e.Close(t.Context()); err != nil { t.Fatal(err) } } @@ -196,19 +190,19 @@ func TestWorkspaceReadUnknownAndRelease(t *testing.T) { } func TestWorkspaceReadDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - p, config := readPreparation(t, "read-held") + e, config := readExecutor(t, "read-held") ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) defer cancel() done := make(chan error, 1) - go func() { _, err := p.ReadWorkspaceFile(ctx, "file", 4); done <- err }() + go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) if err := <-done; err != agent.ErrWorkspaceReadUncertain { t.Fatal(err) } - if p.session.process.Context().Err() == nil { + if e.base.process.Context().Err() == nil { t.Fatal("uncertain deadline returned before owner cancellation") } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Start") + if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("unknown owner accepted a new Turn") } } diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index 357eb8127..ec627a49e 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -3,6 +3,7 @@ package claudesdk import ( + "context" "encoding/json" "os" "path/filepath" @@ -20,7 +21,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T req.ObserveMessages = true schema := `{"type":"object","properties":{"n":{"const":9007199254740992}}}` req.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(schema)}} - p, err := NewPreparationFactory(config)(t.Context(), req) + e, err := NewExecutorFactory(config)(t.Context(), req) if mode == "structured-missing" { if err == nil || !strings.Contains(err.Error(), "workspace structured output") { t.Fatal("unqualified bundle admitted", err) @@ -33,7 +34,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T if err != nil { t.Fatal(err) } - defer p.Close() + defer e.Close(context.Background()) req.ExecutionControls.OutputFormat.Schema[0] = ' ' var frozen startRequest if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")), &frozen); err != nil { @@ -43,7 +44,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T t.Fatal("prepared native schema changed with caller memory") } out := make(chan proto.Envelope, 16) - if _, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out); err != nil { + if _, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out); err != nil { t.Fatal(err) } for event := range out { diff --git a/apps/daemon/internal/agent/codex/contracts.go b/apps/daemon/internal/agent/codex/contracts.go index 0ba51146f..329b053c5 100644 --- a/apps/daemon/internal/agent/codex/contracts.go +++ b/apps/daemon/internal/agent/codex/contracts.go @@ -16,12 +16,7 @@ var ( _ agent.WorkspaceReader = (*Session)(nil) _ agent.WorkspaceDirectoryLister = (*Session)(nil) _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.Prepared = (*Prepared)(nil) - _ agent.PreparedCancellation = (*Prepared)(nil) _ agent.WorkspaceReader = (*Executor)(nil) _ agent.WorkspaceDirectoryLister = (*Executor)(nil) _ agent.WorkspaceWriter = (*Executor)(nil) - _ agent.WorkspaceReader = (*Prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*Prepared)(nil) - _ agent.WorkspaceWriter = (*Prepared)(nil) ) diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index dc6d9ee3d..49f2c528d 100644 --- a/apps/daemon/internal/agent/codex/declaration.go +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -62,19 +62,10 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, i caps := &runtime.Info.Capabilities caps.NativeSessionRecovery = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) caps.LocalEnvironment = proto.CapabilityFromBool(SupportsLocalEnvironment(version)) + caps.WorkspaceReadPreparation = caps.LocalEnvironment caps.MCPHTTPRequired = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) runtime.Executor = NewExecutorFactory() runtime.View = discoverView(version) - if caps.LocalEnvironment.IsSupported() { - runtime.WorkspaceReadPreparation = true - runtime.Preparation = func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - prepared, err := Prepare(ctx, req) - if prepared == nil { - return nil, err - } - return prepared, err - } - } fmt.Fprintf(options.Stdout, "Codex preflight ok (%s)\n", version) return runtime } diff --git a/apps/daemon/internal/agent/codex/declaration_test.go b/apps/daemon/internal/agent/codex/declaration_test.go index a32af4573..b059fef73 100644 --- a/apps/daemon/internal/agent/codex/declaration_test.go +++ b/apps/daemon/internal/agent/codex/declaration_test.go @@ -15,7 +15,7 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return version, nil }) - if !runtime.Info.Available || runtime.Executor == nil || (runtime.Preparation != nil) != SupportsLocalEnvironment(version) { + if !runtime.Info.Available || runtime.Executor == nil || runtime.Info.Capabilities.WorkspaceReadPreparation.IsSupported() != SupportsLocalEnvironment(version) { t.Fatalf("factories: %+v", runtime) } if runtime.Info.Capabilities.MCPHTTPRequired.IsSupported() != (version == "codex-cli 0.153.4") { @@ -49,7 +49,7 @@ func TestDeclaredCapabilityBaseline(t *testing.T) { func TestUnavailableRuntimeHasNoExecutionFactories(t *testing.T) { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return "", errors.New("missing") }) - if runtime.Info.Available || runtime.Executor != nil || runtime.Preparation != nil || runtime.View != nil || runtime.Session == nil { + if runtime.Info.Available || runtime.Executor != nil || runtime.View != nil || runtime.Session == nil { t.Fatalf("unavailable runtime: %+v", runtime) } } diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 03904c7b0..3a072c808 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -11,15 +11,6 @@ import ( obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) -// Prepare connects the native harness without creating a thread or starting model -// work. req supplies configuration and a stable state key, but no RunID or Prompt. -// owner owns the entire harness lifetime, including the eventual Session; it must -// not be a disposable readiness-request context. Initialization/readiness use their -// existing operation-local deadlines. Close an unused preparation explicitly. -func Prepare(owner context.Context, req proto.PromptRequestPayload) (*Prepared, error) { - return newPreparation(owner, req, defaultSessionConfig()) -} - func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { if out == nil { return nil, errors.New("codex: nil out channel") diff --git a/apps/daemon/internal/agent/codex/prepared.go b/apps/daemon/internal/agent/codex/prepared.go index 454b71976..edae1d169 100644 --- a/apps/daemon/internal/agent/codex/prepared.go +++ b/apps/daemon/internal/agent/codex/prepared.go @@ -6,11 +6,11 @@ import ( "strings" "sync" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// Prepared owns a connected native resource until Start transfers it to a Session. +// Prepared owns a connected native resource until start transfers it to a Session +// or an Executor takes it. // It observes owner cancellation and RPC exit, not continuous executor readiness. type Prepared struct { mu sync.Mutex @@ -25,19 +25,9 @@ type Prepared struct { transferred chan struct{} } -var _ agent.PreparedCancellation = (*Prepared)(nil) - -// Start consumes the preparation once. ctx bounds only this start operation; +// start consumes the preparation once. ctx bounds only this start operation; // cancellation after return does not cancel the transferred Session. The original // owner context remains its lifetime context. On success the Session owns out. -func (p *Prepared) Start(ctx context.Context, runID string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - session, err := p.start(ctx, runID, prompt, out) - if err != nil { - return nil, err - } - return session, nil -} - func (p *Prepared) start(ctx context.Context, runID string, prompt proto.MessageInput, out chan<- proto.Envelope) (*Session, error) { if out == nil || strings.TrimSpace(runID) == "" || prompt.Validate() != nil { return nil, errors.New("codex: start requires a run identity, prompt and output channel") @@ -76,7 +66,7 @@ func (p *Prepared) start(ctx context.Context, runID string, prompt proto.Message } // Close waits for unused teardown and plan cleanup, including another caller's -// ongoing Close. After successful Start it is inert; use +// ongoing Close. After a successful start it is inert; use // the returned Session's cancellation path to release the transferred resource. func (p *Prepared) Close() error { p.mu.Lock() @@ -94,31 +84,6 @@ func (p *Prepared) Close() error { return err } -// Cancel fences Start and cancels the resource even after transfer. -func (p *Prepared) Cancel(ctx context.Context) error { - p.mu.Lock() - p.closed = true - started := p.started - p.mu.Unlock() - if started { - if err := p.session.Cancel(ctx); err != nil { - return err - } - select { - case <-p.session.waitDone: - return nil - case <-ctx.Done(): - return ctx.Err() - } - } - return p.Close() -} - -// CancellationOutcome returns observed state, not a guarantee of final output or quiescence. -func (p *Prepared) CancellationOutcome() proto.DonePayload { - return p.session.CancellationOutcome() -} - func (p *Prepared) watchOwner() { select { case <-p.session.cancelCtx.Done(): diff --git a/apps/daemon/internal/agent/codex/prepared_cancel_test.go b/apps/daemon/internal/agent/codex/prepared_cancel_test.go deleted file mode 100644 index 3cc919e44..000000000 --- a/apps/daemon/internal/agent/codex/prepared_cancel_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package codex - -import ( - "context" - "errors" - "reflect" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestPreparedCancelUnusedWaitsForCleanup(t *testing.T) { - req, cfg, root := preparationFixture(t) - req.AgentSessionID = "requested-but-unobserved-thread" - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - entered, release := make(chan struct{}), make(chan struct{}) - allowCleanup := sync.OnceFunc(func() { close(release) }) - defer allowCleanup() - cleanup := p.plan.Cleanup - var cleanups atomic.Int32 - p.plan.Cleanup = sync.OnceFunc(func() { - cleanups.Add(1) - close(entered) - <-release - cleanup() - }) - finished := make(chan error, 4) - for range 4 { - go func() { finished <- p.Cancel(context.Background()) }() - } - select { - case <-entered: - case <-time.After(4 * time.Second): - t.Fatal("cancellation did not begin cleanup") - } - out := make(chan proto.Envelope, 8) - if s, err := p.Start(t.Context(), "late", proto.TextInput("must not execute"), out); err == nil || s != nil { - t.Fatal("cancellation did not fence Start") - } - select { - case <-finished: - t.Fatal("cancellation returned before unused cleanup") - default: - } - allowCleanup() - for range 4 { - select { - case err := <-finished: - if err != nil { - t.Fatal(err) - } - case <-time.After(4 * time.Second): - t.Fatal("repeated cancellation did not finish") - } - } - if cleanups.Load() != 1 { - t.Fatal("cleanup ran more than once") - } - waitPreparedRelease(t, p, root) - assertPreparationOnly(t, root) - assertUnstartedCancellation(t, p) -} - -func assertUnstartedCancellation(t *testing.T, p *Prepared) { - t.Helper() - got := p.CancellationOutcome() - if got.Content != "" || len(got.Metadata) != 0 || !reflect.DeepEqual(got.Usage, proto.Usage{}) { - t.Fatalf("unobserved result was invented: %+v", got) - } -} - -func TestPreparedCancelTransferredPreservesObservedOutcome(t *testing.T) { - req, cfg, root := preparationFixture(t) - t.Setenv("OAC_TEST_PREPARATION_OBSERVE", "1") - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Cancel(context.Background()) - started, err := p.Start(t.Context(), "run", proto.TextInput("prompt"), make(chan proto.Envelope, 16)) - if err != nil { - t.Fatal(err) - } - s := started.(*Session) - deadline := time.Now().Add(4 * time.Second) - for { - got := p.CancellationOutcome() - if got.Content == "observed partial answer" && got.Usage.Tokens != nil { - break - } - if time.Now().After(deadline) { - t.Fatal("native output and Usage were not observed") - } - time.Sleep(time.Millisecond) - } - if err := p.Close(); err != nil || !s.rpc.Alive() { - t.Fatal("Close cancelled transferred Session", err) - } - var calls sync.WaitGroup - for range 4 { - calls.Go(func() { - if err := p.Cancel(context.Background()); err != nil { - t.Error(err) - } - }) - } - calls.Wait() - select { - case <-s.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("transferred Session did not finish") - } - waitPreparedRelease(t, p, root) - got := p.CancellationOutcome() - want := proto.TokenUsage{InputTokens: 30, CachedInputTokens: 4, OutputTokens: 10, ReasoningOutputTokens: 2, TotalTokens: 40} - if got.Content != "observed partial answer" || got.Metadata[proto.DoneMetaAgentSessionID] != "fixture-native-thread" || got.Usage.Tokens == nil || *got.Usage.Tokens != want { - t.Fatalf("observed outcome lost: %+v", got) - } - if !reflect.DeepEqual(got, s.CancellationOutcome()) { - t.Fatal("preparation and Session exposed different outcomes") - } - interrupts := 0 - for _, frame := range preparationFrames(t, root) { - if frame.Method == "turn/interrupt" { - interrupts++ - } - } - if interrupts != 1 { - t.Fatal("native cancellation was missing or repeated", interrupts) - } -} - -func TestPreparedCancelTransferredWaitsForCleanup(t *testing.T) { - req, cfg, root := preparationFixture(t) - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Cancel(context.Background()) - entered, release := make(chan struct{}), make(chan struct{}) - allowCleanup := sync.OnceFunc(func() { close(release) }) - defer allowCleanup() - cleanup := p.session.cleanup - p.session.cleanup = sync.OnceFunc(func() { close(entered); <-release; cleanup() }) - p.plan.Cleanup = p.session.cleanup - out := make(chan proto.Envelope, 16) - if _, err := p.Start(t.Context(), "run", proto.TextInput("prompt"), out); err != nil { - t.Fatal(err) - } - waitPreparationMethod(t, root, "turn/start") - finished := make(chan error, 1) - go func() { finished <- p.Cancel(context.Background()) }() - select { - case <-entered: - case <-time.After(4 * time.Second): - t.Fatal("transferred cancellation did not begin cleanup") - } - for range out { - } - select { - case <-finished: - t.Fatal("cancellation returned after output closure but before local cleanup") - case <-p.session.waitDone: - t.Fatal("Session finished before local cleanup") - default: - } - ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) - defer cancel() - if err := p.Cancel(ctx); !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("blocked cleanup ignored caller deadline: %v", err) - } - allowCleanup() - select { - case err := <-finished: - if err != nil { - t.Fatal(err) - } - case <-time.After(4 * time.Second): - t.Fatal("cancellation did not finish after local cleanup") - } - waitPreparedRelease(t, p, root) - if err := p.Cancel(t.Context()); err != nil { - t.Fatalf("settled cleanup could not be retried: %v", err) - } -} - -func TestPreparedCancelRacingTransfer(t *testing.T) { - for range 8 { - req, cfg, root := preparationFixture(t) - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - begin := make(chan struct{}) - var calls sync.WaitGroup - calls.Go(func() { - <-begin - _, _ = p.Start(t.Context(), "run", proto.TextInput("prompt"), make(chan proto.Envelope, 16)) - }) - calls.Go(func() { <-begin; _ = p.Cancel(context.Background()) }) - calls.Go(func() { <-begin; _ = p.Close() }) - close(begin) - calls.Wait() - if err := p.Cancel(context.Background()); err != nil { - t.Fatal(err) - } - if p.started { - select { - case <-p.session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("racing Session was retained") - } - } else { - assertPreparationOnly(t, root) - assertUnstartedCancellation(t, p) - } - waitPreparedRelease(t, p, root) - if s, err := p.Start(t.Context(), "again", proto.TextInput("must not execute"), make(chan proto.Envelope, 8)); err == nil || s != nil { - t.Fatal("cancelled preparation started again") - } - } -} diff --git a/apps/daemon/internal/agent/codex/prepared_test.go b/apps/daemon/internal/agent/codex/prepared_test.go index 628408575..83d63d008 100644 --- a/apps/daemon/internal/agent/codex/prepared_test.go +++ b/apps/daemon/internal/agent/codex/prepared_test.go @@ -35,12 +35,11 @@ func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { copy(req.FunctionTools[0].Parameters, strings.ReplaceAll(string(req.FunctionTools[0].Parameters), "integer", "boolean")) out := make(chan proto.Envelope, 8) startCtx, stopStart := context.WithCancel(t.Context()) - started, err := p.Start(startCtx, "actual-run", proto.TextInput("actual prompt"), out) + session, err := p.start(startCtx, "actual-run", proto.TextInput("actual prompt"), out) stopStart() if err != nil { t.Fatal(err) } - session := started.(*Session) defer session.Cancel(context.Background()) if session.rpc != p.session.rpc || session.rpc.process.Cmd.Process.Pid != pid { t.Fatal("start replaced the prepared native resource") @@ -48,7 +47,7 @@ func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { if err := p.Close(); err != nil || !session.rpc.Alive() { t.Fatal("close cancelled transferred resource", err) } - if again, err := p.Start(t.Context(), "second", proto.TextInput("second prompt"), out); err == nil || again != nil { + if again, err := p.start(t.Context(), "second", proto.TextInput("second prompt"), out); err == nil || again != nil { t.Fatal("preparation started twice", err) } frames := waitPreparationMethod(t, root, "turn/start") @@ -133,7 +132,7 @@ func TestPreparedSessionAbandonmentAndFailedStart(t *testing.T) { waitPreparedRelease(t, p, root) } out := make(chan proto.Envelope, 8) - if started, err := p.Start(startCtx, "late-run", proto.TextInput("must not start"), out); err == nil || started != nil { + if started, err := p.start(startCtx, "late-run", proto.TextInput("must not start"), out); err == nil || started != nil { t.Fatal("abandoned preparation started", err) } waitPreparedRelease(t, p, root) diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index 0fba69762..49713413e 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -171,7 +171,7 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { } assertPreparationOnly(t, root) out := make(chan proto.Envelope, 16) - session, err := p.Start(t.Context(), "recovery-run", proto.TextInput("continue"), out) + session, err := p.start(t.Context(), "recovery-run", proto.TextInput("continue"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/unsupported.go b/apps/daemon/internal/agent/codex/unsupported.go index a2a092928..3da26fb7f 100644 --- a/apps/daemon/internal/agent/codex/unsupported.go +++ b/apps/daemon/internal/agent/codex/unsupported.go @@ -28,15 +28,3 @@ func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.Wo func (s *Session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } - -func (s *Prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/codex/unsupported_test.go b/apps/daemon/internal/agent/codex/unsupported_test.go index 0f0936197..fe2fe3616 100644 --- a/apps/daemon/internal/agent/codex/unsupported_test.go +++ b/apps/daemon/internal/agent/codex/unsupported_test.go @@ -24,21 +24,21 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { t.Fatal("unsupported error disclosed input") } } - for _, owner := range []agent.WorkspaceReader{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + for _, owner := range []agent.WorkspaceReader{(*Executor)(nil), (*Session)(nil)} { result, err := owner.ReadWorkspaceFile(ctx, secret, 1) check(err) if len(result.Data) != 0 || result.Truncated { t.Fatal("unsupported read fabricated data") } } - for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil)} { result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) check(err) if len(result.Entries) != 0 || result.Truncated { t.Fatal("unsupported listing fabricated entries") } } - for _, owner := range []agent.WorkspaceWriter{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + for _, owner := range []agent.WorkspaceWriter{(*Executor)(nil), (*Session)(nil)} { result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) check(err) if result.SizeBytes != 0 { diff --git a/apps/daemon/internal/agent/configuration_test.go b/apps/daemon/internal/agent/configuration_test.go index 2fc4e7aff..7085e371d 100644 --- a/apps/daemon/internal/agent/configuration_test.go +++ b/apps/daemon/internal/agent/configuration_test.go @@ -26,10 +26,6 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { calls++ return nil, expected }) - registry.RegisterPreparation("fixture", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - calls++ - return nil, expected - }) configuration.Providers[0].Protocol = "anthropic" copy, err := registry.Configuration("fixture") if err != nil { @@ -38,11 +34,9 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { copy.Providers[0].Protocol = "anthropic" factory, _ := registry.Resolve("fixture") executor, _ := registry.ResolveExecutor("fixture") - preparation, _ := registry.ResolvePreparation("fixture") entries := []func(proto.PromptRequestPayload) error{ func(req proto.PromptRequestPayload) error { _, err := factory(t.Context(), req, nil); return err }, func(req proto.PromptRequestPayload) error { _, err := executor(t.Context(), req); return err }, - func(req proto.PromptRequestPayload) error { _, err := preparation(t.Context(), req); return err }, } for _, entry := range entries { responses := &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: "https://provider.example", APIKey: "private-sentinel"} @@ -61,7 +55,7 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { t.Fatal("bound declaration was lost or mutated", err) } } - if calls != 3 { + if calls != 2 { t.Fatal("unexpected native calls", calls) } if _, err := registry.Configuration("missing"); err == nil { diff --git a/apps/daemon/internal/agent/contract_declarations_test.go b/apps/daemon/internal/agent/contract_declarations_test.go index fc642d3f4..316f61634 100644 --- a/apps/daemon/internal/agent/contract_declarations_test.go +++ b/apps/daemon/internal/agent/contract_declarations_test.go @@ -24,11 +24,9 @@ func TestPublicHarnessContractDeclarations(t *testing.T) { "FunctionResultSubmitter": {"session"}, "PermissionResponder": {"session"}, "UserChoiceResponder": {"session"}, - "WorkspaceReader": {"executor", "prepared", "session"}, - "WorkspaceDirectoryLister": {"executor", "prepared", "session"}, - "WorkspaceWriter": {"executor", "prepared", "session"}, - "Prepared": {"prepared"}, - "PreparedCancellation": {"prepared"}, + "WorkspaceReader": {"executor", "session"}, + "WorkspaceDirectoryLister": {"executor", "session"}, + "WorkspaceWriter": {"executor", "session"}, } files, err := filepath.Glob("*.go") if err != nil { diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index c0d9e3c6f..0894a867e 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -64,11 +64,9 @@ type DiscoveryOptions struct { // Runtime binds one discovered descriptor to its native factories. type Runtime struct { - Info proto.SupportedAgentKind - Session Factory - Preparation PreparationFactory - Executor ExecutorFactory - WorkspaceReadPreparation bool + Info proto.SupportedAgentKind + Session Factory + Executor ExecutorFactory // View declares how the Harness runs in an agent-host Session view. // A nil View means the agent host rejects the kind with ErrUnsupportedOperation. View *View @@ -83,9 +81,6 @@ func (r *Registry) Register(declaration Declaration, runtime Runtime) { if runtime.Executor != nil { r.RegisterExecutor(runtime.Info.Kind, runtime.Executor) } - if runtime.Preparation != nil { - r.RegisterPreparation(runtime.Info.Kind, runtime.WorkspaceReadPreparation, runtime.Preparation) - } if runtime.View != nil { r.RegisterView(runtime.Info.Kind, *runtime.View) } @@ -525,7 +520,7 @@ func (r *Registry) ResolveView(kind string) (View, error) { // Model configuration has one shared contract, authored in // internal/harnessconfig/harness.go. RegisterKind requires that declaration; -// RegisterExecutor and RegisterPreparation inherit it. Every registered entry +// RegisterExecutor inherits it. Every registered entry // validates model, provider and native parameters before calling native code. // The declaration belongs to the adapter and is also consumed by Core. Keep // adapter field rules and rendering private. That shared contract owns frozen @@ -650,34 +645,6 @@ type WorkspaceWriter interface { WriteWorkspaceFile(context.Context, string, []byte) (WorkspaceWriteResult, error) } -// Separate preparation for qualified workspace access and direct-call paths. - -// Prepared owns native resources until Start returns a non-nil Session. The -// preparation owner context spans the eventual Session; Start's context is local -// to that operation. A nil Session leaves preparation cleanup with the caller. -type Prepared interface { - // Start transfers output ownership only when it returns a non-nil Session. - // A nil Session leaves the caller as the sole owner of closing out, and the - // implementation must not retain or write to it after Start returns. - Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (Session, error) - // Close retains unused ownership on error; callers may retry settlement. - Close() error -} - -// PreparedCancellation is required for executable preparations and follows the -// same native resource across Start. Read-only preparations need only Prepared. -type PreparedCancellation interface { - Prepared - Session - // Cancel returns after local cleanup and all output writes have stopped. - // An error retains ownership so callers can retry this exact object serially. - Cancel(context.Context) error -} - -// A factory may return both a resource and an error when construction failed but -// cleanup remains unconfirmed. The caller must retain and close that resource. -type PreparationFactory func(context.Context, proto.PromptRequestPayload) (Prepared, error) - // Direct-call factory and registration. These use the existing Registry behavior. // Factory builds a Session for one prompt_request. out is the upstream @@ -712,11 +679,9 @@ func (r *Registry) RegisterKind(info proto.SupportedAgentKind, configuration har } return f(ctx, req, out) } - delete(r.preparers, kind) delete(r.executors, kind) delete(r.views, kind) info.Capabilities.Preparation = proto.CapabilityUnsupported - info.Capabilities.WorkspaceReadPreparation = proto.CapabilityUnsupported r.kinds[kind] = info } @@ -742,26 +707,3 @@ func (r *Registry) RegisterExecutor(kind string, factory ExecutorFactory) { info.Capabilities.Preparation = proto.CapabilitySupported r.kinds[kind] = info } - -// RegisterPreparation installs a separate execution-only path. -func (r *Registry) RegisterPreparation(kind string, workspaceRead bool, prepare PreparationFactory) { - r.mu.Lock() - defer r.mu.Unlock() - info, exists := r.kinds[kind] - if !exists || prepare == nil { - panic("agent.Registry.RegisterPreparation: registered kind and factory required") - } - configuration, declared := r.configurations[kind] - if !declared { - panic("agent.Registry.RegisterPreparation: configuration required") - } - r.preparers[kind] = func(ctx context.Context, req proto.PromptRequestPayload) (Prepared, error) { - if _, err := configuration.Prepare(req); err != nil { - return nil, err - } - return prepare(ctx, req) - } - info.Capabilities.Preparation = proto.CapabilitySupported - info.Capabilities.WorkspaceReadPreparation = proto.CapabilityFromBool(workspaceRead) - r.kinds[kind] = info -} diff --git a/apps/daemon/internal/agent/mcode/contracts.go b/apps/daemon/internal/agent/mcode/contracts.go index d2ed2e6d4..03261a304 100644 --- a/apps/daemon/internal/agent/mcode/contracts.go +++ b/apps/daemon/internal/agent/mcode/contracts.go @@ -16,12 +16,7 @@ var ( _ agent.WorkspaceReader = (*Session)(nil) _ agent.WorkspaceDirectoryLister = (*Session)(nil) _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.Prepared = (*prepared)(nil) - _ agent.PreparedCancellation = (*prepared)(nil) _ agent.WorkspaceReader = (*executor)(nil) _ agent.WorkspaceDirectoryLister = (*executor)(nil) _ agent.WorkspaceWriter = (*executor)(nil) - _ agent.WorkspaceReader = (*prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*prepared)(nil) - _ agent.WorkspaceWriter = (*prepared)(nil) ) diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index 98ed670ce..ff79fd25f 100644 --- a/apps/daemon/internal/agent/mcode/declaration.go +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -79,10 +79,6 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, r runtime.View = discoverView(options) } } - if workspace != nil { - runtime.Preparation = NewPreparationFactory(*workspace) - runtime.WorkspaceReadPreparation = true - } fmt.Fprintf(options.Stdout, "mcode preflight ok (%s)\n", version) return runtime } diff --git a/apps/daemon/internal/agent/mcode/declaration_test.go b/apps/daemon/internal/agent/mcode/declaration_test.go index ff2f74f16..229d6192e 100644 --- a/apps/daemon/internal/agent/mcode/declaration_test.go +++ b/apps/daemon/internal/agent/mcode/declaration_test.go @@ -19,7 +19,7 @@ func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { t.Setenv("OAC_RUNTIME_MCODE_AGENTS_API", tc.enabled) rc := agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard} runtime := discoverWithCheck(t.Context(), rc, Declaration.Info, func(context.Context, string) (string, error) { return tc.version, nil }) - if runtime.Executor == nil || runtime.Preparation != nil { + if runtime.Executor == nil || runtime.Info.Capabilities.WorkspaceReadPreparation.IsSupported() { t.Fatalf("factories: %+v", runtime) } info := runtime.Info diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index 1e2c745cb..f8ea52093 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -32,11 +32,11 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { } t.Setenv("USER_SELECTED", "must-not-resolve-from-daemon") t.Setenv("MODEL_SECRET", "must-not-forward") - resource, err := NewPreparationFactory(c)(t.Context(), req) + resource, err := NewExecutorFactory(&c)(t.Context(), req) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.Close() }) + t.Cleanup(func() { _ = resource.Close(context.Background()) }) raw, err := os.ReadFile(record + ".session") if err != nil { t.Fatal(err) @@ -117,18 +117,18 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { if err := os.WriteFile(c.Binary, []byte(strings.Replace(string(script), "HELPER=prepared", "HELPER=prepared-mcp-cancel", 1)), 0700); err != nil { t.Fatal(err) } - resource, err := NewPreparationFactory(c)(t.Context(), req) + resource, err := NewExecutorFactory(&c)(t.Context(), req) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - session, err := resource.Start(ctx, "run", proto.TextInput("invoke and wait"), out) + session, err := resource.StartTurn(ctx, "run", proto.TextInput("invoke and wait"), out) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.(*prepared).Cancel(context.Background()) }) + t.Cleanup(func() { _ = resource.Close(context.Background()) }) select { case event := <-out: var call proto.ToolCallPayload @@ -191,11 +191,11 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { item.BearerToken = &value } req.LocalEnvironment.MCP = []proto.EnvironmentMCP{item} - resource, err := NewPreparationFactory(c)(t.Context(), req) + resource, err := NewExecutorFactory(&c)(t.Context(), req) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.Close() }) + t.Cleanup(func() { _ = resource.Close(context.Background()) }) raw, err := os.ReadFile(record + ".session") if err != nil { t.Fatal(err) @@ -220,7 +220,7 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { } else if len(server.Headers) != 0 { t.Fatal("anonymous MCP inherited credentials") } - dataDir := resource.(*prepared).session.opts.DataDir + dataDir := resource.(*executor).opts.DataDir for _, name := range []string{"config.yaml", "mcp.json", "workspace-profile.json"} { body, err := os.ReadFile(filepath.Join(dataDir, name)) if err != nil && !os.IsNotExist(err) { diff --git a/apps/daemon/internal/agent/mcode/executor_test.go b/apps/daemon/internal/agent/mcode/executor_test.go index eeac6a0ce..3c949ab0a 100644 --- a/apps/daemon/internal/agent/mcode/executor_test.go +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -13,6 +13,26 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) +func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { + t.Helper() + r := executionRequest(t) + r.RunID, r.Input, r.ConversationID = "", nil, "" + r.DisableExecutionEnvironment = false + r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()} + record := filepath.Join(t.TempDir(), "calls") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "native") + quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } + script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=prepared\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.LocalEnvironment.WorkspaceRoot, Network: "enabled", Scratch: t.TempDir()}, r, record +} + func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, string) { t.Helper() config, req, record := workspaceFixture(t) diff --git a/apps/daemon/internal/agent/mcode/preparation.go b/apps/daemon/internal/agent/mcode/preparation.go deleted file mode 100644 index 1366cb5ba..000000000 --- a/apps/daemon/internal/agent/mcode/preparation.go +++ /dev/null @@ -1,87 +0,0 @@ -package mcode - -import ( - "context" - "fmt" - "sync" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// Prepared retains its single admission API over the same native Executor. -// Runtime's Session lifecycle uses Executor directly. -type prepared struct { - mu sync.Mutex - executor *executor - session *Session - started, closed bool -} - -func NewPreparationFactory(config WorkspaceConfig) agent.PreparationFactory { - factory := NewExecutorFactory(&config) - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - value, err := factory(ctx, req) - if err != nil { - if value != nil { - return &prepared{executor: value.(*executor)}, err - } - return nil, err - } - e := value.(*executor) - return &prepared{executor: e, session: newTurnSession(ctx, req, e.opts, e.connection, nil)}, nil - } -} - -func (p *prepared) Start(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.closed || p.started { - return nil, fmt.Errorf("mcode: preparation is no longer available") - } - turn, err := p.executor.StartTurn(ctx, runID, input, out) - if turn != nil { - p.started, p.session = true, turn.(*Session) - } - return turn, err -} - -func (p *prepared) Close() error { - p.mu.Lock() - started := p.started - if !started { - p.closed = true - } - p.mu.Unlock() - if started { - return nil - } - return p.executor.Close(context.Background()) -} - -func (p *prepared) Cancel(ctx context.Context) error { - p.mu.Lock() - p.closed = true - current, started := p.session, p.started - p.mu.Unlock() - if started { - if err := current.Cancel(ctx); err != nil { - // The single-use Prepared owns disposal as well as cancellation. - if closeErr := p.executor.Close(ctx); closeErr != nil { - return closeErr - } - return nil - } - } - return p.executor.Close(ctx) -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - p.mu.Lock() - s := p.session - p.mu.Unlock() - if s == nil { - return proto.DonePayload{} - } - return s.CancellationOutcome() -} diff --git a/apps/daemon/internal/agent/mcode/preparation_test.go b/apps/daemon/internal/agent/mcode/preparation_test.go deleted file mode 100644 index 93b095ee4..000000000 --- a/apps/daemon/internal/agent/mcode/preparation_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { - t.Helper() - r := executionRequest(t) - r.RunID, r.Input, r.ConversationID = "", nil, "" - r.DisableExecutionEnvironment = false - r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()} - record := filepath.Join(t.TempDir(), "calls") - exe, err := os.Executable() - if err != nil { - t.Fatal(err) - } - binary := filepath.Join(t.TempDir(), "native") - quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } - script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=prepared\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { - t.Fatal(err) - } - return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.LocalEnvironment.WorkspaceRoot, Network: "enabled", Scratch: t.TempDir()}, r, record -} - -func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { - c, r, record := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - raw, err := os.ReadFile(record) - if err != nil || strings.Contains(string(raw), "session/prompt") { - t.Fatalf("preparation consumed input: %q %v", raw, err) - } - if p.session.opts.Dir != r.LocalEnvironment.WorkspaceRoot || p.session.opts.DataDir == r.LocalEnvironment.WorkspaceRoot { - t.Fatal("native cwd must use the workspace without moving Session state") - } - out := make(chan proto.Envelope) - var wg sync.WaitGroup - winners := make(chan bool, 8) - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - _, err := p.Start(ctx, "run", proto.TextInput("input"), out) - winners <- err == nil - }() - } - wg.Wait() - close(winners) - n := 0 - for winner := range winners { - if winner { - n++ - } - } - if n != 1 { - t.Fatalf("owners=%d", n) - } - if err := p.Close(); err != nil { - t.Fatal(err) - } - deltas, done := 0, 0 - for e := range out { - if e.Type == proto.TypeError { - t.Fatalf("execution: %s", e.Payload) - } - if e.Type == proto.TypeDelta { - deltas++ - } - if e.Type == proto.TypeDone { - done++ - select { - case <-p.session.exited: - t.Fatal("successful Turn disposed the reusable native owner") - default: - } - var d proto.DonePayload - _ = json.Unmarshal(e.Payload, &d) - if d.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { - t.Fatal("native identity lost") - } - } - } - if deltas != 100 || done != 1 { - t.Fatalf("deltas=%d done=%d", deltas, done) - } - raw, _ = os.ReadFile(record) - if strings.Count(string(raw), "session/prompt") != 1 { - t.Fatalf("inputs=%s", raw) - } -} - -func TestPreparedWorkspaceCloseBeforeStart(t *testing.T) { - c, r, _ := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - if err = resource.Close(); err != nil { - t.Fatal(err) - } - if _, err = resource.Start(ctx, "run", proto.TextInput("input"), make(chan proto.Envelope)); err == nil { - t.Fatal("released preparation started") - } - if err = resource.Close(); err != nil { - t.Fatal(err) - } -} - -func TestPreparedSubagentsReleaseUnusedOwner(t *testing.T) { - for _, method := range []string{"close", "cancel"} { - t.Run(method, func(t *testing.T) { - c, r, record := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - // The fixture only implements preparation. Enable execution cancellation's - // child branch after initialization to verify unused owners never enter it. - p.session.req.DisableSubagents = false - ended := make(chan error, 1) - go func() { - if method == "close" { - ended <- p.Close() - } else { - ended <- p.Cancel(ctx) - } - }() - select { - case err := <-ended: - if err != nil { - t.Fatal(err) - } - case <-ctx.Done(): - p.session.process.Cancel() - t.Fatal("unused owner did not close") - } - raw, err := os.ReadFile(record) - if err != nil || strings.Contains(string(raw), "session/prompt") || strings.Contains(string(raw), "delegation/stop") { - t.Fatalf("unused preparation executed work: %q %v", raw, err) - } - }) - } -} diff --git a/apps/daemon/internal/agent/mcode/unsupported.go b/apps/daemon/internal/agent/mcode/unsupported.go index a92be890d..e221bd595 100644 --- a/apps/daemon/internal/agent/mcode/unsupported.go +++ b/apps/daemon/internal/agent/mcode/unsupported.go @@ -34,15 +34,3 @@ func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.Wo func (s *Session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } - -func (s *prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/mcode/unsupported_test.go b/apps/daemon/internal/agent/mcode/unsupported_test.go index a059b11ef..eb0609bb6 100644 --- a/apps/daemon/internal/agent/mcode/unsupported_test.go +++ b/apps/daemon/internal/agent/mcode/unsupported_test.go @@ -27,21 +27,21 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { } var turn *Session check(turn.SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: secret, DeliveryID: secret})) - for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil)} { result, err := owner.ReadWorkspaceFile(ctx, secret, 1) check(err) if len(result.Data) != 0 || result.Truncated { t.Fatal("unsupported read fabricated data") } } - for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil)} { result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) check(err) if len(result.Entries) != 0 || result.Truncated { t.Fatal("unsupported listing fabricated entries") } } - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*Session)(nil)} { result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) check(err) if result.SizeBytes != 0 { diff --git a/apps/daemon/internal/agent/registry.go b/apps/daemon/internal/agent/registry.go index ec66a03ff..f64f74fa3 100644 --- a/apps/daemon/internal/agent/registry.go +++ b/apps/daemon/internal/agent/registry.go @@ -27,7 +27,6 @@ var ErrUnsupportedKind = errors.New("agent: unsupported agent_kind") type Registry struct { mu sync.RWMutex factories map[string]Factory - preparers map[string]PreparationFactory executors map[string]ExecutorFactory views map[string]View kinds map[string]proto.SupportedAgentKind @@ -37,7 +36,6 @@ type Registry struct { func NewRegistry() *Registry { return &Registry{ factories: make(map[string]Factory), - preparers: make(map[string]PreparationFactory), executors: make(map[string]ExecutorFactory), views: make(map[string]View), kinds: make(map[string]proto.SupportedAgentKind), @@ -100,16 +98,6 @@ func (r *Registry) ResolveExecutor(kind string) (ExecutorFactory, error) { return factory, nil } -func (r *Registry) ResolvePreparation(kind string) (PreparationFactory, error) { - r.mu.RLock() - defer r.mu.RUnlock() - f := r.preparers[kind] - if f == nil { - return nil, fmt.Errorf("agent: preparation unavailable for %q", kind) - } - return f, nil -} - // Configuration returns an owned declaration for registry wrappers. Wrappers // transfer it with the factory; they must not infer configuration from kind names. func (r *Registry) Configuration(kind string) (harnessconfig.Configuration, error) { diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index 41879e82b..415ad6079 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -71,7 +71,7 @@ func newConfig(t *testing.T, reg *agent.Registry, ca *x509.Certificate) Config { // register declares kind with view, or without one when view is nil. func register(reg *agent.Registry, kind string, view *agent.View) { info := proto.SupportedAgentKind{Kind: kind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported})} + MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})} declaration := agent.Declaration{Info: info, Configuration: harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: string(modelprovider.Anthropic)}}}} reg.Register(declaration, agent.Runtime{Info: info, View: view, diff --git a/apps/daemon/internal/agenthost/executor_linux.go b/apps/daemon/internal/agenthost/executor_linux.go index 80c005cf9..205ab9eee 100644 --- a/apps/daemon/internal/agenthost/executor_linux.go +++ b/apps/daemon/internal/agenthost/executor_linux.go @@ -61,7 +61,8 @@ func registry(harnesses *agent.Registry, factory agent.ExecutorFactory) *agent.R // what caps admits, and without what needs a local workspace. func viewInfo(info proto.SupportedAgentKind, caps agent.ViewCapabilities) proto.SupportedAgentKind { c := &info.Capabilities - c.LocalEnvironment, c.WorkspaceOutputExport = proto.CapabilitySupported, proto.CapabilityUnsupported + c.LocalEnvironment = proto.CapabilitySupported + c.WorkspaceReadPreparation, c.WorkspaceOutputExport = proto.CapabilityUnsupported, proto.CapabilityUnsupported c.EnvironmentNone, c.FunctionTools, c.FunctionResultImages, c.ToolSearch = caps.EnvironmentNone, caps.FunctionTools, caps.FunctionResultImages, caps.ToolSearch return info } diff --git a/apps/daemon/internal/dispatch/local_directory.go b/apps/daemon/internal/dispatch/local_directory.go index 02c1a4fa4..57e72d8e9 100644 --- a/apps/daemon/internal/dispatch/local_directory.go +++ b/apps/daemon/internal/dispatch/local_directory.go @@ -1,20 +1,7 @@ package dispatch -import ( - "context" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - +// localDirectoryPreparation is a ready read-only preparation. The bound local +// workspace serves its reads, so it holds no native resource. type localDirectoryPreparation struct{} -func prepareLocalDirectory(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - return localDirectoryPreparation{}, nil -} - -func (localDirectoryPreparation) Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) { - return nil, agent.ErrWorkspaceReadUnsupported -} - func (localDirectoryPreparation) Close() error { return nil } diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index b0363bf24..467a3c13f 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -29,11 +29,11 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing } var harnessCalls atomic.Int32 reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { harnessCalls.Add(1) return nil, errors.New("must not start a model") }) - reg.RegisterPreparation("native", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + reg.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { harnessCalls.Add(1) return nil, errors.New("must not prepare a harness") }) @@ -111,10 +111,10 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { t.Fatal(err) } reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { return nil, errors.New("must not start a model") }) - reg.RegisterPreparation("native", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + reg.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, errors.New("must not prepare a harness") }) sender := &recSender{} diff --git a/apps/daemon/internal/dispatch/preparation.go b/apps/daemon/internal/dispatch/preparation.go index ae57a2212..9f72d48d1 100644 --- a/apps/daemon/internal/dispatch/preparation.go +++ b/apps/daemon/internal/dispatch/preparation.go @@ -5,10 +5,10 @@ import ( "crypto/sha256" "encoding/json" "errors" + "io" "strings" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) @@ -30,7 +30,7 @@ type preparationState struct { timer *time.Timer ctx context.Context cancel context.CancelFunc - prepared agent.Prepared + prepared io.Closer stateKey string environmentID string busy bool @@ -53,14 +53,14 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) if !available { return r.rejectPreparation(env, "resource_unavailable") } - prepare, err := r.registry.ResolvePreparation(req.AgentKind) - if err != nil || !caps.Preparation.IsSupported() { + if !caps.Preparation.IsSupported() { return r.rejectPreparation(env, "unsupported_preparation") } - if req.WorkspaceReadOnly && (!caps.WorkspaceReadPreparation.IsSupported() || !proto.ValidWorkspaceReadPreparation(req)) { + if !caps.WorkspaceReadPreparation.IsSupported() || !proto.ValidWorkspaceReadPreparation(req) { return r.rejectPreparation(env, "unsupported_read_preparation") } - if req, err = r.localWorkspace.Configure(req); err != nil { + req, err := r.localWorkspace.Configure(req) + if err != nil { return r.rejectPreparation(env, "invalid_configuration") } if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.EnvironmentID() == "" || strings.TrimSpace(req.AgentStateKey) == "" || !req.StrictResume || !req.ReleaseOnCompletion { @@ -69,9 +69,6 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) if validateExecutionEnvironment(req, caps) != nil || (len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported()) { return r.rejectPreparation(env, "unsupported_configuration") } - if req.LocalEnvironment != nil && req.WorkspaceReadOnly { - prepare = prepareLocalDirectory - } encoded, err := json.Marshal(req) if err != nil { return r.rejectPreparation(env, "invalid_configuration") @@ -114,32 +111,21 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) r.shutdownWG.Add(1) r.mu.Unlock() - go r.prepareExecution(p, req, prepare) + go r.prepareExecution(p) return nil } -func (r *Router) prepareExecution(p *preparationState, req proto.PromptRequestPayload, prepare agent.PreparationFactory) { +// prepareExecution readies a read-only preparation without starting a Harness. +func (r *Router) prepareExecution(p *preparationState) { defer r.shutdownWG.Done() if !r.sendPreparation(p.requestID, p.trace, proto.PreparationStatusPayload{Handle: p.status.Handle, Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()}) { r.releasePreparation(p, "failed", "status_delivery_failed", false, false) } - var prepared agent.Prepared - var err error - if p.ctx.Err() == nil { - prepared, err = prepare(p.ctx, req) - } else { - err = p.ctx.Err() - } - if err == nil && prepared != nil && !p.workspaceReadOnly { - if _, ok := prepared.(agent.PreparedCancellation); !ok { - err = errors.New("executable preparation requires cross-transfer cancellation") - } - } r.mu.Lock() p.busy = false - p.prepared = prepared - ready := err == nil && prepared != nil && p.status.State == "preparing" && p.ctx.Err() == nil && !r.closed + ready := p.status.State == "preparing" && p.ctx.Err() == nil && !r.closed if ready { + p.prepared = localDirectoryPreparation{} p.status.State, p.status.Revision = "ready", p.status.Revision+1 } else if p.status.State == "preparing" { p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 @@ -153,14 +139,9 @@ func (r *Router) prepareExecution(p *preparationState, req proto.PromptRequestPa r.mu.Unlock() if !ready { r.closePreparationResource(p) - if p.workspaceReadOnly { - return - } - r.mu.Lock() - status = p.status - r.mu.Unlock() + return } - if !r.sendPreparation(p.requestID, p.trace, status) && ready { + if !r.sendPreparation(p.requestID, p.trace, status) { r.releasePreparation(p, "failed", "status_delivery_failed", false, false) } } diff --git a/apps/daemon/internal/dispatch/preparation_cancel_test.go b/apps/daemon/internal/dispatch/preparation_cancel_test.go index b21a5c21c..5ee5e3cf3 100644 --- a/apps/daemon/internal/dispatch/preparation_cancel_test.go +++ b/apps/daemon/internal/dispatch/preparation_cancel_test.go @@ -28,20 +28,6 @@ func (p *cancellationPreparation) Cancel(ctx context.Context) error { func (p *cancellationPreparation) CancellationOutcome() proto.DonePayload { return p.outcome } -type nonCancellablePreparation struct { - closed chan struct{} - once sync.Once -} - -func (*nonCancellablePreparation) Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("must not start") -} - -func (p *nonCancellablePreparation) Close() error { - p.once.Do(func() { close(p.closed) }) - return nil -} - func startCancellationPreparation(t *testing.T, r *dispatch.Router, sender *recSender) proto.PreparationStatusPayload { t.Helper() if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { @@ -116,7 +102,7 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { <-cleanupReturn return nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) <-startEntered if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { @@ -186,7 +172,7 @@ func TestPreparedCancellationBeforeTransferPreservesUnknownOutcome(t *testing.T) if boundary == "expiry" { timeout = 100 * time.Millisecond } - r := preparationRouter(t, sender, timeout, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, timeout, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) @@ -243,7 +229,7 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { } return session.Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) @@ -276,27 +262,6 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { } } -func TestExecutablePreparationRequiresCrossTransferCancellation(t *testing.T) { - sender := &recSender{} - p := &nonCancellablePreparation{closed: make(chan struct{})} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { - t.Fatal(err) - } - waitPreparationStatus(t, sender, "request", "failed", "") - select { - case <-p.closed: - case <-time.After(time.Second): - t.Fatal("unsupported executable preparation was not closed") - } - for _, frame := range sender.snapshot() { - var status proto.PreparationStatusPayload - if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "ready" { - t.Fatal("unsupported executable preparation became ready") - } - } -} - func TestPreparedCancellationTimeoutKeepsCapacityUntilStartReturns(t *testing.T) { sender := &recSender{} entered, allowReturn := make(chan struct{}), make(chan struct{}) @@ -308,7 +273,7 @@ func TestPreparedCancellationTimeoutKeepsCapacityUntilStartReturns(t *testing.T) return nil, ctx.Err() } var count atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { if count.Add(1) == 1 { return p, nil } diff --git a/apps/daemon/internal/dispatch/preparation_cleanup_test.go b/apps/daemon/internal/dispatch/preparation_cleanup_test.go index 20a45212b..b88d21b73 100644 --- a/apps/daemon/internal/dispatch/preparation_cleanup_test.go +++ b/apps/daemon/internal/dispatch/preparation_cleanup_test.go @@ -43,7 +43,7 @@ func TestPreparedCancellationDoesNotAcknowledgeFailedCleanup(t *testing.T) { return nil, context.Canceled } sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) @@ -74,7 +74,7 @@ func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { } return session.Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) waitPreparationStatus(t, sender, "request", "started", "") if err := r.Shutdown(t.Context()); !errors.Is(err, want) { @@ -105,7 +105,7 @@ func TestShutdownRetriesFailedPreparationCleanup(t *testing.T) { return nil }} sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) waitPreparationStatus(t, sender, "request", "ready", "") if err := r.Shutdown(t.Context()); !errors.Is(err, want) { @@ -126,7 +126,7 @@ func TestShutdownTimeoutAndConcurrentRetryWaitForCleanup(t *testing.T) { unblock := func() { once.Do(func() { close(release) }) } p := &retryablePreparation{close: func(int32) error { close(entered); <-release; return nil }} sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) t.Cleanup(unblock) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) waitPreparationStatus(t, sender, "request", "ready", "") @@ -176,7 +176,7 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { return session.Cancel(ctx) } var factories atomic.Int32 - r := preparationRouter(t, sender, 200*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, 200*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { if factories.Add(1) == 1 { return p, nil } diff --git a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go index a21823c6a..8ca7eebdc 100644 --- a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go +++ b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -2,30 +2,39 @@ package dispatch_test import ( "context" - "errors" "sync" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) +// preparedFixture is a disposable fault-injection resource; Start transfers it +// to one Session. A cancellablePreparation follows that Session across Start. +type preparedFixture interface { + Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) + Close() error +} + +type cancellablePreparation interface { + preparedFixture + agent.Session +} + +type preparationFactory func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) + // Old fault-injection fixtures model disposable executors, not reusable native implementations. -func preparationExecutorFixture(factory agent.PreparationFactory) agent.ExecutorFactory { +func preparationExecutorFixture(factory preparationFactory) agent.ExecutorFactory { return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { prepared, err := factory(ctx, req) if prepared == nil { return nil, err } - owner := &preparationExecutor{prepared: prepared} - if _, ok := prepared.(agent.PreparedCancellation); !ok { - return owner, errors.New("fixture has no cancellation target") - } - return owner, err + return &preparationExecutor{prepared: prepared}, err } } type preparationExecutor struct { - prepared agent.Prepared + prepared preparedFixture mu sync.Mutex cancelled bool } @@ -39,7 +48,7 @@ func (e *preparationExecutor) Close(ctx context.Context) error { var err error if resource, ok := e.prepared.(*retryablePreparation); ok { err = resource.Close() - } else if cancel, ok := e.prepared.(agent.PreparedCancellation); ok { + } else if cancel, ok := e.prepared.(cancellablePreparation); ok { err = cancel.Cancel(ctx) } else { err = e.prepared.Close() @@ -79,7 +88,7 @@ type preparationTurn struct { func (t *preparationTurn) Cancel(ctx context.Context) error { return t.owner.Close(ctx) } func (t *preparationTurn) CancellationOutcome() proto.DonePayload { - if target, ok := t.owner.prepared.(agent.PreparedCancellation); ok { + if target, ok := t.owner.prepared.(cancellablePreparation); ok { return target.CancellationOutcome() } return proto.DonePayload{} diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index e7b7b904f..056deeaab 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -116,13 +116,12 @@ func preparationRequest() proto.ExecutionPreparePayload { return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} } -func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory agent.PreparationFactory) *dispatch.Router { +func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router { t.Helper() reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, Permissions: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported, Permissions: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { return nil, errors.New("ordinary Factory must not be used for preparation") }) - reg.RegisterPreparation("prepared", true, factory) reg.RegisterExecutor("prepared", preparationExecutorFixture(factory)) r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, LocalWorkspace: preparationWorkspace(t)}) if err != nil { @@ -167,7 +166,7 @@ func TestPreparationReleaseDuringBlockedFactory(t *testing.T) { sender := &recSender{} p := &controlledPreparation{closed: make(chan struct{})} entered, allowReturn := make(chan context.Context, 1), make(chan struct{}) - r := preparationRouter(t, sender, time.Minute, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, time.Minute, func(ctx context.Context, req proto.PromptRequestPayload) (preparedFixture, error) { if req.RunID != "" || len(req.Input) != 0 { t.Error("run input reached preparation") } @@ -219,7 +218,7 @@ func TestPreparationSingleTransferAndReleaseDoesNotCancelRun(t *testing.T) { gotSession <- s return s, nil } - r := preparationRouter(t, sender, 80*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, 80*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) prepare := mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest()) if err := r.Handle(t.Context(), prepare); err != nil { t.Fatal(err) @@ -276,7 +275,7 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { close(cancelEntered) return (<-lateSession).Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) ready := waitPreparationStatus(t, sender, "request", "ready", "") _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "real-run", Input: proto.TextInput("input")})) @@ -313,7 +312,7 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { sender := &recSender{} created := make(chan *controlledPreparation, 2) - r := preparationRouter(t, sender, 60*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, 60*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { p := &controlledPreparation{closed: make(chan struct{})} created <- p return p, nil @@ -351,7 +350,7 @@ func (s failReadySender) Send(ctx context.Context, env proto.Envelope) error { func TestPreparationFailedReadyDeliveryAbandonsAdmission(t *testing.T) { created := make(chan struct{}) p := &controlledPreparation{closed: make(chan struct{})} - r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { close(created) return p, nil }) @@ -379,7 +378,7 @@ func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { "resume": func(p *proto.PromptRequestPayload) { p.StrictResume = false }, } { t.Run(name, func(t *testing.T) { - r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { t.Error("invalid preparation reached native factory") return nil, errors.New("invalid") }) diff --git a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go index 095ec5642..c2292f83c 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -89,7 +89,7 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) waitPreparationStatus(t, sender.recSender, "request", "started", "") @@ -244,7 +244,7 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) waitPreparationStatus(t, sender.recSender, "request", "started", "") @@ -302,7 +302,7 @@ func TestPreparedHandoffEarlyDonePublishesAfterStarted(t *testing.T) { return nil, ctx.Err() } } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) <-emitted if hasFrame(sender, proto.TypeDone, "run") || session.cancels() != 0 { diff --git a/apps/daemon/internal/dispatch/prepared_handoff_test.go b/apps/daemon/internal/dispatch/prepared_handoff_test.go index f70e4db7b..b27b899ca 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_test.go @@ -36,7 +36,7 @@ func TestPreparedHandoffDrainsBurstBeforeStartReturns(t *testing.T) { return nil, ctx.Err() } } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) select { case <-sent: @@ -118,7 +118,7 @@ func TestPreparedHandoffAbortBeforeStartAdmissionSkipsNativeStart(t *testing.T) return nil, errors.New("unexpected Start") } p.cancel = func(context.Context) error { return p.Close() } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender.recSender) select { case <-sender.entered: @@ -165,7 +165,7 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi out <- mustEnv(t, proto.TypePromptForUserChoice, "run", proto.PromptForUserChoicePayload{AskID: "publication-choice"}) return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender.recSender) select { case <-sender.entered: @@ -229,7 +229,7 @@ func TestPreparedHandoffUnsupportedFunctionReleasesOperationBarrier(t *testing.T session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) waitPreparationStatus(t, sender, "request", "started", "") result := mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "unsupported", Success: true, Content: functionResultContent("answer"), DeliveryID: "unsupported"}) @@ -256,7 +256,7 @@ func TestPreparedHandoffEarlyDoneStillAllowsExplicitAbort(t *testing.T) { return nil, context.Canceled } p.cancel = func(context.Context) error { unblock(); return nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) defer unblock() startCancellationPreparation(t, r, sender) waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "early Done release claim") @@ -280,7 +280,7 @@ func TestPreparedHandoffExpiresDuringStartedPublication(t *testing.T) { session.out = out return session, nil } - r := preparationRouter(t, sender, 100*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, 100*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) <-sender.entered time.Sleep(250 * time.Millisecond) @@ -320,7 +320,7 @@ func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { } return session.Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender) waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "early Done claim") unblock() diff --git a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go index 1b9954af5..f6bb85f2a 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -30,7 +29,7 @@ func TestRuntimePreparationUnavailablePreventsNativeExecutor(t *testing.T) { } sender := &recSender{} var calls atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { calls.Add(1) return nil, errors.New("native factory must not be reached") }) diff --git a/apps/daemon/internal/dispatch/workspace_directory_test.go b/apps/daemon/internal/dispatch/workspace_directory_test.go index 89de922ed..1c584484f 100644 --- a/apps/daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/daemon/internal/dispatch/workspace_directory_test.go @@ -18,7 +18,7 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { p.start = func(ctx context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) for _, name := range []string{"file", "second"} { if err := os.WriteFile(filepath.Join(os.Getenv("OAC_RUNTIME_WORKSPACE"), name), []byte("abc"), 0600); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go b/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go deleted file mode 100644 index fa48438e5..000000000 --- a/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package dispatch - -import ( - "context" - "errors" - "io" - "log/slog" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type workspaceFailureBoundary struct { - slog.Handler - once sync.Once - entered, resume chan struct{} -} - -func (h *workspaceFailureBoundary) Handle(ctx context.Context, record slog.Record) error { - h.once.Do(func() { close(h.entered); <-h.resume }) - return h.Handler.Handle(ctx, record) -} - -type workspaceCloseFunc struct { - agent.Prepared - close func() error -} - -func (p workspaceCloseFunc) Close() error { return p.close() } - -func TestReadConstructorFailureCannotPublishReleaseDuringCleanupRetry(t *testing.T) { - boundary := &workspaceFailureBoundary{Handler: slog.NewTextHandler(io.Discard, nil), entered: make(chan struct{}), resume: make(chan struct{})} - sender := make(workspaceStatusSender, 16) - r := &Router{sender: sender, shutdownCh: make(chan struct{}), log: slog.New(boundary)} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - timer := time.NewTimer(time.Hour) - defer timer.Stop() - retryEntered, retryResume := make(chan struct{}), make(chan struct{}) - var resumeOnce sync.Once - defer resumeOnce.Do(func() { close(retryResume) }) - calls := 0 - resource := workspaceCloseFunc{close: func() error { - calls++ - if calls == 2 { - close(retryEntered) - <-retryResume - } - return errors.New("cleanup incomplete") - }} - p := &preparationState{workspaceReadOnly: true, owns: true, busy: true, - ctx: ctx, cancel: cancel, timer: timer, deadline: time.Now().Add(time.Hour), - status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "preparing"}} - r.shutdownWG.Add(1) - prepared := make(chan struct{}) - go func() { - r.prepareExecution(p, proto.PromptRequestPayload{}, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - return resource, errors.New("construction failed") - }) - close(prepared) - }() - <-boundary.entered - r.releasePreparation(p, "released", "", true, true) - <-retryEntered - close(boundary.resume) - <-prepared - for len(sender) > 0 { - var status proto.PreparationStatusPayload - if (<-sender).DecodePayload(&status) == nil && status.State == "released" { - t.Error("constructor published release while retry cleanup was blocked") - } - } - resumeOnce.Do(func() { close(retryResume) }) - r.shutdownWG.Wait() - if !p.owns || p.busy || p.status.ErrorCode != "cleanup_unconfirmed" || calls != 2 { - t.Fatal("failed retry did not retain cleanup ownership") - } -} diff --git a/apps/daemon/internal/dispatch/workspace_preparation_status_test.go b/apps/daemon/internal/dispatch/workspace_preparation_status_test.go index ab8b4b98e..3e0bbcc25 100644 --- a/apps/daemon/internal/dispatch/workspace_preparation_status_test.go +++ b/apps/daemon/internal/dispatch/workspace_preparation_status_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) @@ -27,7 +26,6 @@ func (offlineWorkspaceStatusSender) Send(context.Context, proto.Envelope) error } type unsettledWorkspacePreparation struct { - agent.Prepared calls atomic.Int32 settled atomic.Bool } @@ -94,7 +92,6 @@ func TestReadPreparationRetryCannotPublishStaleRelease(t *testing.T) { // Local read-only preparation uses no native factory. Keep the shared close // settlement regression at its owner boundary instead of a retired remote fixture. type blockingWorkspacePreparation struct { - agent.Prepared entered, release chan struct{} } diff --git a/apps/daemon/internal/localworkspace/runtime_initialization.go b/apps/daemon/internal/localworkspace/runtime_initialization.go index 1c8164fef..2db03a780 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization.go @@ -36,9 +36,6 @@ func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInit if input.Action != "setup" && input.Action != "npm" && input.Action != "python" { return agentcapabilities.ErrInvalid } - if input.Network != "enabled" && input.Network != "disabled" { - return agentcapabilities.ErrInvalid - } directory, err := b.initializationCWD(input.CWD) if err != nil { return err diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_test.go b/apps/daemon/internal/localworkspace/runtime_initialization_test.go index 19ed6e18d..e16e46787 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_test.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_test.go @@ -74,7 +74,7 @@ func TestRuntimeInitializationPackageTargets(t *testing.T) { } packages, _ := PackageDirectory() for _, action := range []string{"npm", "python"} { - if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Network: "enabled", Packages: []string{"name with spaces", "second"}}); err != nil { + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Packages: []string{"name with spaces", "second"}}); err != nil { t.Fatal(action, err) } raw, err := os.ReadFile(receipt) @@ -242,7 +242,7 @@ func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { if err := os.WriteFile(filepath.Join(b.workspace, "proof.sh"), []byte("printf skill-proof"), 0600); err != nil { t.Fatal(err) } - err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", Network: "enabled", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) + err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) if err != nil { t.Fatal(err) } @@ -261,13 +261,13 @@ func TestRuntimeInitializationMissingDependenciesAndInvalidRequests(t *testing.T if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}); err != nil { t.Fatal(err) } - for _, input := range []proto.RuntimeInitialization{{Action: "setup", Network: "enabled", Command: "true"}, {Action: "npm", Network: "enabled", Packages: []string{"valid"}}, {Action: "python", Network: "enabled", Packages: []string{"valid"}}} { + for _, input := range []proto.RuntimeInitialization{{Action: "setup", Command: "true"}, {Action: "npm", Packages: []string{"valid"}}, {Action: "python", Packages: []string{"valid"}}} { var failed *InitializationFailure if err := b.initializeRuntime(t.Context(), input); !errors.As(err, &failed) || !strings.Contains(err.Error(), "requires") { t.Fatal("missing dependency was not explicit", input.Action, err) } } - for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Network: "enabled", Packages: []string{"--unsafe"}}} { + for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Packages: []string{"--unsafe"}}} { if !errors.Is(b.initializeRuntime(t.Context(), input), agentcapabilities.ErrInvalid) { t.Fatal("invalid request accepted", input.Action) } diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index c028c6248..bf62a5367 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -70,8 +70,7 @@ For example, the Codex adapter keeps its app-server and thread, the Claude adapt | `Steerer` | Explicit implementation or Unsupported | Additional non-durable active-Turn input | | `FunctionResultSubmitter` | Explicit implementation or Unsupported | Match native call and result identity and acknowledge application | | `PermissionResponder`, `UserChoiceResponder` | Explicit implementation or Unsupported | Respond to exact emitted identities; unknown or expired interactions stay distinct from Unsupported | -| `WorkspaceReader`, `WorkspaceDirectoryLister`, `WorkspaceWriter` | Explicit on Turn, Executor and Prepared owners | Use the authorized workspace, confirm access, commit or close, or return the operation's Unsupported error | -| `Prepared`, `PreparedCancellation` | Real implementation for an executable preparation | Keep resource and output ownership across Start, cancellation and unused cleanup | +| `WorkspaceReader`, `WorkspaceDirectoryLister`, `WorkspaceWriter` | Explicit on Turn and Executor owners | Use the authorized workspace, confirm access, commit or close, or return the operation's Unsupported error | | Neutral messages, images, MCP, structured output and Subagent observations | Explicit capability decisions | Keep each operation's protocol semantics; reject unsupported input before submission | Each adapter's `contracts.go` holds an individual compile-time assertion for each small interface. Do not embed a default implementation that makes future interfaces appear implemented. Adding a contract also requires a classification in the common completeness check and an explicit assertion in every public adapter; the check follows the authored Harness catalog. @@ -116,8 +115,8 @@ A Session owns one reusable Executor in its connected Runtime; a Turn owns one i - An error means settlement is unconfirmed and frees neither ownership nor capacity. Caller deadlines stop the wait, not the tracked cleanup. Retry the same cleanup target serially; a failed cleanup blocks replacement and keeps its resource slot. - `Executor.Close` confirms resource retirement independently of the Turn outcome: an immutable Turn error must not prevent closing the native transport once its work and output have stopped. - Include owned background work in settlement and keep the exact native cleanup target after a failure. Native termination belongs to the adapter; a bulk cleanup acknowledgement alone does not establish quiescence. -- Every `Session`, including a direct-call factory result, declares `CancellationOutcome`. `Turn` and `PreparedCancellation` inherit it. The snapshot keeps observed native identity, Usage and output and remains readable after cancellation. Missing evidence stays unset; an empty `DonePayload` means nothing has been observed, not that cancellation succeeded or is unsupported. Reading the snapshot does not wait for settlement. -- Direct-call `Session.Cancel` requests cancellation; output closure signals teardown. Executable `PreparedCancellation.Cancel` waits for local cleanup and output writes to stop. Turn settlement still requires `AwaitSettlement` and any required `Executor.Close`; neither a successful cancellation request nor its snapshot replaces those waits. +- Every `Session`, including a direct-call factory result, declares `CancellationOutcome`. `Turn` inherits it. The snapshot keeps observed native identity, Usage and output and remains readable after cancellation. Missing evidence stays unset; an empty `DonePayload` means nothing has been observed, not that cancellation succeeded or is unsupported. Reading the snapshot does not wait for settlement. +- Direct-call `Session.Cancel` requests cancellation; output closure signals teardown. Turn settlement still requires `AwaitSettlement` and any required `Executor.Close`; neither a successful cancellation request nor its snapshot replaces those waits. **What the Runtime does around a Turn.** One output consumer starts before native Start, drains the bounded 64-frame channel and keeps the terminal observation until Start publication, Turn settlement and admitted operation receipts finish. Natural completion never calls Cancel. Input, function and interaction admission close before settlement; operations already admitted hold their barrier through native receipts and outbound acknowledgement. The Runtime sends cancellation to the Turn before waiting on that barrier, because a written input may need a native interrupt to produce its receipt. It joins native settlement, any required confirmed Executor close, output drain and all admitted operations before an applied acknowledgement or reuse, and only then forwards Done or an applied cancellation receipt. A failed Close can report failure while keeping the same Run and outstanding operations for retry; a closed caller wait cannot manufacture an applied input receipt. The Runtime commits native continuity and releases the old Run's admission before publishing Done, since the receiver may start another Turn at once; a late terminal-send failure belongs to the old Run and cannot invalidate a successor that already owns the Executor. Connection shutdown owns transport-loss cleanup. The settlement wait is ten seconds and the receipt send budget five seconds; a timeout is not proof of quiescence. @@ -149,7 +148,7 @@ A Harness that supports the Subagent reads implements the [neutral observation c ## Register the adapter -Registration is static and requires a build. Export one `agent.Declaration` from `apps/daemon/internal/agent//declaration.go`, then add it to `harnessDeclarations` in [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go). The declaration contains the kind and complete capability descriptor, the shared model `Configuration` and a `Discover` function. Discovery receives the profile and diagnostic writers, owns native configuration and availability checks, and returns the installed `agent.Runtime` with its descriptor and session, preparation and Executor factories. Return nil when the adapter is not configured; return an unavailable descriptor with a session factory when configured prerequisites fail. Keep version gates and factory-selection conditions inside the adapter. +Registration is static and requires a build. Export one `agent.Declaration` from `apps/daemon/internal/agent//declaration.go`, then add it to `harnessDeclarations` in [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go). The declaration contains the kind and complete capability descriptor, the shared model `Configuration` and a `Discover` function. Discovery receives the profile and diagnostic writers, owns native configuration and availability checks, and returns the installed `agent.Runtime` with its descriptor and session and Executor factories. Return nil when the adapter is not configured; return an unavailable descriptor with a session factory when configured prerequisites fail. Keep version gates and factory-selection conditions inside the adapter. [`cli/agent_registration.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_registration.go) iterates the discovered runtimes and calls `Registry.Register` from `agent/harness.go`. It verifies that discovery retained the declared kind and installs factories in this order: @@ -157,8 +156,7 @@ Registration is static and requires a build. Export one `agent.Declaration` from | --- | --- | --- | | 1 | `RegisterKind(proto.SupportedAgentKind, harnessconfig.Configuration, agent.Factory)` | Kind, availability, version, `AgentKindCapabilities`, the model configuration declaration and the direct-call factory. It resets the other registrations, so call it first. | | 2 | `RegisterExecutor(kind, agent.ExecutorFactory)` | The Executor and Turn lifecycle used for execution; derives the `Preparation` capability | -| 3 | `RegisterPreparation(kind, workspaceRead, agent.PreparationFactory)` | Optional: separate read-only workspace preparation for qualified workspace operations | -| 4 | `RegisterView(kind, agent.View)` | Optional: the agent-host view declaration from `Runtime.View`. It panics with `ErrInvalidView` when `View.Validate` fails. Its Executor factory validates the model configuration like `RegisterExecutor` and enforces the [gateway rule](#endpoints-and-proxy). | +| 3 | `RegisterView(kind, agent.View)` | Optional: the agent-host view declaration from `Runtime.View`. It panics with `ErrInvalidView` when `View.Validate` fails. Its Executor factory validates the model configuration like `RegisterExecutor` and enforces the [gateway rule](#endpoints-and-proxy). | The direct-call `agent.Factory` delegates to the same Executor implementation. @@ -203,7 +201,7 @@ Run the `engine` and `execution` tests for omission, policy, combination and err ## Native model configuration -[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) owns the shared configuration declaration and pure preparation contract. Each adapter supplies one `Configuration`, in `internal/harnessconfig/`, to Core's composition and to the Runtime's `RegisterKind`. The direct factory, preparation and Executor paths all validate through that declaration before native side effects, and Registry wrappers keep the declaration with the factory. The wire object is `proto.HarnessConfig`. [Model execution](./model-execution.md#native-model-parameters) lists each Harness's accepted fields. +[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) owns the shared configuration declaration and pure preparation contract. Each adapter supplies one `Configuration`, in `internal/harnessconfig/`, to Core's composition and to the Runtime's `RegisterKind`. The direct factory and Executor paths both validate through that declaration before native side effects, and Registry wrappers keep the declaration with the factory. The wire object is `proto.HarnessConfig`. [Model execution](./model-execution.md#native-model-parameters) lists each Harness's accepted fields. A supplied `model` must be a nonempty string, and an explicit `model_provider` requires it. The native-owned connection path may omit both; explicit null is invalid. An explicitly empty declaration accepts no provider or nonempty native parameters and advertises no provider support. Unknown protocol formats and duplicate protocol declarations fail at registration. @@ -235,7 +233,7 @@ Keep provider keys in private operator files, never in commits or logs. Existing | Boundary | Tests | | --- | --- | -| Codex reuse, cancellation and unconfirmed cleanup | `codex/executor_test.go`, `terminal_cleanup_test.go`, `prepared_cancel_test.go` | +| Codex reuse, cancellation and unconfirmed cleanup | `codex/executor_test.go`, `terminal_cleanup_test.go` | | Codex input receipts and strict recovery | `codex/function_write_receipt_test.go`, `function_receipt_test.go`, `resume_test.go`, `recovery_test.go` | | Claude input ownership, cancellation and preparation cleanup | `claudesdk/executor_test.go`, `cancellation_test.go`, `preparation_test.go` | | MiniMax cancellation retirement, failed Start and cleanup retry | `mcode/executor_test.go`, `executor_backpressure_test.go` | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 6e3c61c9b..3630b0fa5 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "将原生 Harness 添加到 OpenAgentCore" source: contracts/agents-api/harness-onboarding.md -source_hash: a8ad85b32c64437d4cf46bd6366ade5e145d018ecdec9f65c7c77bd40558112a +source_hash: d62c6b491f137b9cafd254a056c02b285ece3141f26fcd96d290c63210dcf785 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -72,8 +72,7 @@ Environment 提供执行资源。受管 E2B、Docker 和 microsandbox 机器以 | `Steerer` | 明确实现或 Unsupported | 额外的非持久化活动 Turn 输入 | | `FunctionResultSubmitter` | 明确实现或 Unsupported | 匹配原生调用和结果身份,并确认应用 | | `PermissionResponder`、`UserChoiceResponder` | 明确实现或 Unsupported | 响应精确发出的身份;未知或已过期的交互与 Unsupported 保持区分 | -| `WorkspaceReader`、`WorkspaceDirectoryLister`、`WorkspaceWriter` | 在 Turn、Executor 和 Prepared 所有者上明确实现 | 使用授权工作区,确认访问,提交或关闭,或者返回该操作的 Unsupported 错误 | -| `Prepared`、`PreparedCancellation` | 对可执行准备进行真实实现 | 在 Start、取消和未使用清理之间保持资源和输出的所有权 | +| `WorkspaceReader`、`WorkspaceDirectoryLister`、`WorkspaceWriter` | 在 Turn 和 Executor 所有者上明确实现 | 使用授权工作区,确认访问,提交或关闭,或者返回该操作的 Unsupported 错误 | | 中立消息、图像、MCP、结构化输出和 Subagent 观察 | 明确作出能力决策 | 保持每项操作的协议语义;在提交前拒绝不受支持的输入 | 每个适配器的 `contracts.go` 都包含针对每个小型接口的单项编译时断言。不要嵌入会让未来接口看起来已经实现的默认实现。添加契约时,还必须在通用完整性检查中进行分类,并在每个公共适配器中添加明确断言;该检查遵循已编写的 Harness 目录。 @@ -118,8 +117,8 @@ Session 在其已连接的 Runtime 中拥有一个可复用的 Executor;Turn - 错误表示结算尚未确认,既不释放所有权,也不释放容量。调用方截止时间只会停止等待,不会停止受跟踪的清理。必须串行重试同一个清理目标;清理失败会阻止替换并保留其资源槽位。 - `Executor.Close` 独立于 Turn 结果确认资源退役:不可变的 Turn 错误不得阻止在其工作和输出已经停止后关闭原生传输层。 - 结算必须包含所属的后台工作,并在失败后保留精确的原生清理目标。原生终止由适配器负责;仅有批量清理确认并不能证明已达到静默状态。 -- 每个 `Session`(包括直接调用工厂的结果)都要声明 `CancellationOutcome`。`Turn` 和 `PreparedCancellation` 继承该声明。快照保留已观察到的原生身份、Usage 和输出,并在取消后仍可读取。缺失的证据保持未设置;空的 `DonePayload` 表示未观察到任何内容,而不是表示取消成功或不受支持。读取快照不会等待结算。 -- 直接调用的 `Session.Cancel` 请求取消;输出关闭表示拆卸开始。可执行准备中的 `PreparedCancellation.Cancel` 会等待本地清理和输出写入停止。Turn 结算仍需要 `AwaitSettlement` 和所需的任何 `Executor.Close`;取消请求成功或其快照都不能替代这些等待。 +- 每个 `Session`(包括直接调用工厂的结果)都要声明 `CancellationOutcome`。`Turn` 继承该声明。快照保留已观察到的原生身份、Usage 和输出,并在取消后仍可读取。缺失的证据保持未设置;空的 `DonePayload` 表示未观察到任何内容,而不是表示取消成功或不受支持。读取快照不会等待结算。 +- 直接调用的 `Session.Cancel` 请求取消;输出关闭表示拆卸开始。Turn 结算仍需要 `AwaitSettlement` 和所需的任何 `Executor.Close`;取消请求成功或其快照都不能替代这些等待。 **Runtime 在 Turn 前后执行的工作。** 一个输出消费者会在原生 Start 之前启动,耗尽有界的 64 帧通道,并将终态观察保留到 Start 发布、Turn 结算和已准入操作回执完成为止。正常完成绝不调用 Cancel。输入、函数和交互准入会在结算前关闭;已准入的操作会持有其屏障,直至原生回执和出站确认完成。Runtime 会在等待该屏障之前向 Turn 发送取消,因为已写入的输入可能需要原生中断才能生成回执。Runtime 会汇合原生结算、所需的已确认 Executor 关闭、输出耗尽和所有已准入操作,然后应用确认或执行复用,之后才会转发 Done 或已应用的取消回执。Close 失败可以报告失败,同时保留同一 Run 和未完成操作以供重试;已关闭的调用方等待无法凭空生成已应用输入回执。Runtime 会在发布 Done 前提交原生连续性状态并释放旧 Run 的准入,因为接收方可能立即启动另一个 Turn;迟到的终态发送失败属于旧 Run,不能使已拥有 Executor 的后继对象失效。连接关闭负责传输丢失清理。结算等待时间为十秒,回执发送预算为五秒;超时不能证明已达到静默状态。 @@ -151,7 +150,7 @@ MCP、公共函数、延迟函数发现、结构化输出、图像输入、详 ## 注册适配器 {#register-the-adapter} -注册是静态的,并且需要构建。从 `apps/daemon/internal/agent//declaration.go` 导出一个 `agent.Declaration`,然后将其添加到 [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go) 的 `harnessDeclarations` 中。声明包含 kind、完整能力描述符、共享模型 `Configuration` 和 `Discover` 函数。发现过程接收 profile 和诊断写入器,负责原生配置和可用性检查,并返回已安装的 `agent.Runtime` 及其描述符、session 工厂、准备工厂和 Executor 工厂。未配置适配器时返回 nil;已配置的前置条件失败时,返回不可用描述符和 session 工厂。将版本门控和工厂选择条件保留在适配器内部。 +注册是静态的,并且需要构建。从 `apps/daemon/internal/agent//declaration.go` 导出一个 `agent.Declaration`,然后将其添加到 [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go) 的 `harnessDeclarations` 中。声明包含 kind、完整能力描述符、共享模型 `Configuration` 和 `Discover` 函数。发现过程接收 profile 和诊断写入器,负责原生配置和可用性检查,并返回已安装的 `agent.Runtime` 及其描述符、session 工厂和 Executor 工厂。未配置适配器时返回 nil;已配置的前置条件失败时,返回不可用描述符和 session 工厂。将版本门控和工厂选择条件保留在适配器内部。 [`cli/agent_registration.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_registration.go) 遍历已发现的 Runtime,并调用 `agent/harness.go` 中的 `Registry.Register`。它验证发现过程是否保留了声明的 kind,并按以下顺序安装工厂: @@ -159,8 +158,7 @@ MCP、公共函数、延迟函数发现、结构化输出、图像输入、详 | --- | --- | --- | | 1 | `RegisterKind(proto.SupportedAgentKind, harnessconfig.Configuration, agent.Factory)` | Kind、可用性、版本、`AgentKindCapabilities`、模型配置声明和直接调用工厂。它会重置其他注册项,因此必须首先调用。 | | 2 | `RegisterExecutor(kind, agent.ExecutorFactory)` | 执行所用的 Executor 和 Turn 生命周期;据此派生 `Preparation` 能力 | -| 3 | `RegisterPreparation(kind, workspaceRead, agent.PreparationFactory)` | 可选:针对已认定合格的工作区操作的独立只读工作区准备 | -| 4 | `RegisterView(kind, agent.View)` | 可选:来自 `Runtime.View` 的 agent-host 视图声明。`View.Validate` 失败时以 `ErrInvalidView` panic。其 Executor 工厂像 `RegisterExecutor` 一样验证模型配置,并执行[网关规则](#endpoints-and-proxy)。 | +| 3 | `RegisterView(kind, agent.View)` | 可选:来自 `Runtime.View` 的 agent-host 视图声明。`View.Validate` 失败时以 `ErrInvalidView` panic。其 Executor 工厂像 `RegisterExecutor` 一样验证模型配置,并执行[网关规则](#endpoints-and-proxy)。 | 直接调用的 `agent.Factory` 委托给同一个 Executor 实现。 @@ -205,7 +203,7 @@ profile 是纯逻辑:它使用现有的公共类型和协议类型,声明受 ## 原生模型配置 {#native-model-configuration} -[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 负责共享配置声明和纯准备契约。每个适配器在 `internal/harnessconfig/` 中提供一个 `Configuration`,供 Core 组合和 Runtime 的 `RegisterKind` 使用。直接调用工厂、准备路径和 Executor 路径都会在产生原生副作用之前通过该声明进行验证,而 Registry 包装器会将声明与工厂保留在一起。线协议对象是 `proto.HarnessConfig`。[Model execution](model-execution.md#native-model-parameters) 列出了每个 Harness 接受的字段。 +[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 负责共享配置声明和纯准备契约。每个适配器在 `internal/harnessconfig/` 中提供一个 `Configuration`,供 Core 组合和 Runtime 的 `RegisterKind` 使用。直接调用工厂和 Executor 路径都会在产生原生副作用之前通过该声明进行验证,而 Registry 包装器会将声明与工厂保留在一起。线协议对象是 `proto.HarnessConfig`。[Model execution](model-execution.md#native-model-parameters) 列出了每个 Harness 接受的字段。 提供的 `model` 必须是非空字符串,并且显式指定 `model_provider` 时必须提供它。原生所有权连接路径可以省略二者;显式 null 无效。显式为空的声明不接受任何 Provider 或非空原生参数,也不宣称支持 Provider。未知协议格式和重复协议声明会导致注册失败。 @@ -237,7 +235,7 @@ Environment 验收使用 `services/core/tests/official_environment_{templates,se | 边界 | 测试 | | --- | --- | -| Codex 复用、取消和未确认清理 | `codex/executor_test.go`、`terminal_cleanup_test.go`、`prepared_cancel_test.go` | +| Codex 复用、取消和未确认清理 | `codex/executor_test.go`、`terminal_cleanup_test.go` | | Codex 输入回执和严格恢复 | `codex/function_write_receipt_test.go`、`function_receipt_test.go`、`resume_test.go`、`recovery_test.go` | | Claude 输入所有权、取消和准备清理 | `claudesdk/executor_test.go`、`cancellation_test.go`、`preparation_test.go` | | MiniMax 取消退役、Start 失败和清理重试 | `mcode/executor_test.go`、`executor_backpressure_test.go` | diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index f2e995dfe..ccab33d78 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -186,7 +186,7 @@ Core stores accepted values in the Turn outcome as `engine_error_code` and `engi ## Workspace operations -A workspace read that needs no running Turn uses the read-only preparation profile: `execution_prepare` with `workspace_read_only`, which requires the `workspace_read_preparation` capability. It accepts only the bound Environment and resource identity; execution options, model and MCP credentials, native Session continuation and model or tool input are excluded, and the owner rejects `execution_start`. A Runtime may serve it from its bound local filesystem without starting a Harness process. The profile publishes `released` only after local close succeeds; a cleanup error keeps ownership and reports `cleanup_unconfirmed`. A failed factory returns its resource with the error while cleanup is unconfirmed, and wrappers keep both values. A successful cleanup retry publishes the confirmed release; a stale status snapshot never publishes success. A release request, HTTP disconnect or remote socket closure alone does not confirm cleanup. +A workspace read that needs no running Turn uses the read-only preparation profile: `execution_prepare` with `workspace_read_only`, which requires the `workspace_read_preparation` capability. It accepts only the bound Environment and resource identity; execution options, model and MCP credentials, native Session continuation and model or tool input are excluded, and the owner rejects `execution_start`. The Runtime serves it from its bound local workspace without starting a Harness process. The profile publishes `released` only after local close succeeds; a cleanup error keeps ownership and reports `cleanup_unconfirmed`. A successful cleanup retry publishes the confirmed release; a stale status snapshot never publishes success. A release request, HTTP disconnect or remote socket closure alone does not confirm cleanup. `workspace_read` targets an existing preparation handle, or the Run it was transferred to, on the same authenticated device connection, with the exact frozen Environment identity; callers cannot supply sockets, credentials or workspace roots. `operation: directory` lists one workspace-relative directory (an empty path selects the root) with mutually exclusive byte and entry limits. A result carries at most 1024 single-component UTF-8 names of at most 255 bytes each, the entry kind, regular-file sizes and explicit truncation, and is returned only after directory access and handle cleanup settle. There is no snapshot, recursion or pagination at this layer. Byte and directory reads share target checks, correlation, capacity and retained operation waits. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 2e0bc3d7d..df11adff9 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: 2b5d80e228532628ebf3fac18c37b32bc20ce235833bef96e9f4e2bb78a8b7ae +source_hash: baa81e7d48db878d926a64ff627ff55c66e85ed564833ce731fd2cf164b66f18 --- 此协议在 Runtime daemon 获取机器凭据后连接 Core 与 daemon,定义 daemon 连接上消息的含义和顺序。wire 类型、限制和验证器仅在 [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/agentdaemon/proto) 中定义一次;Core 的 [gateway](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/runtimegateway) 与参考 Runtime 的 [dispatcher](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/dispatch) 都使用它们,因此无需同步第二套 payload schema。签发凭据和打开连接的 HTTP 路由见[机器连接 API](../../contracts/agents-api/zh/machine-api.md)。 @@ -188,7 +188,7 @@ Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engin ## 工作区操作 {#workspace-operations} -无需运行 Turn 的工作区读取使用只读 preparation profile:带 `workspace_read_only` 的 `execution_prepare`,要求 `workspace_read_preparation` 能力。仅接受绑定的 Environment 和 resource 身份;不包含 execution option、model 与 MCP 凭据、原生 Session continuation、model 或 tool 输入,owner 拒绝 `execution_start`。Runtime 可以从绑定的本地文件系统提供读取,不启动 Harness 进程。profile 仅在本地 close 成功后发布 `released`;清理错误保留所有权并报告 `cleanup_unconfirmed`。factory 失败且清理未确认时,将 resource 与 error 一起返回,wrapper 保留两者。清理重试成功后发布已确认释放;旧 status snapshot 不发布成功。release 请求、HTTP 断连或远端 socket 关闭本身都不确认清理。 +无需运行 Turn 的工作区读取使用只读 preparation profile:带 `workspace_read_only` 的 `execution_prepare`,要求 `workspace_read_preparation` 能力。仅接受绑定的 Environment 和 resource 身份;不包含 execution option、model 与 MCP 凭据、原生 Session continuation、model 或 tool 输入,owner 拒绝 `execution_start`。Runtime 从绑定的本地工作区提供读取,不启动 Harness 进程。profile 仅在本地 close 成功后发布 `released`;清理错误保留所有权并报告 `cleanup_unconfirmed`。清理重试成功后发布已确认释放;旧 status snapshot 不发布成功。release 请求、HTTP 断连或远端 socket 关闭本身都不确认清理。 `workspace_read` 在同一已认证设备连接上,针对现有 preparation handle 或它已转移给的 Run,使用精确冻结的 Environment 身份;调用方不能提供 socket、凭据或 workspace root。`operation: directory` 列出一个 workspace 相对目录(空路径选择根目录),字节与条目限制互斥。结果最多携带 1024 个单路径组件 UTF-8 名称,每个最多 255 字节,并包含 entry kind、普通文件大小和明确截断信息;仅在目录访问与 handle 清理结算后返回。此层没有快照、递归或分页。字节读取与目录读取共享目标检查、关联、容量和保留的操作等待。 diff --git a/internal/agentdaemon/proto/runtime_prepare.go b/internal/agentdaemon/proto/runtime_prepare.go index c4b78e3fc..bbb2549c6 100644 --- a/internal/agentdaemon/proto/runtime_prepare.go +++ b/internal/agentdaemon/proto/runtime_prepare.go @@ -29,7 +29,6 @@ type RuntimeInitialFile struct { type RuntimeInitialization struct { Action string `json:"action"` Env map[string]string `json:"env,omitempty"` - Network string `json:"network,omitempty"` Packages []string `json:"packages,omitempty"` Command string `json:"command,omitempty"` CWD string `json:"cwd,omitempty"` @@ -179,9 +178,6 @@ func validWorkspacePath(value string, allowRoot bool) bool { } func validRuntimeInitialization(p RuntimeInitialization) bool { - if p.Network != "" && p.Network != "enabled" && p.Network != "disabled" { - return false - } if p.CWD != "" && !validWorkspacePath(p.CWD, true) { return false } @@ -197,7 +193,7 @@ func validRuntimeInitialization(p RuntimeInitialization) bool { } return true case "npm", "python": - if p.Env != nil || p.Command != "" || p.CWD != "" || len(p.Packages) == 0 || len(p.Packages) > 1000 || p.Network == "" { + if p.Env != nil || p.Command != "" || p.CWD != "" || len(p.Packages) == 0 || len(p.Packages) > 1000 { return false } for _, value := range p.Packages { @@ -207,7 +203,7 @@ func validRuntimeInitialization(p RuntimeInitialization) bool { } return true case "setup": - return p.Env == nil && p.Packages == nil && p.Network != "" && p.Command != "" && utf8.ValidString(p.Command) && !strings.ContainsRune(p.Command, 0) + return p.Env == nil && p.Packages == nil && p.Command != "" && utf8.ValidString(p.Command) && !strings.ContainsRune(p.Command, 0) default: return false } diff --git a/internal/agentdaemon/proto/runtime_prepare_test.go b/internal/agentdaemon/proto/runtime_prepare_test.go index d7cb3359b..84519b778 100644 --- a/internal/agentdaemon/proto/runtime_prepare_test.go +++ b/internal/agentdaemon/proto/runtime_prepare_test.go @@ -176,10 +176,10 @@ func TestRuntimePreparationInitialActions(t *testing.T) { base := RuntimePreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} for _, initialization := range []RuntimeInitialization{ {Action: "configure", Env: map[string]string{"EXAMPLE": "value"}}, - {Action: "npm", Network: "disabled", Packages: []string{"typescript"}}, - {Action: "python", Network: "enabled", Packages: []string{"requests"}}, - {Action: "setup", Network: "enabled", Command: "echo done"}, - {Action: "setup", Network: "disabled", Command: "echo done", CWD: "/workspace/project"}, + {Action: "npm", Packages: []string{"typescript"}}, + {Action: "python", Packages: []string{"requests"}}, + {Action: "setup", Command: "echo done"}, + {Action: "setup", Command: "echo done", CWD: "/workspace/project"}, } { p := base p.Action, p.Initialization = "initialize", &initialization @@ -203,13 +203,12 @@ func TestRuntimePreparationInitialActions(t *testing.T) { {Action: "exec", Command: "echo done"}, {Action: "configure", Packages: []string{"git"}}, {Action: "configure", Env: map[string]string{"A=B": "value"}}, - {Action: "system", Network: "enabled", Packages: []string{"git"}}, - {Action: "npm", Network: "invalid", Packages: []string{"a"}}, - {Action: "python", Network: "enabled", Packages: []string{"--help"}}, - {Action: "setup", Network: "enabled", Command: "x", CWD: "/environment"}, - {Action: "setup", Network: "enabled", Command: "x", CWD: "/workspace/../private"}, - {Action: "setup", Network: "enabled", Command: "x", Env: map[string]string{}}, - {Action: "setup", Network: "enabled", Command: "x", Packages: []string{}}, + {Action: "system", Packages: []string{"git"}}, + {Action: "python", Packages: []string{"--help"}}, + {Action: "setup", Command: "x", CWD: "/environment"}, + {Action: "setup", Command: "x", CWD: "/workspace/../private"}, + {Action: "setup", Command: "x", Env: map[string]string{}}, + {Action: "setup", Command: "x", Packages: []string{}}, } { p := base p.Action, p.Initialization = "initialize", &initialization diff --git a/services/core/internal/execution/runtime_setup.go b/services/core/internal/execution/runtime_setup.go index d463d8a02..ccfcb795f 100644 --- a/services/core/internal/execution/runtime_setup.go +++ b/services/core/internal/execution/runtime_setup.go @@ -54,17 +54,16 @@ func setupOperations(setup environmentconfig.Setup) []runtimeSetupOperation { for i, plugin := range setup.Plugins { result = append(result, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "plugin", Slot: i, Plugin: &plugin.Metadata}, Data: plugin.Archive}) } - // Provisioning network policy is distinct from the policy enforced for Turns. for _, packages := range []struct { action string values []string }{{"npm", setup.Packages.NPM}, {"python", setup.Packages.Python}} { if len(packages.values) > 0 { - result = append(result, initialize(proto.RuntimeInitialization{Action: packages.action, Network: "enabled", Packages: packages.values})) + result = append(result, initialize(proto.RuntimeInitialization{Action: packages.action, Packages: packages.values})) } } for i, command := range setup.Commands { - operation := initialize(proto.RuntimeInitialization{Action: "setup", Network: "enabled", Command: command.Command, CWD: command.CWD}) + operation := initialize(proto.RuntimeInitialization{Action: "setup", Command: command.Command, CWD: command.CWD}) operation.Index = i result = append(result, operation) } diff --git a/services/core/internal/runtimegateway/runtime_prepare_test.go b/services/core/internal/runtimegateway/runtime_prepare_test.go index b070cbb50..02834d87a 100644 --- a/services/core/internal/runtimegateway/runtime_prepare_test.go +++ b/services/core/internal/runtimegateway/runtime_prepare_test.go @@ -289,7 +289,7 @@ func TestRuntimeInitializationNoDataAndExitReceipt(t *testing.T) { t.Run(fmt.Sprint(exit), func(t *testing.T) { s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) defer s.Close("test") - request := proto.RuntimePreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Network: "enabled", Command: "echo test"}} + request := proto.RuntimePreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Command: "echo test"}} if _, err := s.PrepareRuntime(t.Context(), uuid.NewString(), request, []byte("forbidden")); err == nil { t.Fatal("initialization body accepted") } From 2fa3100cd711592d23a5b9c4bb3d7ac5d807af7f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 17:19:52 +0800 Subject: [PATCH 3/4] Run environment none in an empty root and stdio MCP under its alias (#475) * Build an empty root for a view without a world * Run a frozen command for each process broker alias * Run environment none in an empty root and stdio MCP under its alias * Keep the empty root searchable under any umask --- apps/daemon/internal/agenthost/admit.go | 69 ++++--- .../internal/agenthost/admit_linux_test.go | 51 ++++- .../agenthost/agenthost_linux_test.go | 12 +- apps/daemon/internal/agenthost/doc.go | 27 ++- .../internal/agenthost/executor_linux.go | 8 +- .../daemon/internal/agenthost/launch_linux.go | 37 +++- .../internal/agenthost/sessiondir_linux.go | 7 +- .../internal/agenthost/view_linux_test.go | 189 +++++++++++++++--- .../processbroker/broker_linux_test.go | 33 +++ apps/daemon/internal/processbroker/config.go | 48 ++++- .../processbroker/invocation_linux.go | 16 +- .../internal/sessionview/build_linux.go | 49 +++++ .../internal/sessionview/control_linux.go | 4 +- apps/daemon/internal/sessionview/doc.go | 2 +- .../internal/sessionview/launcher_linux.go | 37 ++-- apps/daemon/internal/sessionview/spec.go | 4 +- .../daemon/internal/sessionview/view_linux.go | 30 ++- .../internal/sessionview/view_linux_test.go | 53 +++++ 18 files changed, 552 insertions(+), 124 deletions(-) diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index 04b9f0bde..cb1ab18b1 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/gateway" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processbroker" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" ) @@ -34,7 +35,8 @@ type plan struct { 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. + // that name on the sandbox PATH, each shim path the same path, and each + // alias its stdio MCP server. An empty-root view has no shims. executables processbroker.Executables } @@ -89,17 +91,17 @@ func loadRoots(dir string) (*x509.CertPool, error) { // admit checks req and derives its plan without touching anything. env is // the Session's Environment, and openNetwork its Network dial for the -// gateway. +// gateway, which a Session with environment none never uses. func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env Environment, openNetwork func(context.Context) (sandboxlink.Stream, error)) (*plan, error) { view, err := cfg.Harnesses.ResolveView(req.AgentKind) if err != nil { return nil, fmt.Errorf("%w: admit: %w", ErrUnsupported, err) } - caps, local := view.Capabilities, req.LocalEnvironment + caps, local, none := view.Capabilities, req.LocalEnvironment, req.DisableExecutionEnvironment switch { - case local == nil && !req.DisableExecutionEnvironment: + case local == nil && !none: return nil, invalidSession("a Session with neither a workspace nor environment none is an incomplete binding") - case req.DisableExecutionEnvironment && !caps.EnvironmentNone.IsSupported(): + case none && !caps.EnvironmentNone.IsSupported(): return nil, unsupported("environment none") case local != nil && !isViewPath(local.WorkspaceDirectory): return nil, invalidSession("workspace %q is not absolute and clean", local.WorkspaceDirectory) @@ -115,7 +117,7 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env return nil, unsupported("function tools") case req.ToolSearch && !caps.ToolSearch.IsSupported(): return nil, unsupported("tool search") - case len(view.Shims) > 0 && !hasPATH(env): + case !none && len(view.Shims) > 0 && !hasPATH(env): return nil, invalidSession("the view's shims run names on the sandbox PATH, and the Environment sets no PATH") } if req.ModelProvider == nil { @@ -129,32 +131,47 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env if err != nil { return nil, invalidSession("MCP: %v", err) } - for _, b := range bindings { + gw := gateway.Config{Model: provider, Prompt: req, RootCAs: roots, Proxy: view.Proxy == agent.ViewProxyEnv} + table := processbroker.Executables{Aliases: map[string]processbroker.Command{}} + if !none { + gw.OpenNetwork, table.Names, table.Paths = openNetwork, identity(view.Shims), identity(view.ShimPaths) + } + for i, b := range bindings { + if b.Transport != "stdio" { + gw.MCP = append(gw.MCP, b) + continue + } + server := b.Stdio.Server + dir := server.CWD + if !path.IsAbs(dir) { + dir = path.Join(b.Stdio.InstallationRoot, b.Stdio.PackageRoot, dir) + } switch { - case b.Transport == "stdio" && b.CredentialAuthority != "none": + case b.CredentialAuthority != "none": return nil, fmt.Errorf("%w: admit: %w: stdio MCP server %q needs a credential", ErrUnsupported, agent.ErrViewHandoff, b.ServerLabel) - case b.Transport == "stdio" && !caps.StdioMCP.IsSupported(): + case !caps.StdioMCP.IsSupported(): return nil, unsupported("stdio MCP server %q", b.ServerLabel) + case !path.IsAbs(dir): + return nil, invalidSession("stdio MCP server %q has no absolute working directory", b.ServerLabel) + case !strings.Contains(server.Command, "/") && !hasPATH(env): + return nil, invalidSession("stdio MCP server %q runs a name on the sandbox PATH, and the Environment sets no PATH", b.ServerLabel) } + table.Aliases[path.Base(agent.ViewAlias(i))] = processbroker.Command{Executable: server.Command, Args: slices.Clone(server.Args), Dir: dir} } if err := checkLayout(cfg, view); err != nil { return nil, err } - gw := gateway.Config{ - Model: provider, - MCP: bindings, - Prompt: req, - OpenNetwork: openNetwork, - RootCAs: roots, - Proxy: view.Proxy == agent.ViewProxyEnv, - } endpoints, err := gateway.Plan(gw) if err != nil { return nil, fmt.Errorf("%w: gateway: %w", ErrInvalidSession, err) } - p := &plan{view: view, gateway: gw, request: handoff(req, provider, endpoints), proxy: endpoints.Proxy, - executables: processbroker.Executables{Names: identity(view.Shims), Paths: identity(view.ShimPaths)}} - for _, b := range bindings { + p := &plan{view: view, gateway: gw, request: handoff(req, provider, endpoints), proxy: endpoints.Proxy, executables: table} + for i, b := range bindings { + if b.Stdio != nil { + // The Harness runs the binding under its alias, which the process + // broker maps to the frozen command. + b.Stdio = &proto.EnvironmentMCP{Server: agentplugin.MCPServer{Name: b.ServerLabel, Type: "stdio", Command: agent.ViewAlias(i)}} + } b.ServerURL, b.BearerToken, b.HTTPHeaders = endpoints.MCP[b.ServerLabel], nil, nil if b.AllowedTools != nil { tools := slices.Clone(*b.AllowedTools) @@ -167,15 +184,17 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env // handoff rewrites the request as a view Executor receives it: the model // provider is the gateway's listener with the placeholder key, MCP is only in -// ViewSession.MCP, and the workspace is the Environment's declared directory, -// which the view shows from the sandbox. +// ViewSession.MCP, and the workspace, if any, is the Environment's declared +// directory, which the view shows from the sandbox. func handoff(req proto.PromptRequestPayload, provider modelprovider.Provider, endpoints gateway.Endpoints) proto.PromptRequestPayload { provider.BaseURL, provider.APIKey = endpoints.Model, modelprovider.Placeholder req.ModelProvider = &provider req.MCPHTTPServers = nil - local := *req.LocalEnvironment - local.MCP, local.WorkspaceRoot = nil, local.WorkspaceDirectory - req.LocalEnvironment = &local + if req.LocalEnvironment != nil { + local := *req.LocalEnvironment + local.MCP, local.WorkspaceRoot = nil, local.WorkspaceDirectory + req.LocalEnvironment = &local + } return req } diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index c9b466705..054085479 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -9,12 +9,14 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "slices" "strings" "sync/atomic" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processbroker" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" @@ -26,9 +28,9 @@ var errFactory = errors.New("factory reached") // viewFixture registers "viewed", whose factory records what it receives and // whose view supports no capability, "supporting", whose view supports every -// capability but environment none and stdio MCP, "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. +// capability, "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 @@ -56,7 +58,6 @@ func newViewFixture(t *testing.T) *viewFixture { register(reg, "viewed", &view) supporting := view supporting.Capabilities = declared(proto.CapabilitySupported) - supporting.Capabilities.EnvironmentNone, supporting.Capabilities.StdioMCP = proto.CapabilityUnsupported, proto.CapabilityUnsupported register(reg, "supporting", &supporting) masked := view masked.Masks = []agent.ViewMask{{Path: "/etc/passwd"}} @@ -91,6 +92,12 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { "credentialed stdio MCP": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools", EnvVars: []string{"TOKEN"}}}} }, []error{ErrUnsupported, agent.ErrViewHandoff}}, + "stdio MCP without an absolute directory": {"supporting", func(r *proto.PromptRequestPayload) { + r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{PackageRoot: "pkg", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "/bin/tools"}}} + }, []error{ErrInvalidSession}}, + "stdio MCP name without PATH": {"supporting", func(r *proto.PromptRequestPayload) { + r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{InstallationRoot: "/capabilities", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools"}}} + }, []error{ErrInvalidSession}}, } { req := request(c.kind, "/workspace", "https://model.test", "sk-test") c.change(&req) @@ -138,7 +145,7 @@ func TestAdmissionFollowsDeclarations(t *testing.T) { use func(*proto.PromptRequestPayload) viewed, supporting bool }{ - "environment none": {func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment, r.LocalEnvironment = true, nil }, false, false}, + "environment none": {func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment, r.LocalEnvironment = true, nil }, false, true}, "Skills": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities, r.LocalEnvironment.CapabilityRoot = true, "/capabilities" r.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "skills/review"}} @@ -146,8 +153,8 @@ func TestAdmissionFollowsDeclarations(t *testing.T) { "function tools": {func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "lookup"}} }, false, true}, "tool search": {func(r *proto.PromptRequestPayload) { r.ToolSearch = true }, false, true}, "stdio MCP": {func(r *proto.PromptRequestPayload) { - r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools"}}} - }, false, false}, + r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{InstallationRoot: "/capabilities", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "/bin/tools"}}} + }, false, true}, // An installation with only HTTP MCP needs no Skill support. "installed HTTP MCP": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities, r.LocalEnvironment.CapabilityRoot = true, "/capabilities" @@ -164,13 +171,41 @@ func TestAdmissionFollowsDeclarations(t *testing.T) { } } +// The binding at index i runs under alias i, which the process broker maps to +// the frozen command; only HTTP bindings reach the gateway. +func TestStdioMCPRunsUnderItsAlias(t *testing.T) { + f := newViewFixture(t) + roots, err := checkConfig(f.cfg) + if err != nil { + t.Fatal(err) + } + req := request("supporting", "/workspace", "https://model.test", "sk-test") + req.LocalEnvironment.MCP = []proto.EnvironmentMCP{ + {Server: agentplugin.MCPServer{Name: "docs", Type: "http", URL: "https://mcp.test/docs"}}, + {InstallationRoot: "/capabilities", PackageRoot: "pkg", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "bin/tools", Args: []string{"--stdio"}, CWD: "run"}}, + } + network := func(context.Context) (sandboxlink.Stream, error) { return nil, errors.New("not dialled") } + p, err := admit(f.cfg, roots, req, Environment{}, network) + if err != nil { + t.Fatal(err) + } + alias := proto.EnvironmentMCP{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: agent.ViewAlias(1)}} + if len(p.mcp) != 2 || p.mcp[1].Stdio == nil || !reflect.DeepEqual(*p.mcp[1].Stdio, alias) || len(p.gateway.MCP) != 1 || p.gateway.MCP[0].ServerLabel != "docs" { + t.Errorf("ViewSession.MCP %+v and gateway MCP %+v; want the stdio binding under its alias and only HTTP at the gateway", p.mcp, p.gateway.MCP) + } + want := map[string]processbroker.Command{"oac-mcp-1": {Executable: "bin/tools", Args: []string{"--stdio"}, Dir: "/capabilities/pkg/run"}} + if !reflect.DeepEqual(p.executables.Aliases, want) { + t.Errorf("aliases %+v, want %+v", p.executables.Aliases, want) + } +} + func TestRegistryDescribesTheViewPath(t *testing.T) { f := newViewFixture(t) var kinds []string for _, info := range (&Host{cfg: f.cfg}).Registry(nil).SupportedAgentKinds() { kinds = append(kinds, info.Kind) c, declared := info.Capabilities, info.Kind == "supporting" - if !c.Preparation.IsSupported() || !c.LocalEnvironment.IsSupported() || !c.MCPHTTPTools.IsSupported() || c.EnvironmentNone.IsSupported() || + if !c.Preparation.IsSupported() || !c.LocalEnvironment.IsSupported() || !c.MCPHTTPTools.IsSupported() || c.EnvironmentNone.IsSupported() != declared || c.FunctionTools.IsSupported() != declared || c.FunctionResultImages.IsSupported() != declared || c.ToolSearch.IsSupported() != declared || c.WorkspaceOutputExport.IsSupported() || c.WorkspaceReadPreparation.IsSupported() { t.Errorf("%s: capabilities %+v do not describe the view path", info.Kind, c) diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index 415ad6079..d0d47654e 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -37,7 +37,7 @@ func TestMain(m *testing.M) { os.Exit(processshim.Relay()) } // The shim runs with the Harness's environment, so it comes first. - if filepath.Base(os.Args[0]) == "sh" { + if base := filepath.Base(os.Args[0]); base == "sh" || strings.HasPrefix(base, "oac-mcp-") { os.Exit(processshim.Run(processshim.SocketPath)) } if os.Getenv(harnessEnv) != "" { @@ -147,7 +147,10 @@ func leftEntries(t *testing.T, cfg Config) []string { // binds each request to the Session its state key names and records the // latest Executor the agent host opened for each Session. type daemon struct { - router *dispatch.Router + router *dispatch.Router + // mcp is the installed MCP that the Environment's preparation resolves + // into each request; the wire does not carry it. + mcp []proto.EnvironmentMCP mu sync.Mutex frames map[string]chan proto.Envelope // by envelope ID bindings map[string]Binding // by Session ID @@ -158,6 +161,11 @@ func newDaemon(t *testing.T, cfg Config, d deps) *daemon { t.Helper() dm := &daemon{frames: map[string]chan proto.Envelope{}, bindings: map[string]Binding{}, opened: map[string]*session{}} reg := registry(cfg.Harnesses, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + if dm.mcp != nil { + local := *req.LocalEnvironment + local.MCP = dm.mcp + req.LocalEnvironment = &local + } e, err := open(ctx, cfg, req, dm.bind, d) if s, ok := e.(*session); ok { dm.mu.Lock() diff --git a/apps/daemon/internal/agenthost/doc.go b/apps/daemon/internal/agenthost/doc.go index 2aaad44e7..20484ce98 100644 --- a/apps/daemon/internal/agenthost/doc.go +++ b/apps/daemon/internal/agenthost/doc.go @@ -35,17 +35,19 @@ // its bind function binds the request to. It admits the request before any // effect: the kind must declare an agent.View, the request must use only // what the view's agent.ViewCapabilities declare, and when the view declares -// shim names, which run on the sandbox PATH, the Session's Environment must -// set PATH. A Session without strict resume, with a restricted network, or -// with a stdio MCP server that needs a credential is rejected whatever the -// view declares. The registry's Info follows the declarations and marks what -// needs a local workspace unsupported. The factory then allocates the +// shim names or a stdio MCP server's command is a bare name, both of which +// run on the sandbox PATH, the Session's Environment must set PATH. A +// Session without strict resume, with a restricted network, or with a stdio +// MCP server that needs a credential is rejected whatever the view declares. +// The registry's Info follows the declarations and marks what needs a local +// workspace unsupported. The factory then allocates the // Executor's 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 prepares 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 gives the Session home to +// reach the network only through the Session's gateway and each stdio MCP +// server runs under its alias, agent.ViewAlias, and calls the view's +// Executor factory. Each ViewSession.Launch gives the Session home to // the Executor's uid and 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. @@ -55,13 +57,20 @@ // 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. +// with the launch's kill timeout as its grace. An alias runs its stdio MCP +// server's frozen command, arguments and working directory, a relative one +// in the installation's package root, with only the Session's Environment. +// +// A Session with environment none has no sandbox. Its views are empty-root +// views: no world, no shims, no process broker and no sandbox network, so +// the gateway's generic proxy refuses every request, and the Harness runs in +// the home's work directory. Such a Session never opens its Link attachment. // // Each view presents the closure directories read-only and executable, the // Session home read-write and noexec, the agent host's /etc/passwd, group, // hosts, resolv.conf and nsswitch.conf, the agent host's CA directory at its // host path, then the adapter's overlays and masks and the process shim with -// its relay. Everything else is the world. +// its relay. Everything else is the world, or nothing in an empty-root view. // // The Session directory, StateDir/sessions/, stays root-owned // and private. Its home holds the Harness's native history and persists diff --git a/apps/daemon/internal/agenthost/executor_linux.go b/apps/daemon/internal/agenthost/executor_linux.go index 205ab9eee..e6a156746 100644 --- a/apps/daemon/internal/agenthost/executor_linux.go +++ b/apps/daemon/internal/agenthost/executor_linux.go @@ -118,8 +118,14 @@ func open(ctx context.Context, cfg Config, req proto.PromptRequestPayload, bind if s.log == nil { s.log = slog.New(slog.DiscardHandler) } + // The attachment opens on first use. A Session with environment none, + // whose binding names no sandbox, never uses it. s.link = newLinkOwner(d.dial, b, sandboxwire.NewID(), s.fail) - if err := checkBinding(s.link.request(sandboxlink.ServiceFile, sandboxfs.Version, sandboxwire.ID{}), env); err != nil { + if req.DisableExecutionEnvironment { + if b.SessionID.IsZero() { + return nil, invalidSession("binding: no Session ID") + } + } else if err := checkBinding(s.link.request(sandboxlink.ServiceFile, sandboxfs.Version, sandboxwire.ID{}), env); err != nil { return nil, err } if s.plan, err = admit(cfg, roots, req, env, s.openNetwork); err != nil { diff --git a/apps/daemon/internal/agenthost/launch_linux.go b/apps/daemon/internal/agenthost/launch_linux.go index 57ea8f0fa..3fde5e108 100644 --- a/apps/daemon/internal/agenthost/launch_linux.go +++ b/apps/daemon/internal/agenthost/launch_linux.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "slices" "sync" @@ -44,6 +45,13 @@ type viewWorld interface { Err() error } +// noWorld is the world of an empty-root view, which has none to stop or lose. +type noWorld struct{} + +func (noWorld) Stop() error { return nil } +func (noWorld) Lost() <-chan struct{} { return nil } +func (noWorld) Err() error { return nil } + // closeLive closes the live view. It runs when the Session's context ends. func (s *session) closeLive() { s.mu.Lock() @@ -145,9 +153,9 @@ func (s *session) start(lv *liveView, opts clirunner.StartOptions) (*clirunner.P startCtx, cancel := context.WithCancel(s.ctx) defer cancel() defer context.AfterFunc(opts.Parent, cancel)() - view := s.plan.view + x := s.plan.executables var scope sandboxprocess.Scope - if len(view.Shims) > 0 || len(view.ShimPaths) > 0 { + if len(x.Names) > 0 || len(x.Paths) > 0 || len(x.Aliases) > 0 { var err error if scope, err = s.processScope(startCtx); err != nil { s.release(lv) @@ -166,8 +174,14 @@ func (s *session) start(lv *liveView, opts clirunner.StartOptions) (*clirunner.P s.release(lv) return nil, fmt.Errorf("%w: stdio: %w", ErrLaunch, err) } - world := worldfs.New(s.openFile) - spec := s.spec(world, opts, child) + // An empty-root view has no world. + var world viewWorld = noWorld{} + var serve sessionview.World + if !s.plan.request.DisableExecutionEnvironment { + w := worldfs.New(s.openFile) + world, serve = w, w.Serve + } + spec := s.spec(serve, opts, child) // The gateway serves from the view's network hook until the view has ended. var stopGateway func() spec.Network.Setup = func(netns *os.File) (err error) { @@ -361,11 +375,11 @@ func (h *ownedView) end(waitErr error) { h.s.release(h.lv) } -// spec builds the view: the closure and home directories, the agent -// host's /etc files and CA directory, the adapter's overlays and masks, and -// the shim. -func (s *session) spec(world *worldfs.World, opts clirunner.StartOptions, stdio [3]*os.File) sessionview.Spec { - view := s.plan.view +// spec builds the view over world: the closure and home directories, the +// agent host's /etc files and CA directory, the adapter's overlays and masks, +// and the shim under each name and path of the process broker's table. +func (s *session) spec(world sessionview.World, opts clirunner.StartOptions, stdio [3]*os.File) sessionview.Spec { + view, x := s.plan.view, s.plan.executables var private []sessionview.PrivateDir for _, m := range view.Closure { private = append(private, sessionview.PrivateDir{Name: m.Name, HostDir: m.HostDir, Exec: true}) @@ -387,10 +401,11 @@ func (s *session) spec(world *worldfs.World, opts clirunner.StartOptions, stdio overlays = append(overlays, sessionview.Overlay{Path: m.Path, Source: source}) } return sessionview.Spec{ - World: world.Serve, + World: world, Private: private, Overlays: overlays, - Shim: sessionview.Shim{Binary: s.cfg.Shim, Names: view.Shims, Paths: view.ShimPaths}, + Shim: sessionview.Shim{Binary: s.cfg.Shim, Names: slices.Concat(slices.Sorted(maps.Keys(x.Names)), slices.Sorted(maps.Keys(x.Aliases))), + Paths: slices.Sorted(maps.Keys(x.Paths))}, Process: sessionview.Process{Path: opts.Binary, Args: append([]string{opts.Binary}, opts.Args...), Env: opts.Env, Dir: opts.Dir, UID: s.uid, GID: s.uid, Stdin: stdio[0], Stdout: stdio[1], Stderr: stdio[2], Grace: opts.KillTimeout}, diff --git a/apps/daemon/internal/agenthost/sessiondir_linux.go b/apps/daemon/internal/agenthost/sessiondir_linux.go index 4a8794b56..5e104ec06 100644 --- a/apps/daemon/internal/agenthost/sessiondir_linux.go +++ b/apps/daemon/internal/agenthost/sessiondir_linux.go @@ -85,11 +85,12 @@ func (d sessionDir) entry(name ...string) string { } // openSessionDir prepares the Session directory for an Executor running as -// uid: it creates the directory and its home unless an earlier Executor left -// them, and the transient entries. +// uid: it creates the directory and its home, with the work directory that +// an empty-root view runs in, unless an earlier Executor left them, and the +// transient entries. func openSessionDir(stateDir string, id sandboxwire.ID, uid uint32) (sessionDir, error) { d := sessionDir(filepath.Join(sessionsDir(stateDir), id.String())) - if err := os.MkdirAll(d.entry(homeEntry), 0o700); err != nil { + if err := os.MkdirAll(d.entry(homeEntry, agent.ViewWorkName), 0o700); err != nil { return "", fmt.Errorf("%w: session directory: %w", ErrInvalidConfig, err) } if err := d.populate(uid); err != nil { diff --git a/apps/daemon/internal/agenthost/view_linux_test.go b/apps/daemon/internal/agenthost/view_linux_test.go index 6b8b8c2d6..d08db8c6b 100644 --- a/apps/daemon/internal/agenthost/view_linux_test.go +++ b/apps/daemon/internal/agenthost/view_linux_test.go @@ -3,6 +3,7 @@ package agenthost import ( + "bufio" "bytes" "context" "encoding/json" @@ -12,15 +13,18 @@ import ( "io" "io/fs" "log/slog" + "maps" "net" "net/http" "net/http/httptest" + "net/url" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" + "sync/atomic" "syscall" "testing" "time" @@ -35,6 +39,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview/sessionviewtest" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" @@ -61,6 +66,8 @@ const ( harnessEnv = "OAC_AGENTHOST_HARNESS" modelEnv = "OAC_AGENTHOST_MODEL" caEnv = "OAC_AGENTHOST_CA" + proxyEnv = "OAC_AGENTHOST_PROXY" + aliasEnv = "OAC_AGENTHOST_ALIAS" harnessPath = "/.oac/harness/harness" upstreamKey = "sk-agenthost-upstream" wait = 20 * time.Second @@ -93,16 +100,26 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Fatal(err) } copyExecutable(t, filepath.Join(closure, "harness")) + caps := declared(proto.CapabilityUnsupported) + caps.EnvironmentNone, caps.StdioMCP = proto.CapabilitySupported, proto.CapabilitySupported 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}}, LocalExec: []string{harnessPath}, ShimPaths: []string{"/bin/sh"}, - Proxy: agent.ViewProxyNone, - Capabilities: declared(proto.CapabilityUnsupported), + ForwardEnv: []string{"KEEP"}, + Proxy: agent.ViewProxyEnv, + Capabilities: caps, Executor: func(_ context.Context, req proto.PromptRequestPayload, s agent.ViewSession) (agent.Executor, error) { - return &testExecutor{session: s, dir: req.LocalEnvironment.WorkspaceRoot, - env: []string{harnessEnv + "=1", modelEnv + "=" + req.ModelProvider.BaseURL, caEnv + "=" + cfg.CADir}}, nil + e := &testExecutor{session: s, dir: workDir, + env: []string{harnessEnv + "=1", modelEnv + "=" + req.ModelProvider.BaseURL, caEnv + "=" + cfg.CADir, proxyEnv + "=" + s.Proxy}} + if req.LocalEnvironment != nil { + e.dir = req.LocalEnvironment.WorkspaceRoot + } + for _, b := range s.MCP { + e.env = append(e.env, aliasEnv+"="+b.Stdio.Server.Command) + } + return e, nil }, }) sb.auth.AddRuntime(cfg.Credential, cfg.RuntimeID) @@ -179,6 +196,58 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { checkReleased(t, cfg) }) + t.Run("environment none runs in an empty root", func(t *testing.T) { + var dials atomic.Int32 + d, b := newDaemon(t, cfg, deps{dial: countingDial(&dials), tasks: taskUIDs}), Binding{SessionID: sandboxwire.NewID()} + none := request("test", "", upstream.URL, upstreamKey) + none.LocalEnvironment, none.DisableExecutionEnvironment = nil, true + r := d.turn(t, b, none, "none") + for _, name := range []string{"empty root", "work directory", "model through the gateway", "no direct route", "no sandbox network"} { + if msg, ok := r.Checks[name]; !ok || msg != "" { + t.Errorf("%s: %q", name, msg) + } + } + if r.Exit != "" { + t.Errorf("Harness: %s; stderr %s", r.Exit, r.Stderr) + } + select { + case <-keyed: + default: + } + if err := d.shutdown(); err != nil { + t.Fatalf("Shutdown = %v", err) + } + if dials.Load() != 0 { + t.Errorf("the Session dialled the relay %d times", dials.Load()) + } + if err := logged.take(); err != nil { + t.Errorf("logged %v", err) + } + checkReleased(t, cfg) + }) + + t.Run("a stdio MCP server runs its frozen command under its alias", func(t *testing.T) { + d, b := newRun(t), sb.bind(cfg.RuntimeID, time.Minute) + pkg := filepath.Join(workspace, "pkg") + if err := os.Mkdir(pkg, 0o755); err != nil { + t.Fatal(err) + } + d.mcp = []proto.EnvironmentMCP{{InstallationRoot: workspace, PackageRoot: "pkg", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "/bin/sh", + Args: []string{"-c", `printf '%s %s %s %s\n' "$0" "$#" "$(pwd -P)" "${KEEP-unset}"; exec /bin/sleep 1000`, "frozen"}}}} + // The Harness exits while the alias's process runs on. + if r := d.turn(t, b, req, "alias"); r.Stdout != "frozen 0 "+pkg+" unset\n" || r.Exit != "" { + t.Errorf("the alias printed %q; exit %q, stderr %s", r.Stdout, r.Exit, r.Stderr) + } + until(t, "the alias's process to end with the view", func() bool { return len(processesWith([]string{"/bin/sleep", "1000"})) == 0 }) + if err := d.shutdown(); err != nil { + t.Fatalf("Shutdown = %v", err) + } + if err := logged.take(); err != nil { + t.Errorf("logged %v", err) + } + checkReleased(t, cfg) + }) + t.Run("a command runs in the sandbox through the shim", func(t *testing.T) { d, b := newRun(t), sb.bind(cfg.RuntimeID, time.Minute) if r := d.turn(t, b, req, "shim"); r.Stdout != "42\n" || r.Code != 3 { @@ -672,6 +741,38 @@ func (t *testTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, e var harnessChecks = []string{"world rename", "model through the gateway", "no direct route", "world is noexec", "masks", "home", "passwd", "CA directory"} +// workDir is where the Harness of an empty-root view runs. +const workDir = agent.ViewPrivateRoot + "/" + agent.ViewHomeName + "/" + agent.ViewWorkName + +// gatewayChecks pass only when the Harness reaches the model through the +// Session's gateway and nothing else. +var gatewayChecks = map[string]func() error{ + "model through the gateway": func() error { + req, _ := http.NewRequest("POST", os.Getenv(modelEnv)+"/v1/messages", strings.NewReader("{}")) + req.Header.Set("X-Api-Key", modelprovider.Placeholder) + resp, err := (&http.Client{Timeout: wait}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if body, _ := io.ReadAll(resp.Body); resp.StatusCode != http.StatusOK || string(body) != "answer" { + return fmt.Errorf("answered %d %q", resp.StatusCode, body) + } + return nil + }, + "no direct route": func() error { + c, err := net.DialTimeout("tcp", "192.0.2.1:80", 2*time.Second) + if err == nil { + c.Close() + return errors.New("connected outside the gateway") + } + if !errors.Is(err, syscall.ENETUNREACH) { + return fmt.Errorf("dial: %v, want ENETUNREACH", err) + } + return nil + }, +} + // runHarness runs inside the view, in the workspace, and prints a JSON map // from each check to its failure, empty when it passed. func runHarness(args []string) int { @@ -688,30 +789,6 @@ func runHarness(args []string) int { } return os.Rename("staged", "renamed") }, - "model through the gateway": func() error { - req, _ := http.NewRequest("POST", os.Getenv(modelEnv)+"/v1/messages", strings.NewReader("{}")) - req.Header.Set("X-Api-Key", modelprovider.Placeholder) - resp, err := (&http.Client{Timeout: wait}).Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if body, _ := io.ReadAll(resp.Body); resp.StatusCode != http.StatusOK || string(body) != "answer" { - return fmt.Errorf("answered %d %q", resp.StatusCode, body) - } - return nil - }, - "no direct route": func() error { - c, err := net.DialTimeout("tcp", "192.0.2.1:80", 2*time.Second) - if err == nil { - c.Close() - return errors.New("connected outside the gateway") - } - if !errors.Is(err, syscall.ENETUNREACH) { - return fmt.Errorf("dial: %v, want ENETUNREACH", err) - } - return nil - }, "world is noexec": func() error { if err := exec.Command("/bin/true").Run(); !errors.Is(err, fs.ErrPermission) { return fmt.Errorf("exec of a world binary: %v, want a permission error", err) @@ -747,6 +824,62 @@ func runHarness(args []string) int { return nil }, } + maps.Copy(checks, gatewayChecks) + case "none": + checks = map[string]func() error{ + "empty root": func() error { + var st unix.Statfs_t + if err := unix.Statfs("/", &st); err != nil || st.Type != unix.TMPFS_MAGIC || st.Flags&unix.ST_RDONLY == 0 { + return fmt.Errorf("root: type %#x flags %#x, %v", st.Type, st.Flags, err) + } + for _, p := range []string{"/bin", agent.ViewPrivateRoot + "/" + agent.ViewRunName} { + if _, err := os.Lstat(p); !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("%s: %v, want none", p, err) + } + } + if shims, err := os.ReadDir(agent.ViewPrivateRoot + "/" + agent.ViewShimName); err != nil || len(shims) != 0 { + return fmt.Errorf("shims %v, %v", shims, err) + } + return nil + }, + "work directory": func() error { + if wd, err := os.Getwd(); err != nil || wd != workDir { + return fmt.Errorf("cwd %q, %v", wd, err) + } + return os.WriteFile("probe", []byte("x"), 0o600) + }, + "no sandbox network": func() error { + proxy, err := url.Parse(os.Getenv(proxyEnv)) + if err != nil { + return err + } + resp, err := (&http.Client{Timeout: wait, Transport: &http.Transport{Proxy: http.ProxyURL(proxy)}}).Get("http://sandbox.test/") + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + return fmt.Errorf("the proxy answered %d, want 403", resp.StatusCode) + } + return nil + }, + } + maps.Copy(checks, gatewayChecks) + case "alias": + // The alias ignores the Harness's arguments, directory and environment. + cmd := exec.Command(os.Getenv(aliasEnv), "ignored") + cmd.Dir, cmd.Env = "/", append(os.Environ(), "KEEP=harness") + out, err := cmd.StdoutPipe() + if err == nil { + err = cmd.Start() + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 125 + } + line, _ := bufio.NewReader(out).ReadString('\n') + fmt.Print(line) + return 0 case "touch": checks["touch"] = func() error { return os.WriteFile("touched", []byte("renewed"), 0o644) } case "home": diff --git a/apps/daemon/internal/processbroker/broker_linux_test.go b/apps/daemon/internal/processbroker/broker_linux_test.go index 2ee9e164b..a0143d4c1 100644 --- a/apps/daemon/internal/processbroker/broker_linux_test.go +++ b/apps/daemon/internal/processbroker/broker_linux_test.go @@ -9,10 +9,12 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "os" "os/exec" "path/filepath" + "reflect" "runtime" "strconv" "strings" @@ -261,6 +263,37 @@ func TestEnvironmentIsDeclaredOnly(t *testing.T) { } } +// An alias runs its frozen command whatever the shim's argv, working +// directory and environment say. +func TestAliasRunsItsFrozenCommand(t *testing.T) { + b := &Broker{log: slog.Default(), cfg: Config{ + Executables: Executables{Aliases: map[string]Command{"oac-mcp-0": {Executable: "server", Args: []string{"--stdio"}, Dir: "/pkg"}}}, + Environment: Environment{Pass: []string{"KEEP"}, Sandbox: map[string]string{"PATH": "/bin"}}, + Scope: sp.ScopePOSIXSession, + }} + inv := b.newInvocation(processshim.Open{ID: 1, Request: processshim.Request{ + ExecPath: []byte("/.oac/bin/oac-mcp-0"), + Argv: [][]byte{[]byte("other"), []byte("--extra")}, + Env: [][]byte{[]byte("KEEP=1")}, + Cwd: []byte("/elsewhere"), + }}) + if r := inv.prepare(); r != nil { + t.Fatalf("prepare refused: %d %s", r.Code, r.Message) + } + got := inv.spec + want := sp.ProcessSpec{ + Executable: []byte("server"), + Argv: [][]byte{[]byte("server"), []byte("--stdio")}, + Env: []sp.EnvVar{{Name: []byte("PATH"), Value: []byte("/bin")}}, + Cwd: []byte("/pkg"), + IOMode: sp.IOPipes, + Scope: sp.ScopePOSIXSession, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("spec %+v, want %+v", got, want) + } +} + func TestLinkLossKeepsOutputOrdered(t *testing.T) { var dials atomic.Int32 f := newFixture(t, dialService(t, func(n int32, c net.Conn) io.ReadWriteCloser { diff --git a/apps/daemon/internal/processbroker/config.go b/apps/daemon/internal/processbroker/config.go index 580cd032d..495243a20 100644 --- a/apps/daemon/internal/processbroker/config.go +++ b/apps/daemon/internal/processbroker/config.go @@ -49,6 +49,19 @@ type Executables struct { // Paths maps an absolute view path the shim is bound over, such as // /bin/bash, to its remote executable. Paths map[string]string + // Aliases maps a name in the view's shim directory to the command it + // runs. Nothing of the shim's argv, working directory or environment + // reaches that command. + Aliases map[string]Command +} + +// Command is an alias's frozen command. Its executable may also be a path +// relative to Dir, which is an absolute sandbox path. +type Command struct { + Executable string + // Args follow argv[0], which is Executable. + Args []string + Dir string } // Environment is the remote environment policy. A name set in more than one @@ -95,13 +108,25 @@ func (c *Config) validate() error { _, path1 := c.Environment.Sandbox["PATH"] _, path2 := c.Environment.Tool["PATH"] for name, remote := range c.Executables.Names { - if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\x00") { + if !validName(name) { return invalid("executable name %q", name) } if err := checkRemote(remote, path1 || path2); err != nil { return invalid("executable %q: %v", name, err) } } + for name, cmd := range c.Executables.Aliases { + switch { + case !validName(name): + return invalid("alias name %q", name) + case !path.IsAbs(cmd.Dir): + return invalid("alias %q: working directory %q is not absolute", name, cmd.Dir) + case cmd.Executable == "": + return invalid("alias %q: empty executable", name) + case !strings.Contains(cmd.Executable, "/") && !(path1 || path2): + return invalid("alias %q: remote name %q needs PATH in the sandbox or tool environment", name, cmd.Executable) + } + } for local, remote := range c.Executables.Paths { if !path.IsAbs(local) || path.Clean(local) != local || underPrivate(local) || strings.Contains(local, "\x00") { return invalid("executable path %q", local) @@ -125,6 +150,11 @@ func checkRemote(remote string, havePATH bool) error { return nil } +// validName reports whether name can be a file in the view's shim directory. +func validName(name string) bool { + return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, "/\x00") +} + func validEnvName(name string) bool { return name != "" && !strings.ContainsAny(name, "=\x00") } @@ -133,21 +163,25 @@ func underPrivate(p string) bool { return p == agent.ViewPrivateRoot || strings.HasPrefix(p, agent.ViewPrivateRoot+"/") } -// resolve returns the remote executable for the shim's exec path. A relative -// path resolves against cwd; resolution is lexical, because the view's -// symlinks are not the broker's to follow. -func (x Executables) resolve(execPath, cwd string) (string, bool) { +// resolve returns the remote executable for the shim's exec path, and the +// frozen command when the path is an alias. A relative path resolves against +// cwd; resolution is lexical, because the view's symlinks are not the +// broker's to follow. +func (x Executables) resolve(execPath, cwd string) (string, *Command, bool) { p := execPath if !path.IsAbs(p) { p = path.Join(cwd, p) } p = path.Clean(p) if name, ok := strings.CutPrefix(p, agent.ViewPrivateRoot+"/"+agent.ViewShimName+"/"); ok { + if cmd, ok := x.Aliases[name]; ok { + return cmd.Executable, &cmd, true + } remote, ok := x.Names[name] - return remote, ok + return remote, nil, ok } remote, ok := x.Paths[p] - return remote, ok + return remote, nil, ok } // privateMarker is the view prefix no value may carry into the sandbox. diff --git a/apps/daemon/internal/processbroker/invocation_linux.go b/apps/daemon/internal/processbroker/invocation_linux.go index 667004205..992a142cd 100644 --- a/apps/daemon/internal/processbroker/invocation_linux.go +++ b/apps/daemon/internal/processbroker/invocation_linux.go @@ -137,23 +137,29 @@ func message(msg string) []byte { // here, before the relay acknowledges the shim. func (inv *invocation) prepare() *processshim.Result { b, req := inv.b, inv.open.Request - remote, ok := b.cfg.Executables.resolve(string(req.ExecPath), string(req.Cwd)) + remote, alias, ok := b.cfg.Executables.resolve(string(req.ExecPath), string(req.Cwd)) if !ok { return refuse(processshim.ExitNotFound, "%s: not a declared sandbox executable", req.ExecPath) } - if underPrivate(path.Clean(string(req.Cwd))) { + argv, cwd, environ := req.Argv, req.Cwd, req.Env + if alias != nil { + argv, cwd, environ = [][]byte{[]byte(alias.Executable)}, []byte(alias.Dir), nil + for _, a := range alias.Args { + argv = append(argv, []byte(a)) + } + } else if underPrivate(path.Clean(string(req.Cwd))) { return refuse(processshim.ExitCannotRun, "%s: the working directory is private to the Session", req.Cwd) } inv.log = inv.log.With("executable", remote) - env, dropped := b.cfg.Environment.compose(req.Env) + env, dropped := b.cfg.Environment.compose(environ) if len(dropped) > 0 { inv.log.Info("environment entries naming the private directory dropped", "names", dropped) } spec := sp.ProcessSpec{ Executable: []byte(remote), - Argv: req.Argv, + Argv: argv, Env: env, - Cwd: req.Cwd, + Cwd: cwd, Umask: req.Umask, IOMode: sp.IOPipes, Scope: b.cfg.Scope, diff --git a/apps/daemon/internal/sessionview/build_linux.go b/apps/daemon/internal/sessionview/build_linux.go index dbab1cb37..30044de1b 100644 --- a/apps/daemon/internal/sessionview/build_linux.go +++ b/apps/daemon/internal/sessionview/build_linux.go @@ -110,6 +110,55 @@ func (b *builder) build(spec *launchSpec) error { return b.dev() } +// emptyRoot mounts at staging a read-only, noexec tmpfs that holds only the mountpoints and their parent directories. +func emptyRoot(staging string, mps []Mountpoint) error { + mnt, err := newFS("tmpfs", [][2]string{{"mode", "0755"}, {"size", "64k"}}, attrNoSuid|attrNoDev|attrNoExec) + if err != nil { + return err + } + defer unix.Close(mnt) + mkdir := func(rel string) error { + err := unix.Mkdirat(mnt, rel, 0o755) + if err == unix.EEXIST { + return nil + } + if err == nil { + // The daemon's umask must not narrow the path the view's identity + // searches. + err = unix.Fchmodat(mnt, rel, 0o755, 0) + } + if err != nil { + return &Error{Kind: ErrLauncher, Op: "mkdir", Path: "/" + rel, Err: err} + } + return nil + } + for _, m := range mps { + rel := strings.TrimPrefix(m.Path, "/") + for i, c := range rel { + if c == '/' { + if err := mkdir(rel[:i]); err != nil { + return err + } + } + } + if m.Dir { + err = mkdir(rel) + } else { + err = createFile(mnt, rel, m.Path) + } + if err != nil { + return err + } + } + if err := readOnly(mnt, "/"); err != nil { + return err + } + if err := unix.MoveMount(mnt, "", unix.AT_FDCWD, staging, unix.MOVE_MOUNT_F_EMPTY_PATH); err != nil { + return mountError("move_mount", "/", err) + } + return nil +} + // 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 { diff --git a/apps/daemon/internal/sessionview/control_linux.go b/apps/daemon/internal/sessionview/control_linux.go index 34dc420a8..0d8e7f238 100644 --- a/apps/daemon/internal/sessionview/control_linux.go +++ b/apps/daemon/internal/sessionview/control_linux.go @@ -37,6 +37,8 @@ type launchSpec struct { UID uint32 GID uint32 Grace time.Duration + // EmptyRoot holds the mountpoints of an empty root, which the launcher creates; it is nil for a world. + EmptyRoot []Mountpoint } // command is what a process of the view runs. @@ -50,7 +52,7 @@ type command struct { type msgKind uint8 const ( - msgMounted msgKind = iota + 1 // launcher: the world is mounted; carries the /dev/fuse and netns fds + msgMounted msgKind = iota + 1 // launcher: the root is mounted; carries the world's /dev/fuse fd, if any, then the netns fd msgProceed // daemon: the world serves and the network is set up; carries the mount targets msgStarted // launcher: the process runs msgFailed // launcher: construction failed diff --git a/apps/daemon/internal/sessionview/doc.go b/apps/daemon/internal/sessionview/doc.go index d198a111c..17fedc6e5 100644 --- a/apps/daemon/internal/sessionview/doc.go +++ b/apps/daemon/internal/sessionview/doc.go @@ -1,6 +1,6 @@ // Package sessionview runs a process, and others spawned beside it, 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 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. +// 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. A [Spec] without a world gets an empty root instead: a read-only, noexec tmpfs that holds only the mountpoints, each at its own path. 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. // diff --git a/apps/daemon/internal/sessionview/launcher_linux.go b/apps/daemon/internal/sessionview/launcher_linux.go index 2d2d26a60..99afa7db2 100644 --- a/apps/daemon/internal/sessionview/launcher_linux.go +++ b/apps/daemon/internal/sessionview/launcher_linux.go @@ -78,7 +78,7 @@ func (l *launcher) run() error { if err := loopbackUp(); err != nil { return &Error{Kind: ErrNetwork, Op: "loopback", Err: err} } - if err := l.mountWorld(spec.Staging); err != nil { + if err := l.mountRoot(spec); err != nil { return err } <-l.proceed @@ -371,22 +371,35 @@ func exitOf(ws unix.WaitStatus) (Exit, int) { return Exit{Code: ws.ExitStatus()}, ws.ExitStatus() } -func (l *launcher) mountWorld(staging string) error { - dev, err := unix.Open("/dev/fuse", unix.O_RDWR|unix.O_CLOEXEC, 0) - if err != nil { - return &Error{Kind: ErrNoFUSE, Op: "open", Path: "/dev/fuse", Err: err} - } - defer unix.Close(dev) - opts := strings.Join(append([]string{fmt.Sprintf("fd=%d", dev)}, fuseOptions...), ",") - if err := unix.Mount("oac-world", staging, "fuse", fuseMountFlags, opts); err != nil { - return mountError("mount", staging, err) +// mountRoot mounts the view's root at the staging directory, the world over a new /dev/fuse connection or an empty root, and reports it with that connection and the network namespace. +func (l *launcher) mountRoot(spec *launchSpec) error { + var fds []int + defer func() { + for _, fd := range fds { + unix.Close(fd) + } + }() + if spec.EmptyRoot != nil { + if err := emptyRoot(spec.Staging, spec.EmptyRoot); err != nil { + return err + } + } else { + dev, err := unix.Open("/dev/fuse", unix.O_RDWR|unix.O_CLOEXEC, 0) + if err != nil { + return &Error{Kind: ErrNoFUSE, Op: "open", Path: "/dev/fuse", Err: err} + } + fds = append(fds, dev) + opts := strings.Join(append([]string{fmt.Sprintf("fd=%d", dev)}, fuseOptions...), ",") + if err := unix.Mount("oac-world", spec.Staging, "fuse", fuseMountFlags, opts); err != nil { + return mountError("mount", spec.Staging, err) + } } netns, err := unix.Open("/proc/self/ns/net", unix.O_RDONLY|unix.O_CLOEXEC, 0) if err != nil { return &Error{Kind: ErrNetwork, Op: "open", Path: "/proc/self/ns/net", Err: err} } - defer unix.Close(netns) - if err := l.ctl.send(context.Background(), message{Kind: msgMounted}, dev, netns); err != nil { + fds = append(fds, netns) + if err := l.ctl.send(context.Background(), message{Kind: msgMounted}, fds...); err != nil { return &Error{Kind: ErrLauncher, Op: "report mount", Err: err} } return nil diff --git a/apps/daemon/internal/sessionview/spec.go b/apps/daemon/internal/sessionview/spec.go index 4306145b4..1a5e5b18e 100644 --- a/apps/daemon/internal/sessionview/spec.go +++ b/apps/daemon/internal/sessionview/spec.go @@ -15,6 +15,7 @@ import ( // Spec declares one view and the process it runs. type Spec struct { + // World serves the view's root. A nil World makes the root empty: a read-only, noexec tmpfs that holds only the mountpoints. World World Private []PrivateDir Overlays []Overlay @@ -121,9 +122,6 @@ type Exit struct { } func (s *Spec) validate() error { - if s.World == nil { - return invalid("world is required") - } if !filepath.IsAbs(s.CgroupParent) || filepath.Clean(s.CgroupParent) != s.CgroupParent { return invalid("cgroup parent %q must be absolute and clean", s.CgroupParent) } diff --git a/apps/daemon/internal/sessionview/view_linux.go b/apps/daemon/internal/sessionview/view_linux.go index b8cedb7c0..852463b62 100644 --- a/apps/daemon/internal/sessionview/view_linux.go +++ b/apps/daemon/internal/sessionview/view_linux.go @@ -185,6 +185,9 @@ func (v *View) launch(spec *Spec) error { Command: command{Path: spec.Process.Path, Args: spec.Process.Args, Env: spec.Process.Env, Dir: spec.Process.Dir}, UID: spec.Process.UID, GID: spec.Process.GID, Grace: spec.Process.Grace, } + if spec.World == nil { + ls.EmptyRoot = spec.mountpoints() + } // A launcher that dies early breaks the pipe; the handshake reports that. go func() { _ = gob.NewEncoder(specW).Encode(ls) @@ -233,20 +236,31 @@ func (v *View) handshake(ctx context.Context, spec *Spec) error { if m.Kind == msgFailed { return m.Fail.err() } - if m.Kind != msgMounted || len(files) != 2 { + want := 2 + if spec.World == nil { + want = 1 + } + if m.Kind != msgMounted || len(files) != want { closeFiles(files) return &Error{Kind: ErrLauncher, Op: "mount", Err: fmt.Errorf("unexpected message %d with %d files", m.Kind, len(files))} } - v.dev = files[0] - netns := files[1] + netns := files[want-1] defer netns.Close() 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} + if spec.World == nil { + // The empty root presents each mountpoint at its own path. + for _, m := range mps { + v.present.Targets = append(v.present.Targets, m.Path) + } + } else { + v.dev = files[0] + 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, v.present = world, present } - v.world, v.present = world, present - targets, err := targetsOf(mps, present) + targets, err := targetsOf(mps, v.present) if err != nil { return &Error{Kind: ErrWorld, Op: "present", Err: err} } diff --git a/apps/daemon/internal/sessionview/view_linux_test.go b/apps/daemon/internal/sessionview/view_linux_test.go index e66855f05..c7ac45002 100644 --- a/apps/daemon/internal/sessionview/view_linux_test.go +++ b/apps/daemon/internal/sessionview/view_linux_test.go @@ -773,6 +773,31 @@ func TestSeccompProgram(t *testing.T) { } } +func TestEmptyRootView(t *testing.T) { + requireView(t) + f := newFixture(t) + spec := f.spec(nil, "empty") + spec.World, spec.Shim, spec.Process.Dir = nil, Shim{}, "/.oac/home" + // The launcher inherits the umask; it must not narrow the empty root. + umask := unix.Umask(0o077) + v, err := Start(context.Background(), spec) + unix.Umask(umask) + if err != nil { + t.Fatalf("Start: %v", err) + } + defer v.Close() + if v.Relay() != nil { + t.Error("an empty root without a shim has a relay") + } + out, err := io.ReadAll(v.Stdout()) + if err != nil { + t.Fatal(err) + } + if exit, err := v.Wait(); err != nil || exit != (Exit{}) { + t.Fatalf("Wait = %+v, %v; output %s", exit, err, out) + } +} + func TestStartRejectsInvalidSpec(t *testing.T) { for name, spec := range map[string]Spec{ "relative overlay": {Overlays: []Overlay{{Path: "etc/resolv.conf", Source: "/etc/hosts"}}}, @@ -1252,6 +1277,34 @@ func runHelper(mode string) int { return 0 case "noop": return 0 + case "empty": + var errs []error + var st unix.Statfs_t + if err := unix.Statfs("/", &st); err != nil || st.Type != unix.TMPFS_MAGIC || st.Flags&(unix.ST_RDONLY|unix.ST_NOEXEC) != unix.ST_RDONLY|unix.ST_NOEXEC { + errs = append(errs, fmt.Errorf("root: type %#x flags %#x, %v", st.Type, st.Flags, err)) + } + for dir, want := range map[string][]string{"/": {".oac", "dev", "etc", "proc"}, "/.oac": {"bin", "harness", "home"}, "/.oac/bin": nil, "/etc": {"oac-overlay"}} { + entries, err := os.ReadDir(dir) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + if err != nil || !slices.Equal(names, want) { + errs = append(errs, fmt.Errorf("%s holds %v, %v", dir, names, err)) + } + } + if err := os.WriteFile("/x", nil, 0o644); !errors.Is(err, syscall.EROFS) { + errs = append(errs, fmt.Errorf("write /x: %v, want EROFS", err)) + } + if wd, err := os.Getwd(); wd != "/.oac/home" || err != nil { + errs = append(errs, fmt.Errorf("cwd %q, %v", wd, err)) + } + errs = append(errs, fileHas("/etc/oac-overlay/greeting", "from the overlay")) + if err := errors.Join(errs...); err != nil { + fmt.Println(err) + return 1 + } + return 0 case "identity": sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGTERM) From 29929bdcd6606788667c264981688d9d0a929d00 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 17:27:02 +0800 Subject: [PATCH 4/4] Qualify function tools in the Codex and Claude views (#478) Codex's view declares function tools and function-result images, and keeps tool search unsupported. Claude's view derives function tools, result images and tool search from the probed bridge features with the predicates its workspace Runtime uses, since the view runs the bridge in workspace mode. The agent-host qualification answers each function call through dispatch with a text, image and text result. A view that declares function tools runs a second Turn in a new Executor that resumes the native session and calls the function; a view that declares tool search runs a Turn in another Session that finds the deferred function. Codex keeps its dynamic tools when a new app-server resumes the thread. --- apps/daemon/internal/agent/claudesdk/view.go | 14 +- .../internal/agent/claudesdk/view_test.go | 2 +- apps/daemon/internal/agent/codex/view.go | 4 +- .../agenthostqualify/qualify_linux_test.go | 176 +++++++++++++++--- contracts/agents-api/harness-onboarding.md | 2 +- contracts/agents-api/zh/harness-onboarding.md | 4 +- 6 files changed, 161 insertions(+), 41 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index 3716771ad..d9ca20e6e 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -58,14 +58,18 @@ func newView(probe Config, info RuntimeInfo) (*agent.View, error) { if err != nil { return nil, err } - view := declareView(probe, node, root, filepath.ToSlash(bridge), filepath.ToSlash(info.NativePath), loader) + view := declareView(probe, info, node, root, filepath.ToSlash(bridge), loader) if err := view.Validate(); err != nil { return nil, err } return view, nil } -func declareView(probe Config, node, root, bridge, native string, loader viewloader.Fragment) *agent.View { +// declareView declares the view of the install that info describes. The view +// runs the bridge in workspace mode, so its functions follow the probe as a +// workspace Runtime's do. +func declareView(probe Config, info RuntimeInfo, node, root, bridge string, loader viewloader.Fragment) *agent.View { + native := filepath.ToSlash(info.NativePath) nodeMount := agent.ViewMount{Name: "node", HostDir: filepath.Dir(node)} bundle := agent.ViewMount{Name: "claude-sdk", HostDir: root} layout := viewLayout{node: nodeMount.Path() + "/" + filepath.Base(node), bridge: bundle.Path() + "/" + bridge, libraries: loader.LibraryPath} @@ -81,9 +85,9 @@ func declareView(probe Config, node, root, bridge, native string, loader viewloa Capabilities: agent.ViewCapabilities{ EnvironmentNone: proto.CapabilityUnsupported, Skills: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()), + FunctionResultImages: proto.CapabilityFromBool(info.SupportsFunctionResultImages()), + ToolSearch: proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()), StdioMCP: proto.CapabilityUnsupported, }, } diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index 1d2a567b2..573aeb21c 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -150,7 +150,7 @@ func resolveTestView(t *testing.T) agent.View { } lib := agent.ViewMount{Name: viewloader.MountName, HostDir: filepath.Join(root, "lib")} loader := viewloader.Fragment{Closure: []agent.ViewMount{lib}, Overlays: []agent.ViewOverlay{{Path: "/lib64/ld-linux-x86-64.so.2", Source: filepath.Join(root, "lib", "ld.so"), Exec: true}}, LibraryPath: lib.Path()} - declared := declareView(probe, probe.Node, filepath.Join(root, "bundle"), "dist/main.js", "native/claude", loader) + declared := declareView(probe, RuntimeInfo{NativePath: "native/claude"}, probe.Node, filepath.Join(root, "bundle"), "dist/main.js", loader) registry := agent.NewRegistry() registry.Register(Declaration, agent.Runtime{Info: Declaration.Info, Session: NewFactory(probe), View: declared}) view, err := registry.ResolveView(Declaration.Info.Kind) diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go index 144b16ed6..3c3872b8a 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -89,8 +89,8 @@ func newView(binary string, codeModeHost bool) agent.View { Capabilities: agent.ViewCapabilities{ EnvironmentNone: proto.CapabilityUnsupported, Skills: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilitySupported, + FunctionResultImages: proto.CapabilitySupported, ToolSearch: proto.CapabilityUnsupported, StdioMCP: proto.CapabilityUnsupported, }, diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index a02b8ef25..5478627f1 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -6,14 +6,18 @@ package agenthostqualify import ( + "bytes" "context" "crypto/rand" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" + "image" + "image/png" "io" "log/slog" "math/big" @@ -107,15 +111,19 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { t.Fatalf("%s declares no agent-host view; discovery reported why above", kind) } reg.Register(declaration, *runtime) - qualify(t, h, cfg, sb, kind, sessionModel(t, raw, key)) + qualify(t, h, cfg, sb, kind, runtime.View.Capabilities, sessionModel(t, raw, key)) }) } } -// qualify runs one Turn that writes a file, runs a failing command and -// reports what it printed and its exit status, then checks all three. Only -// the sandbox's tool environment holds the value and the status. -func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, model proto.PromptRequestPayload) { +// qualify runs the kind's Turns through dispatch. The first writes a file, +// runs a failing command and reports what it printed and its exit status, +// then the test checks all three; only the sandbox's tool environment holds +// the value and the status. A view that declares function tools runs a second +// Turn in a new Executor, which resumes the Session's native history, and +// calls a function there. A view that declares tool search runs a Turn in +// another Session that finds the function, deferred, with tool search. +func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, caps agent.ViewCapabilities, model proto.PromptRequestPayload) { name := "qualify-" + kind + ".txt" value, content := strings.ToLower(rand.Text()), "qualified "+strings.ToLower(rand.Text()[:12]) code, _ := rand.Int(rand.Reader, big.NewInt(90)) @@ -126,43 +134,136 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, " It prints a value and exits with a non-zero status; that is expected.\n" + "3. Answer with exactly one line: VALUE= EXIT=" - // dispatch drives the Session's Turn through the agent host's Executor. - b := sb.binding() - sb.grant(b, cfg.RuntimeID) env := agenthost.Environment{ Sandbox: map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/home/runtime", "LANG": "C.UTF-8"}, Tool: map[string]string{"QUALIFY_VALUE": value, "QUALIFY_EXIT": fmt.Sprint(exit)}, } + configuration := proto.PromptRequestPayload{AgentKind: kind, StrictResume: true, DisableSubagents: true, + Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, + LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}} + if caps.FunctionTools.IsSupported() { + configuration.FunctionTools = []proto.FunctionTool{lookupTicket} + } + k := newTicket(t) + s := sb.session(h, cfg, env, configuration) + done, _ := s.turn(t, "qualify", prompt, k) + if !strings.Contains(done.Content, "VALUE="+value) || !strings.Contains(done.Content, fmt.Sprintf("EXIT=%d", exit)) { + t.Errorf("the answer %q does not report VALUE=%s EXIT=%d", done.Content, value, exit) + } + if got := sb.read(t, cfg, workspace+"/"+name); strings.TrimRight(got, "\n") != content { + t.Errorf("%s holds %q, want %q", name, got, content) + } + + if caps.FunctionTools.IsSupported() { + // The first Executor retired with its Router. + native, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string) + if native == "" { + t.Fatal("the first Turn reported no native session to resume") + } + s.configuration.AgentSessionID = native + done, calls := s.turn(t, "resumed-function", k.prompt("Call the lookup_ticket function"), k) + if resumed, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string); resumed != native { + t.Errorf("the resumed Turn reported the native session %q, want %q", resumed, native) + } + k.check(t, done, calls) + } + if caps.ToolSearch.IsSupported() { + deferred := lookupTicket + deferred.DeferLoading = true + configuration.ToolSearch, configuration.FunctionTools = true, []proto.FunctionTool{deferred} + search := sb.session(h, cfg, env, configuration) + done, calls := search.turn(t, "tool-search", k.prompt("Search your tools for the function that looks up support tickets"), k) + k.check(t, done, calls) + } +} + +// lookupTicket is the function the function Turns call. +var lookupTicket = proto.FunctionTool{Name: "lookup_ticket", Description: "Looks up a support ticket by its number.", + Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string","description":"The ticket number"}},"required":["ticket"],"additionalProperties":false}`)} + +// ticket is lookup_ticket's result for number: a text with first, an image, +// and a text with second. An answer that holds both codes shows that the +// Harness gave its model the whole result. +type ticket struct{ number, first, second, image string } + +func newTicket(t *testing.T) ticket { + var encoded bytes.Buffer + if err := png.Encode(&encoded, image.NewGray(image.Rect(0, 0, 8, 8))); err != nil { + t.Fatal(err) + } + number, _ := rand.Int(rand.Reader, big.NewInt(9000)) + return ticket{number: fmt.Sprint(number.Int64() + 1000), first: strings.ToLower(rand.Text()[:8]), second: strings.ToLower(rand.Text()[:8]), + image: "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())} +} + +func (k ticket) result() []proto.InputContent { + first, second := "Ticket "+k.number+": the first code is "+k.first+".", "The second code is "+k.second+"." + return []proto.InputContent{{Type: "input_text", Text: &first}, {Type: "input_image", ImageURL: &k.image}, {Type: "input_text", Text: &second}} +} + +// prompt asks for one call of the function that find finds. +func (k ticket) prompt(find string) string { + return find + ", and call it once with ticket \"" + k.number + "\".\nAnswer with exactly one line: FIRST= SECOND=" +} + +// check checks that the Turn called lookup_ticket for the ticket and +// answered with both codes. +func (k ticket) check(t *testing.T, done proto.DonePayload, calls []proto.FunctionCallPayload) { + t.Helper() + if len(calls) == 0 || calls[0].Name != lookupTicket.Name || !strings.Contains(string(calls[0].Arguments), k.number) { + t.Errorf("the Turn made %d function calls, want the first to call %s for ticket %s", len(calls), lookupTicket.Name, k.number) + } + if !strings.Contains(done.Content, "FIRST="+k.first) || !strings.Contains(done.Content, "SECOND="+k.second) { + t.Errorf("the answer %q does not report FIRST=%s SECOND=%s", done.Content, k.first, k.second) + } +} + +// session is a Session bound to the sandbox. Each Turn runs in a new +// Executor through a new dispatch Router. +type session struct { + h *agenthost.Host + cfg agenthost.Config + binding agenthost.Binding + env agenthost.Environment + id string + configuration proto.PromptRequestPayload +} + +func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, env agenthost.Environment, configuration proto.PromptRequestPayload) *session { + s := &session{h: h, cfg: cfg, binding: sb.binding(), env: env, id: uuid.NewString(), configuration: configuration} + s.configuration.AgentStateKey = "agents-api-" + s.id + sb.grant(s.binding, cfg.RuntimeID) + return s +} + +// turn prepares an Executor of the Session, runs prompt as its Turn run, +// answers each function call with k's result, and retires the Executor with +// the Router's Shutdown. It returns the Turn's Done and its function calls. +func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { + t.Helper() out := make(sender, 256) - router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: cfg.Log, - Registry: h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { return b, env, nil })}) + router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: s.cfg.Log, + Registry: s.h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { + return s.binding, s.env, nil + })}) if err != nil { t.Fatal(err) } - session := uuid.NewString() - handle(t, router, proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: session, Configuration: proto.PromptRequestPayload{ - AgentKind: kind, AgentStateKey: "agents-api-" + session, StrictResume: true, DisableSubagents: true, - Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, - LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}}}) - ready := out.status(t, "prepare") + prepare := "prepare-" + run + handle(t, router, proto.TypeExecutionPrepare, prepare, proto.ExecutionPreparePayload{SessionID: s.id, Configuration: s.configuration}) + ready := out.status(t, prepare) if ready.State != "ready" { t.Fatalf("the preparation is %s (%s), want ready; the log above says why", ready.State, ready.ErrorCode) } - handle(t, router, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, - RunID: "qualify", Input: proto.TextInput(prompt)}) - answer := out.collect(t, "qualify") + handle(t, router, proto.TypeExecutionStart, prepare, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, + RunID: run, Input: proto.TextInput(prompt)}) + done, calls := out.collect(t, router, run, k) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if err := router.Shutdown(ctx); err != nil { t.Errorf("Shutdown: %v", err) } - - if !strings.Contains(answer, "VALUE="+value) || !strings.Contains(answer, fmt.Sprintf("EXIT=%d", exit)) { - t.Errorf("the answer %q does not report VALUE=%s EXIT=%d", answer, value, exit) - } - if got := sb.read(t, cfg, workspace+"/"+name); strings.TrimRight(got, "\n") != content { - t.Errorf("%s holds %q, want %q", name, got, content) - } + return done, calls } func handle(t *testing.T, router *dispatch.Router, typ, id string, payload any) { @@ -214,21 +315,36 @@ func (s sender) status(t *testing.T, id string) proto.PreparationStatusPayload { } } -// collect reads the Turn's envelopes until its Done and returns its content. -func (s sender) collect(t *testing.T, run string) string { +// collect reads the Turn's envelopes until its Done. It answers each function +// call with k's result through router and checks that dispatch applied it. +func (s sender) collect(t *testing.T, router *dispatch.Router, run string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { t.Helper() deadline := time.After(turnLimit) + var calls []proto.FunctionCallPayload for { switch e := s.next(t, run, deadline); e.Type { case proto.TypeError: t.Errorf("Turn error: %s", e.Payload) + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + if err := e.DecodePayload(&call); err != nil { + t.Fatal(err) + } + t.Logf("function call: %s %s", call.Name, call.Arguments) + calls = append(calls, call) + handle(t, router, proto.TypeFunctionResult, run, proto.FunctionResultPayload{DeliveryID: "result-" + call.CallID, CallID: call.CallID, Success: true, Content: k.result()}) + case proto.TypeInteractionDecisionAck: + var ack proto.InteractionDecisionAckPayload + if err := e.DecodePayload(&ack); err != nil || !ack.Applied { + t.Errorf("a function result was not applied: %s", e.Payload) + } case proto.TypeDone: var d proto.DonePayload - if err := json.Unmarshal(e.Payload, &d); err != nil { + if err := e.DecodePayload(&d); err != nil { t.Fatal(err) } t.Logf("answer: %s", d.Content) - return d.Content + return d, calls } } } diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index bf62a5367..c39df42bb 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -367,7 +367,7 @@ Run the adapter's Turns, cancellation and continuation in a view, then qualify e | `Home` | Native history and configuration stay under `/.oac/home`, and a later Executor in the same Session continues from them. | | `Capabilities` | Each supported feature runs a Turn through dispatch: environment none in the empty-root view, Skills, function calls and results, tool search, and each stdio binding under its alias. | -`scripts/qualify-agent-host.sh` runs one Turn per Harness through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. Each Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. +`scripts/qualify-agent-host.sh` runs each Harness's Turns through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. The first Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. When the view declares function tools, a second Turn runs in a new Executor that resumes the Session's native history and calls a function; the test returns a text, image and text result through dispatch, and the answer must report both texts. When the view declares tool search, a Turn in another Session finds the deferred function with tool search and calls it. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. ## Native references diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3630b0fa5..3f18bf5fe 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "将原生 Harness 添加到 OpenAgentCore" source: contracts/agents-api/harness-onboarding.md -source_hash: d62c6b491f137b9cafd254a056c02b285ece3141f26fcd96d290c63210dcf785 +source_hash: b89f8241275419330cf55481c11add0a40b58cf06fa0544ed8e0d7cb3839d3a3 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -369,7 +369,7 @@ stdio 绑定在沙箱中以其别名运行。`ViewSession.MCP` 中索引为 `i` | `Home` | 原生历史和配置保存在 `/.oac/home` 下,同一 Session 中后续的 Executor 从中继续。 | | `Capabilities` | 每项受支持的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、Skills、函数调用及其结果、工具搜索,以及每个以别名运行的 stdio 绑定。 | -`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 为每个 Harness 运行一个 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。每个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 +`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 运行每个 Harness 的 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。视图声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。视图声明工具搜索时,另一个 Session 中的 Turn 用工具搜索找到延迟加载的函数并调用它。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 ## 原生参考 {#native-references}