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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions apps/daemon/internal/agent/claudesdk/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion apps/daemon/internal/agent/claudesdk/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
Expand Down
11 changes: 8 additions & 3 deletions apps/daemon/internal/agent/codex/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
4 changes: 3 additions & 1 deletion apps/daemon/internal/agent/codex/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
Expand Down
98 changes: 84 additions & 14 deletions apps/daemon/internal/agent/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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/<Name>.
Expand Down Expand Up @@ -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
Expand All @@ -269,18 +331,20 @@ 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)
}
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
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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)
}
}
Expand Down
16 changes: 9 additions & 7 deletions apps/daemon/internal/agent/mcode/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
15 changes: 14 additions & 1 deletion apps/daemon/internal/agent/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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") },
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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")
},
Expand Down
Loading
Loading