From 38a9eb10ee396486e2823a358ebf5679d91503e8 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 08:06:37 +0000 Subject: [PATCH 1/2] 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. --- apps/daemon/internal/agent/claudesdk/view.go | 8 ++ .../internal/agent/claudesdk/view_test.go | 4 +- apps/daemon/internal/agent/codex/view.go | 8 ++ apps/daemon/internal/agent/codex/view_test.go | 4 +- apps/daemon/internal/agent/harness.go | 98 +++++++++++++++--- apps/daemon/internal/agent/mcode/view.go | 10 +- 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(+), 86 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index b0a85689b..0012248dc 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) 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..9359dae5d 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 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..fc4d47aa9 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 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 24f488967e882756a47c1622f14db2942172dc0c Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 08:17:27 +0000 Subject: [PATCH 2/2] Leave capability and network admission to the agent host --- apps/daemon/internal/agent/claudesdk/view.go | 6 ------ apps/daemon/internal/agent/codex/view.go | 3 --- apps/daemon/internal/agent/mcode/view.go | 6 ------ 3 files changed, 15 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index 0012248dc..3716771ad 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -119,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/codex/view.go b/apps/daemon/internal/agent/codex/view.go index 9359dae5d..144b16ed6 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -137,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/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index fc4d47aa9..6e32f6b8d 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -158,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")