From c7685a806e3210168af8d9a7732fc2ae23450b40 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 22:06:20 +0000 Subject: [PATCH] Run installed Skills and MCP in agent-host views The agent host no longer rejects Skills. Each view hands its Harness the installed Skills at the sandbox paths the Environment owner filled, as the local Executor does: - Claude: the view passes Skills and the capability root to the bridge. - Codex: both paths register the Skill roots and check what Codex itself lists there with skills/list, which replaces the host-path layout check that cannot read sandbox paths. - MiniMax Code: the shared native configuration links each Skill into the native data directory, where its catalog loads them. The qualification rig installs a real plugin through runtime_prepare and runs one Turn with its Skill and one with its stdio MCP server, replacing the injected MCP. --- apps/daemon/internal/agent/claudesdk/view.go | 1 + .../internal/agent/claudesdk/view_test.go | 16 ++- .../internal/agent/codex/hosted_skills.go | 41 ------ .../agent/codex/hosted_skills_test.go | 36 ------ .../agent/codex/mcp_http_bearer_test.go | 6 +- .../internal/agent/codex/mcp_http_test.go | 6 +- .../agent/codex/permission_profile_test.go | 6 +- .../internal/agent/codex/preparation.go | 9 +- .../agent/codex/preparation_helpers_test.go | 12 ++ apps/daemon/internal/agent/codex/protocol.go | 18 +++ .../internal/agent/codex/session_plan.go | 31 ++--- .../agent/codex/session_policy_test.go | 2 +- apps/daemon/internal/agent/codex/skills.go | 70 +++++++++- .../internal/agent/codex/skills_test.go | 88 ++++++++----- apps/daemon/internal/agent/codex/view_test.go | 53 ++++++++ apps/daemon/internal/agent/mcode/options.go | 26 +++- apps/daemon/internal/agent/mcode/view.go | 2 +- apps/daemon/internal/agent/mcode/view_test.go | 19 ++- apps/daemon/internal/agent/mcode/workspace.go | 30 +---- apps/daemon/internal/agenthost/admit.go | 2 - .../internal/agenthost/admit_linux_test.go | 11 +- apps/daemon/internal/agenthost/doc.go | 4 +- .../agenthost/environment_linux_test.go | 3 +- .../agenthostqualify/qualify_linux_test.go | 122 +++++++++++------- contracts/agents-api/harness-onboarding.md | 6 +- contracts/agents-api/zh/harness-onboarding.md | 8 +- 26 files changed, 381 insertions(+), 247 deletions(-) delete mode 100644 apps/daemon/internal/agent/codex/hosted_skills.go delete mode 100644 apps/daemon/internal/agent/codex/hosted_skills_test.go diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index 88046d0ed..a5dcd0c91 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -144,6 +144,7 @@ func prepareView(layout viewLayout, req proto.PromptRequestPayload, view agent.V return start, env, nil } profile.NetworkAccess, profile.MCP = environment.NetworkAccess, servers + profile.Skills, profile.CapabilityRoot = environment.Skills, environment.CapabilityRoot start.Workspace, start.Cwd = profile, environment.WorkspaceRoot return start, env, nil } diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index 5d7a25197..c455d1070 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -21,8 +21,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/viewloader" + "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/agentskill" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -105,19 +107,25 @@ func TestViewExecutorLaunchesAClosedGatewayEnvironment(t *testing.T) { t.Fatal("the real key reached the view") } - // The Harness runs a stdio binding's alias without arguments. + // The Harness gets the installed Skill in the sandbox's capability root + // and runs a stdio binding's alias without arguments. docs := session.MCP session.MCP = []agent.MCPBinding{{ServerLabel: "local", Transport: "stdio", Stdio: &proto.EnvironmentMCP{ Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}} - stdio, err := view.Executor(t.Context(), req, session) + installed, local := req, *req.LocalEnvironment + local.CapabilityRoot, local.Skills = agentcapabilities.Directory, []agentcapabilities.InstalledSkill{{InstallationRoot: agentcapabilities.Directory, + Metadata: agentskill.Metadata{Type: "inline", Name: "review", Description: "Review."}, RelativeRoot: "skills/review", PackageRoot: "skills/review"}} + installed.LocalEnvironment = &local + stdio, err := view.Executor(t.Context(), installed, session) if err != nil { t.Fatal(err) } defer stdio.Close(ctx) var stdioStart startRequest if err := json.Unmarshal(<-requests, &stdioStart); err != nil || stdioStart.Workspace == nil || len(stdioStart.Workspace.MCP) != 1 || - stdioStart.Workspace.MCP[0].Command != agent.ViewAlias(0) || stdioStart.Workspace.MCP[0].Args != nil { - t.Fatalf("stdio MCP = %+v, %v", stdioStart.Workspace, err) + stdioStart.Workspace.MCP[0].Command != agent.ViewAlias(0) || stdioStart.Workspace.MCP[0].Args != nil || + stdioStart.Workspace.CapabilityRoot != agentcapabilities.Directory || len(stdioStart.Workspace.Skills) != 1 || stdioStart.Workspace.Skills[0].RelativeRoot != "skills/review" { + t.Fatalf("installed Skill and stdio MCP = %+v, %v", stdioStart.Workspace, err) } // With environment none the bridge runs without a workspace in the work directory. diff --git a/apps/daemon/internal/agent/codex/hosted_skills.go b/apps/daemon/internal/agent/codex/hosted_skills.go deleted file mode 100644 index 7b20452cb..000000000 --- a/apps/daemon/internal/agent/codex/hosted_skills.go +++ /dev/null @@ -1,41 +0,0 @@ -package codex - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" -) - -func verifyHostedSkills(skills []agentcapabilities.InstalledSkill) error { - for _, skill := range skills { - if err := verifyHostedSkillLayout(localworkspace.SkillPath(skill)); err != nil { - return err - } - } - return nil -} - -func verifyHostedSkillLayout(root string) error { - // Native dependency declarations may start MCP installation outside the - // workspace tool sandbox. They are not qualified by an inert Skill upload. - if _, err := os.Lstat(filepath.Join(root, "agents", "openai.yaml")); err == nil { - return fmt.Errorf("codex: native Skill configuration is unsupported") - } else if !os.IsNotExist(err) { - return err - } - // Extra roots are scanned recursively. One public Skill must not silently - // introduce additional native Skills with their own activation metadata. - return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.Name() == "SKILL.md" && path != filepath.Join(root, "SKILL.md") { - return fmt.Errorf("codex: nested native Skills are unsupported") - } - return nil - }) -} diff --git a/apps/daemon/internal/agent/codex/hosted_skills_test.go b/apps/daemon/internal/agent/codex/hosted_skills_test.go deleted file mode 100644 index 0c7455687..000000000 --- a/apps/daemon/internal/agent/codex/hosted_skills_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package codex - -import ( - "os" - "path/filepath" - "testing" -) - -func TestHostedSkillDoesNotActivateAdditionalNativeResources(t *testing.T) { - for _, tc := range []struct { - name string - files []string - rejected bool - }{ - {"ordinary supporting files", []string{"SKILL.md", "scripts/check.py", "references/guide.md"}, false}, - {"native dependency configuration", []string{"SKILL.md", "agents/openai.yaml"}, true}, - {"nested discovery", []string{"SKILL.md", "references/other/SKILL.md"}, true}, - {"nested dependencies", []string{"SKILL.md", "references/other/SKILL.md", "references/other/agents/openai.yaml"}, true}, - } { - t.Run(tc.name, func(t *testing.T) { - root := t.TempDir() - for _, name := range tc.files { - path := filepath.Join(root, name) - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("fixture"), 0400); err != nil { - t.Fatal(err) - } - } - if err := verifyHostedSkillLayout(root); (err != nil) != tc.rejected { - t.Fatalf("rejected=%v err=%v", tc.rejected, err) - } - }) - } -} diff --git a/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go index 8cd66c0d9..9d143b7c1 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go @@ -22,7 +22,7 @@ func TestMCPHTTPBearerPlanSeparatesServersAndProcesses(t *testing.T) { req := proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "retained-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} seen := map[string]bool{} for range 2 { - plan, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + plan, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) if err != nil { t.Fatal(err) } @@ -58,7 +58,7 @@ func TestMCPHTTPBearerRejectsInvalidTokensWithoutPersistence(t *testing.T) { for _, token := range []string{"", "=", " has-space", "has-space ", "has space", "line\r\ninjection", "nul\x00byte", "opaque中文", "middle=padding", "punctuation:invalid"} { servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} req := proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "invalid-bearer", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} - if _, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil || err.Error() != "invalid HTTPS MCP bearer credential" { + if _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil || err.Error() != "invalid HTTPS MCP bearer credential" { t.Fatal("invalid bearer value accepted or unsafe error returned") } } @@ -84,7 +84,7 @@ func TestMCPHTTPBearerDoesNotReachModelCatalogProbe(t *testing.T) { Model: "fixture-model", ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} cfg := defaultSessionConfig() cfg.codexBinary = binary - plan, _, err := prepareSessionPlan(t.Context(), req, cfg) + plan, err := prepareSessionPlan(t.Context(), req, cfg) if err != nil { t.Fatal("catalog probe inherited bearer or failed", err) } diff --git a/apps/daemon/internal/agent/codex/mcp_http_test.go b/apps/daemon/internal/agent/codex/mcp_http_test.go index 0b62c9b87..0ab5c499c 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_test.go @@ -21,7 +21,7 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { {ConnectionOrigin: "service", ServerLabel: "blocked", ServerURL: "http://127.0.0.1:12345/mcp", AllowedTools: &denyAll}, } req := proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "public-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} - plan, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + plan, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) if err != nil { t.Fatal(err) } @@ -54,7 +54,7 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { t.Fatal(err) } servers = []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "replacement", ServerURL: "https://new.example/mcp"}} - second, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + second, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) if err != nil { t.Fatal(err) } @@ -100,7 +100,7 @@ func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { t.Fatal(err) } req := proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "credentials", DisableExecutionEnvironment: true, MCPHTTPServers: &valid} - if _, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil { + if _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil { t.Fatal("existing MCP credentials accepted") } if after, err := os.ReadFile(path); err != nil || !reflect.DeepEqual(after, stored) { diff --git a/apps/daemon/internal/agent/codex/permission_profile_test.go b/apps/daemon/internal/agent/codex/permission_profile_test.go index 642327d50..f93bb8472 100644 --- a/apps/daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/daemon/internal/agent/codex/permission_profile_test.go @@ -12,7 +12,7 @@ func TestRuntimeUsesHostPermissions(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) { req := proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()}} - plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) + plan, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) if err != nil { t.Fatal(err) } @@ -27,7 +27,7 @@ func TestRuntimeUsesHostPermissions(t *testing.T) { plan.Cleanup() for _, network := range []string{"disabled", "restricted"} { req.LocalEnvironment.NetworkAccess = network - if _, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}); err == nil { + if _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}); err == nil { t.Fatal("unsupported network admitted") } } @@ -49,7 +49,7 @@ func TestSelfHostedToolEnvironmentCannotRedirectNativeHistory(t *testing.T) { t.Setenv(key, value) } req := proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled"}} - plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) + plan, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index bbc8a7356..0b6094f6f 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -35,11 +35,10 @@ func newExecutor(parent context.Context, req proto.PromptRequestPayload, cfg ses return nil, err } var plan SessionPlan - var skillRoots []string if cfg.view != nil { plan, err = prepareViewPlan(parent, req, cfg) } else { - plan, skillRoots, err = prepareSessionPlan(parent, req, cfg) + plan, err = prepareSessionPlan(parent, req, cfg) } if err != nil { return nil, err @@ -106,9 +105,9 @@ func newExecutor(parent context.Context, req proto.PromptRequestPayload, cfg ses return e.preparationFailed(err) } } - if len(skillRoots) > 0 { - if err := setSkillExtraRoots(cancelCtx, rpc, skillRoots); err != nil { - return e.preparationFailed(fmt.Errorf("codex: register skill root: %w", err)) + if local := req.LocalEnvironment; local != nil && len(local.Skills) > 0 { + if err := registerSkills(cancelCtx, rpc, plan.Cwd, local.Skills); err != nil { + return e.preparationFailed(err) } } diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index 24952d1d8..57197d0cb 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -148,6 +148,7 @@ func TestPreparationFakeCodexProcess(t *testing.T) { turnNumber := 0 currentTurn := "" executorMode := os.Getenv("OAC_TEST_EXECUTOR_MODE") + var skills SkillsExtraRootsSetParams for scanner.Scan() { var frame preparationFrame if json.Unmarshal(scanner.Bytes(), &frame) != nil { @@ -172,6 +173,17 @@ func TestPreparationFakeCodexProcess(t *testing.T) { status = string(data) } result = map[string]string{"status": status} + case "skills/extraRoots/set": + if json.Unmarshal(frame.Params, &skills) != nil { + os.Exit(7) + } + case "skills/list": + // Each registered root holds one Skill. + listed := []map[string]string{} + for _, root := range skills.ExtraRoots { + listed = append(listed, map[string]string{"name": filepath.Base(root), "path": filepath.Join(root, "SKILL.md")}) + } + result = map[string]any{"data": []any{map[string]any{"skills": listed, "errors": []any{}}}} case "config/read": data, err := os.ReadFile(os.Getenv("OAC_TEST_PREPARATION_MCP_CONFIG")) if err != nil || json.Unmarshal(data, &result) != nil { diff --git a/apps/daemon/internal/agent/codex/protocol.go b/apps/daemon/internal/agent/codex/protocol.go index 2daa4acb7..257ef9124 100644 --- a/apps/daemon/internal/agent/codex/protocol.go +++ b/apps/daemon/internal/agent/codex/protocol.go @@ -93,6 +93,24 @@ type SkillsExtraRootsSetParams struct { ExtraRoots []string `json:"extraRoots"` } +type SkillsListParams struct { + Cwds []string `json:"cwds"` + ForceReload bool `json:"forceReload"` +} + +// SkillsListResponse holds the fields of skills/list that the adapter checks. +type SkillsListResponse struct { + Data []struct { + Skills []struct { + Path string `json:"path"` + Dependencies any `json:"dependencies"` + } `json:"skills"` + Errors []struct { + Path string `json:"path"` + } `json:"errors"` + } `json:"data"` +} + // --------------------------------------------------------------------------- // thread/start, thread/resume, thread/list // --------------------------------------------------------------------------- diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go index cb8008cb2..f69fd07f1 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -9,24 +9,24 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, []string, error) { +func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, error) { if err := validateNativeTransportEnvironment(); err != nil { - return SessionPlan{}, nil, err + return SessionPlan{}, err } if err := validatePermissionProfile(req); err != nil { - return SessionPlan{}, nil, err + return SessionPlan{}, err } mcpServers, mcpEnv, err := runtimeMCPServers(req) if err != nil { - return SessionPlan{}, nil, err + return SessionPlan{}, err } plan, err := BuildSessionPlan(req) if err != nil { - return SessionPlan{}, nil, fmt.Errorf("codex: build session plan: %w", err) + return SessionPlan{}, fmt.Errorf("codex: build session plan: %w", err) } if err := configureSubagentObservations(&plan, req); err != nil { plan.Cleanup() - return SessionPlan{}, nil, err + return SessionPlan{}, err } disableProgrammaticTools(&plan, req.ExecutionControls) if req.LocalEnvironment != nil { @@ -40,7 +40,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg values, err := localworkspace.ReadOptionalToolEnvironment() if err != nil { plan.Cleanup() - return SessionPlan{}, nil, err + return SessionPlan{}, err } for key, value := range values { if strings.EqualFold(key, "CODEX_HOME") || strings.EqualFold(key, "HOME") || strings.EqualFold(key, "USERPROFILE") { @@ -56,29 +56,18 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if mcpServers != nil { if err := configureMCP(&plan, mcpServers); err != nil { plan.Cleanup() - return SessionPlan{}, nil, err + return SessionPlan{}, err } } if req.ExecutionControls != nil { if err := prepareModelVerbosity(ctx, cfg.codexBinary, &plan); err != nil { plan.Cleanup() - return SessionPlan{}, nil, err - } - } - - var skillRoots []string - if req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) > 0 { - if err := verifyHostedSkills(req.LocalEnvironment.Skills); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } - for _, skill := range req.LocalEnvironment.Skills { - skillRoots = append(skillRoots, localworkspace.SkillPath(skill)) + return SessionPlan{}, err } } plan.Env = append(plan.Env, mcpEnv...) - return plan, skillRoots, nil + return plan, nil } diff --git a/apps/daemon/internal/agent/codex/session_policy_test.go b/apps/daemon/internal/agent/codex/session_policy_test.go index 0d9be040a..f1fdb082c 100644 --- a/apps/daemon/internal/agent/codex/session_policy_test.go +++ b/apps/daemon/internal/agent/codex/session_policy_test.go @@ -16,7 +16,7 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { for _, method := range []string{"thread/start", "thread/resume"} { t.Run(method, func(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "conv/agent/codex", DisableSubagents: true, DisableExecutionEnvironment: true}, sessionConfig{}) + plan, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), AgentStateKey: "conv/agent/codex", DisableSubagents: true, DisableExecutionEnvironment: true}, sessionConfig{}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/skills.go b/apps/daemon/internal/agent/codex/skills.go index f7bdfe04a..56acabf64 100644 --- a/apps/daemon/internal/agent/codex/skills.go +++ b/apps/daemon/internal/agent/codex/skills.go @@ -1,11 +1,69 @@ package codex -import "context" +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" -func setSkillExtraRoots(ctx context.Context, rpc *JSONRPCClient, roots []string) error { - if len(roots) == 0 { - return nil + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" +) + +// registerSkills registers each installed Skill's root as an extra root and +// checks, in what Codex itself loaded, that each root holds exactly its Skill. +// Codex scans extra roots recursively, so a nested SKILL.md would add a native +// Skill of its own, and a native dependency declaration may start MCP +// installation outside the workspace tools; both are refused. The paths are as +// the Harness sees them, so this works for a view's sandbox paths too. +func registerSkills(ctx context.Context, rpc *JSONRPCClient, cwd string, skills []agentcapabilities.InstalledSkill) error { + roots := make([]string, 0, len(skills)) + for _, skill := range skills { + roots = append(roots, localworkspace.SkillPath(skill)) + } + if _, err := rpc.Request(ctx, "skills/extraRoots/set", SkillsExtraRootsSetParams{ExtraRoots: roots}); err != nil { + return fmt.Errorf("codex: register skill roots: %w", err) + } + raw, err := rpc.Request(ctx, "skills/list", SkillsListParams{Cwds: []string{cwd}, ForceReload: true}) + var listed SkillsListResponse + if err == nil { + err = json.Unmarshal(raw, &listed) + } + if err != nil { + return fmt.Errorf("codex: list skills: %w", err) + } + within := func(name string) string { + for _, root := range roots { + if strings.HasPrefix(name, root+string(filepath.Separator)) { + return root + } + } + return "" + } + loaded := map[string]bool{} + for _, entry := range listed.Data { + for _, failure := range entry.Errors { + if within(failure.Path) != "" { + return errors.New("codex: an installed Skill failed to load") + } + } + for _, skill := range entry.Skills { + root := within(skill.Path) + switch { + case root == "": + case skill.Path != filepath.Join(root, "SKILL.md"): + return errors.New("codex: nested native Skills are unsupported") + case skill.Dependencies != nil: + return errors.New("codex: native Skill dependencies are unsupported") + default: + loaded[root] = true + } + } + } + if len(loaded) != len(roots) { + return errors.New("codex: an installed Skill did not load") } - _, err := rpc.Request(ctx, "skills/extraRoots/set", SkillsExtraRootsSetParams{ExtraRoots: roots}) - return err + return nil } diff --git a/apps/daemon/internal/agent/codex/skills_test.go b/apps/daemon/internal/agent/codex/skills_test.go index bc316eed9..55e872d8a 100644 --- a/apps/daemon/internal/agent/codex/skills_test.go +++ b/apps/daemon/internal/agent/codex/skills_test.go @@ -3,38 +3,66 @@ package codex import ( "context" "encoding/json" + "path/filepath" "testing" -) - -func TestSetSkillExtraRootsUsesCodexRPC(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - result := make(chan error, 1) - go func() { - result <- setSkillExtraRoots(context.Background(), client.JSONRPCClient, []string{"/managed/skills"}) - }() + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" +) - decoder := json.NewDecoder(server.FromClient) - var request struct { - ID string `json:"id"` - Method string `json:"method"` - Params SkillsExtraRootsSetParams `json:"params"` - } - if err := decoder.Decode(&request); err != nil { - t.Fatalf("decode request: %v", err) - } - if request.Method != "skills/extraRoots/set" { - t.Fatalf("method = %q", request.Method) - } - if len(request.Params.ExtraRoots) != 1 || request.Params.ExtraRoots[0] != "/managed/skills" { - t.Fatalf("params = %+v", request.Params) - } - response, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": request.ID, "result": map[string]any{}}) - if _, err := server.ToClient.Write(append(response, '\n')); err != nil { - t.Fatalf("write response: %v", err) - } - if err := <-result; err != nil { - t.Fatalf("setSkillExtraRoots: %v", err) +// TestRegisterSkillsChecksWhatCodexLoaded checks that the installed Skill's +// root is registered and that Codex's own listing must show exactly that +// Skill there, without native dependencies. +func TestRegisterSkillsChecksWhatCodexLoaded(t *testing.T) { + root := filepath.Join("/managed", "skills", "review") + manifest := filepath.Join(root, "SKILL.md") + skill := map[string]any{"name": "review", "path": manifest, "scope": "user", "enabled": true} + nested := map[string]any{"name": "other", "path": filepath.Join(root, "references", "other", "SKILL.md"), "scope": "user", "enabled": true} + dependent := map[string]any{"name": "review", "path": manifest, "dependencies": map[string]any{"tools": []any{map[string]string{"type": "mcp", "value": "docs"}}}} + system := map[string]any{"name": "imagegen", "path": filepath.Join("/home", ".codex", "skills", ".system", "imagegen", "SKILL.md"), "scope": "system", "enabled": true} + registered, _ := json.Marshal(SkillsExtraRootsSetParams{ExtraRoots: []string{root}}) + for name, c := range map[string]struct { + skills, errors []any + accepted bool + }{ + "the Skill": {[]any{skill, system}, nil, true}, + "a nested Skill": {[]any{skill, nested}, nil, false}, + "native dependencies": {[]any{dependent}, nil, false}, + "a Skill that failed": {[]any{system}, []any{map[string]string{"message": "invalid", "path": manifest}}, false}, + "a Skill that is absent": {[]any{system}, nil, false}, + } { + client, server, cleanup := NewTestClient() + result := make(chan error, 1) + go func() { + result <- registerSkills(context.Background(), client.JSONRPCClient, "/workspace", + []agentcapabilities.InstalledSkill{{InstallationRoot: "/managed", RelativeRoot: "skills/review"}}) + }() + decoder := json.NewDecoder(server.FromClient) + for _, method := range []string{"skills/extraRoots/set", "skills/list"} { + var request struct { + ID string `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := decoder.Decode(&request); err != nil || request.Method != method { + t.Fatalf("%s: request %s, %v; want %s", name, request.Method, err, method) + } + var reply any = map[string]any{} + switch method { + case "skills/extraRoots/set": + if string(request.Params) != string(registered) { + t.Fatalf("%s: params %s", name, request.Params) + } + case "skills/list": + reply = map[string]any{"data": []any{map[string]any{"cwd": "/workspace", "skills": c.skills, "errors": c.errors}}} + } + response, _ := json.Marshal(map[string]any{"id": request.ID, "result": reply}) + if _, err := server.ToClient.Write(append(response, '\n')); err != nil { + t.Fatal(err) + } + } + if err := <-result; (err == nil) != c.accepted { + t.Errorf("%s: registerSkills = %v, want accepted %v", name, err, c.accepted) + } + cleanup() } } diff --git a/apps/daemon/internal/agent/codex/view_test.go b/apps/daemon/internal/agent/codex/view_test.go index 51605ea7f..65d302df8 100644 --- a/apps/daemon/internal/agent/codex/view_test.go +++ b/apps/daemon/internal/agent/codex/view_test.go @@ -1,6 +1,8 @@ package codex import ( + "context" + "encoding/json" "errors" "io/fs" "os" @@ -11,6 +13,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/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" @@ -133,3 +136,53 @@ func TestViewExecutorLaunchesInTheSessionView(t *testing.T) { t.Fatalf("stdio alias config.toml: %v\n%s", err, config) } } + +// TestViewHandsCodexTheInstalledSkillAndMCP checks that a view registers the +// installed Skill's root at the sandbox path the owner filled, which Codex +// lists, and configures the installed stdio MCP server under its alias. +func TestViewHandsCodexTheInstalledSkillAndMCP(t *testing.T) { + _, cfg, root := preparationFixture(t) + declared := newView(filepath.Join(t.TempDir(), "codex"), false) + home := t.TempDir() + session := agent.ViewSession{ + Home: agent.ViewDir{Host: home, View: agent.ViewPrivateRoot + "/" + agent.ViewHomeName}, + Proxy: "http://127.0.0.1:17100", + 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)}}}}, + // The fake Codex runs on the host, outside the view. + Launch: func(opts clirunner.StartOptions) (*clirunner.Process, error) { + opts.Binary, opts.Dir, opts.Env = cfg.codexBinary, "", os.Environ() + return clirunner.Start(opts) + }, + } + server := map[string]any{"command": agent.ViewAlias(0), "args": []string{}, "environment_id": "local", "enabled": true, "default_tools_approval_mode": "approve"} + config := filepath.Join(root, "mcp-config.json") + writeMCPHTTPConfigResponse(t, config, map[string]any{"config": map[string]any{"mcp_servers": map[string]any{"local": server}, + "features": map[string]any{"plugins": false, "apps": false}, "mcp_oauth_credentials_store": "file"}}) + t.Setenv("OAC_TEST_PREPARATION_MCP_CONFIG", config) + req := proto.PromptRequestPayload{AgentStateKey: "state", Model: "m", + ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: "http://127.0.0.1:17101", APIKey: modelprovider.Placeholder}, + LocalEnvironment: &proto.LocalEnvironment{WorkspaceRoot: "/workspace", NetworkAccess: "enabled", CapabilityRoot: agentcapabilities.Directory, + Skills: []agentcapabilities.InstalledSkill{{InstallationRoot: agentcapabilities.Directory, RelativeRoot: "skills/review", PackageRoot: "skills/review"}}}} + e, err := declared.Executor(t.Context(), req, session) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := e.Close(context.Background()); err != nil { + t.Error(err) + } + }) + var registered SkillsExtraRootsSetParams + for _, frame := range preparationFrames(t, root) { + if frame.Method == "skills/extraRoots/set" && json.Unmarshal(frame.Params, ®istered) != nil { + t.Fatal(frame) + } + } + if want := []string{agentcapabilities.Directory + "/skills/review"}; !slices.Equal(registered.ExtraRoots, want) { + t.Errorf("extra roots %v, want %v", registered.ExtraRoots, want) + } + if body, err := os.ReadFile(filepath.Join(home, viewCodexHome, "config.toml")); err != nil || !strings.Contains(string(body), "command = \""+agent.ViewAlias(0)+"\"\n") { + t.Errorf("config.toml: %v\n%s", err, body) + } +} diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 2f3f93414..439101514 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -2,7 +2,9 @@ package mcode import ( "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "strconv" @@ -10,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/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" @@ -120,8 +123,29 @@ func writeNativeConfig(req proto.PromptRequestPayload, prepared harnessconfig.Pr config["permissionMode"] = "bypassPermissions" config["sandbox"] = map[string]bool{"enabled": false} if len(tools.skills) > 0 { + // The native catalog loads Skills from its data directory, so each + // installed Skill is linked there by name to its root as the native + // process sees it. + if err := data.MkdirAll("skills", 0o700); err != nil { + return err + } + names := make([]string, 0, len(tools.skills)) + for _, skill := range tools.skills { + link, target := filepath.Join("skills", skill.Metadata.Name), localworkspace.SkillPath(skill) + actual, err := data.Readlink(link) + switch { + case errors.Is(err, fs.ErrNotExist): + err = data.Symlink(target, link) + case err == nil && actual != target: + err = errors.New("mcode: unexpected native Skill root") + } + if err != nil { + return err + } + names = append(names, skill.Metadata.Name) + } selected := config["agents"].(map[string]any)["default"].(map[string]any) - selected["skills"] = tools.skills + selected["skills"] = names for _, key := range []string{"tools", "builtinTools"} { selected[key] = append(selected[key].([]string), "skill") } diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index 2a3e5db28..86a17290a 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -193,7 +193,7 @@ func (i viewInstall) prepare(req proto.PromptRequestPayload, session agent.ViewS var tools *workspaceTools opts.MCP = []map[string]any{} if local != nil { - tools = &workspaceTools{node: i.node, bridge: i.bridge, profile: map[string]any{"workspace": dir, "scratch": tempDir, "network": "enabled"}} + tools = &workspaceTools{node: i.node, bridge: i.bridge, profile: map[string]any{"workspace": dir, "scratch": tempDir, "network": "enabled"}, skills: local.Skills} opts.MCP = append(opts.MCP, tools.server(dataDir)) } if err := writeNativeConfig(private, prepared, data, dataDir, tools); err != nil { diff --git a/apps/daemon/internal/agent/mcode/view_test.go b/apps/daemon/internal/agent/mcode/view_test.go index 5333c4498..cbfa4fe92 100644 --- a/apps/daemon/internal/agent/mcode/view_test.go +++ b/apps/daemon/internal/agent/mcode/view_test.go @@ -14,8 +14,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/viewloader" + "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/agentskill" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -121,8 +123,9 @@ func TestViewLaunchesNodeWithGatewayOnly(t *testing.T) { } // With environment none the CLI runs in the work directory without the -// workspace tools, and it runs each stdio binding's alias without arguments. -func TestViewRunsEnvironmentNoneAndStdioAliases(t *testing.T) { +// workspace tools. In the workspace it runs each stdio binding's alias +// without arguments and loads each installed Skill from its sandbox path. +func TestViewRunsEnvironmentNoneStdioAliasesAndSkills(t *testing.T) { install, _, req := viewFixture(t) session := agent.ViewSession{Home: agent.ViewDir{Host: t.TempDir(), View: path.Join(agent.ViewPrivateRoot, agent.ViewHomeName)}} none := req @@ -134,12 +137,24 @@ func TestViewRunsEnvironmentNoneAndStdioAliases(t *testing.T) { 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)}}}} + req.LocalEnvironment.CapabilityRoot, req.LocalEnvironment.Skills = agentcapabilities.Directory, []agentcapabilities.InstalledSkill{{InstallationRoot: agentcapabilities.Directory, + Metadata: agentskill.Metadata{Type: "inline", Name: "review", Description: "Review."}, RelativeRoot: "skills/review", PackageRoot: "skills/review"}} if opts, err = install.prepare(req, session); err != nil || len(opts.MCP) != 2 || opts.MCP[0]["name"] != "oac_workspace" || opts.MCP[1]["command"] != agent.ViewAlias(0) { t.Fatalf("stdio MCP = %v: %v", opts.MCP, err) } if args, ok := opts.MCP[1]["args"].([]string); !ok || args == nil || len(args) != 0 { t.Fatalf("the stdio alias runs with arguments %#v", opts.MCP[1]["args"]) } + if target, err := os.Readlink(filepath.Join(session.Home.Host, viewDataName, "skills", "review")); err != nil || target != agentcapabilities.Directory+"/skills/review" { + t.Fatalf("the native Skill links to %q: %v", target, err) + } + var config struct { + Agents map[string]struct{ Skills, Tools []string } + } + raw, err := os.ReadFile(filepath.Join(session.Home.Host, viewDataName, "config.yaml")) + if err != nil || json.Unmarshal(raw, &config) != nil || !slices.Equal(config.Agents["default"].Skills, []string{"review"}) || !slices.Contains(config.Agents["default"].Tools, "skill") { + t.Fatalf("native configuration %s: %v", raw, err) + } } func TestViewReadsSubagentsBesideTheCLI(t *testing.T) { diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go index 61bcb719a..68219c142 100644 --- a/apps/daemon/internal/agent/mcode/workspace.go +++ b/apps/daemon/internal/agent/mcode/workspace.go @@ -7,6 +7,7 @@ import ( "runtime" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) @@ -59,7 +60,8 @@ func prepareWorkspaceOptions(c WorkspaceConfig, req proto.PromptRequestPayload) if err != nil { return launchOptions{}, err } - tools := workspaceTools{node: c.Node, bridge: c.Bridge, profile: map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": c.Directory, "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.LocalEnvironment.Skills) > 0}} + tools := workspaceTools{node: c.Node, bridge: c.Bridge, profile: map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": c.Directory, "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.LocalEnvironment.Skills) > 0}, + skills: req.LocalEnvironment.Skills} file, err := localworkspace.ToolEnvironmentFile() if err != nil { return launchOptions{}, err @@ -67,9 +69,6 @@ func prepareWorkspaceOptions(c WorkspaceConfig, req proto.PromptRequestPayload) if file != "" { tools.profile["toolEnvFile"] = file } - for _, skill := range req.LocalEnvironment.Skills { - tools.skills = append(tools.skills, skill.Metadata.Name) - } // Reuse public option validation and private Session state provisioning. // The native process, ACP Session and workspace tools share the declared cwd. private := req @@ -82,34 +81,15 @@ func prepareWorkspaceOptions(c WorkspaceConfig, req proto.PromptRequestPayload) } opts.Dir, opts.bindings = c.Directory, bindings opts.MCP = append([]map[string]any{tools.server(opts.DataDir)}, servers...) - if len(req.LocalEnvironment.Skills) > 0 { - root := filepath.Join(opts.DataDir, "skills") - if err := os.MkdirAll(root, 0700); err != nil { - return opts, err - } - for _, skill := range req.LocalEnvironment.Skills { - link, target := filepath.Join(root, skill.Metadata.Name), localworkspace.SkillPath(skill) - actual, err := os.Readlink(link) - if err == nil { - if actual != target { - return opts, fmt.Errorf("mcode: unexpected native Skill root") - } - } else if !os.IsNotExist(err) { - return opts, err - } else if err := os.Symlink(target, link); err != nil { - return opts, err - } - } - } return opts, nil } // workspaceTools is the workspace bridge as the native process runs it, the -// bridge's profile and the Skills it presents. +// bridge's profile and the installed Skills it presents. type workspaceTools struct { node, bridge string profile map[string]any - skills []string + skills []agentcapabilities.InstalledSkill } // server is the bridge's ACP MCP server, with its profile in dataDir as the diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index 34d30d75e..f9cbcc84d 100644 --- a/apps/daemon/internal/agenthost/admit.go +++ b/apps/daemon/internal/agenthost/admit.go @@ -105,8 +105,6 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env return nil, invalidSession("workspace %q is not absolute and clean", local.WorkspaceDirectory) case local != nil && local.Capabilities && local.CapabilityRoot == "": return nil, unsupported("installed Capabilities that no preparation resolved") - case local != nil && len(local.Skills) > 0: - return nil, unsupported("Skills") case local != nil && (local.NetworkAccess != "enabled" || len(local.AllowedDomains) > 0): return nil, unsupported("a restricted workspace network") case !none && len(view.Shims) > 0 && !hasPATH(env): diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index a9f776e58..466c5f5ba 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -82,10 +82,6 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { "restricted network": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.NetworkAccess = "disabled" }, unsupported}, "allowed domains only": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.AllowedDomains = []string{"example.com"} }, unsupported}, "unprepared Capabilities": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities = true }, unsupported}, - "Skills": {"viewed", func(r *proto.PromptRequestPayload) { - r.LocalEnvironment.Capabilities, r.LocalEnvironment.CapabilityRoot = true, "/capabilities" - r.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "skills/review"}} - }, unsupported}, "credentialed stdio MCP": {"viewed", 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}}, @@ -180,6 +176,8 @@ func TestViewExecutorReceivesTheGatewayRequest(t *testing.T) { bearer := "mcp-secret" req := request("viewed", "/workspace", "https://model.test", "sk-test") req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "docs", ServerURL: "https://mcp.test/docs?tenant=a", BearerToken: &bearer}} + skills := []agentcapabilities.InstalledSkill{{InstallationRoot: agentcapabilities.Directory, RelativeRoot: "skills/review", PackageRoot: "skills/review"}} + req.LocalEnvironment.Capabilities, req.LocalEnvironment.CapabilityRoot, req.LocalEnvironment.Skills = true, agentcapabilities.Directory, skills original := *req.ModelProvider var dials atomic.Int32 e, err := open(context.Background(), f.cfg, req, bindTo(newBinding(newResource())), deps{dial: countingDial(&dials), tasks: noTasks}) @@ -192,8 +190,9 @@ func TestViewExecutorReceivesTheGatewayRequest(t *testing.T) { if *req.ModelProvider != original { t.Error("the Session's request changed") } - if f.req.MCPHTTPServers != nil || f.req.LocalEnvironment == nil || f.req.LocalEnvironment.MCP != nil || f.req.LocalEnvironment.WorkspaceRoot != "/workspace" { - t.Error("the request still carries MCP or does not run in the Environment's workspace") + if local := f.req.LocalEnvironment; f.req.MCPHTTPServers != nil || local == nil || local.MCP != nil || local.WorkspaceRoot != "/workspace" || + local.CapabilityRoot != agentcapabilities.Directory || !reflect.DeepEqual(local.Skills, skills) { + t.Error("the request still carries MCP, does not run in the Environment's workspace or lost its installed Skills") } mcp := f.session.MCP if len(mcp) != 1 || mcp[0].ServerLabel != "docs" || mcp[0].ServerURL != "http://127.0.0.1:17102/docs" || mcp[0].BearerToken != nil || mcp[0].HTTPHeaders != nil { diff --git a/apps/daemon/internal/agenthost/doc.go b/apps/daemon/internal/agenthost/doc.go index 42ff8f5c5..90599726e 100644 --- a/apps/daemon/internal/agenthost/doc.go +++ b/apps/daemon/internal/agenthost/doc.go @@ -56,8 +56,8 @@ // any effect: the kind must declare an agent.View, and when the view declares // 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 with Skills, with a restricted network, or with a stdio MCP server -// that needs a credential is rejected. The registry declares each kind in a +// Session with a restricted network, or with a stdio MCP server that needs a +// credential, is rejected. The registry declares each kind in a // local Environment and with environment none, as agent.EnvironmentSupport // composes them. The factory then allocates the // Executor's uid, skipping each uid that a running thread holds as its real, diff --git a/apps/daemon/internal/agenthost/environment_linux_test.go b/apps/daemon/internal/agenthost/environment_linux_test.go index 33f5a62ac..de271aa69 100644 --- a/apps/daemon/internal/agenthost/environment_linux_test.go +++ b/apps/daemon/internal/agenthost/environment_linux_test.go @@ -60,8 +60,7 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { sb.auth.AddRuntime(cfg.Credential, cfg.RuntimeID) sb.ready(t, cfg) h := &Host{cfg: cfg, owners: owners{d: deps{dial: relayDial(cfg)}}} - // The factory records what the owner prepared: admission does not run - // Skills in views yet. + // The factory records what the owner prepared. prepared := make(chan preparedExecutor, 4) reg := registry(harnesses, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { _, env, err := h.executor(ctx, req) diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index fd2ad635b..ce56b56a5 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -6,12 +6,15 @@ package agenthostqualify import ( + "archive/zip" "bytes" "context" "crypto/rand" + "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/hex" "encoding/json" "encoding/pem" "errors" @@ -130,9 +133,10 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { // search runs a Turn in a new Executor without native history that finds the // function, deferred, with tool search. A view that declares environment none // answers a Turn in a Session without an Environment, and its native state -// names the work directory. Every view calls a tool of a stdio MCP server -// that runs in the sandbox. Each Executor after the first reopens the -// Environment that runtime_prepare prepared. +// names the work directory. In a Session whose Environment installs a +// plugin, every view uses the plugin's Skill in one Turn and calls a tool of +// its stdio MCP server, which runs in the sandbox, in another. Each Executor +// after the first reopens the Environment that runtime_prepare prepared. func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, caps proto.AgentKindCapabilities, model proto.PromptRequestPayload) { name := "qualify-" + kind + ".txt" value, content := strings.ToLower(rand.Text()), "qualified "+strings.ToLower(rand.Text()[:12]) @@ -156,7 +160,7 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, sb.reset(t, cfg) s := sb.session(h, cfg, configuration) setup := "setup-" + kind + ".txt" - s.prepare(t, + s.prepare(t, nil, proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"QUALIFY_VALUE": value, "QUALIFY_EXIT": fmt.Sprint(exit)}}, proto.RuntimeInitialization{Action: "setup", Command: `printf '%s' "$QUALIFY_VALUE" > ` + setup}) if got := sb.read(t, cfg, workspace+"/"+setup); got != value { @@ -202,18 +206,54 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, s.checkCwd(t, agent.ViewPrivateRoot+"/"+agent.ViewHomeName+"/"+agent.ViewWorkName) } { - code := strings.ToLower(rand.Text()[:12]) - stdio := configuration - stdio.FunctionTools, stdio.ToolSearch = nil, false - s := s.with(stdio) - s.mcp = []proto.EnvironmentMCP{{InstallationRoot: workspace, Server: agentplugin.MCPServer{Name: "qualify", Type: "stdio", Command: "python3", Args: []string{"-c", mcpServer, code}}}} - _, answer, _ := s.turn(t, "stdio-mcp", "Call the reveal_code tool of the qualify MCP server once.\nAnswer with exactly one line: CODE=", k) + // Claude does not combine Skills with tool search. + word, code := strings.ToLower(rand.Text()[:12]), strings.ToLower(rand.Text()[:12]) + installed, local := configuration, *configuration.LocalEnvironment + installed.FunctionTools, installed.ToolSearch = nil, false + local.Capabilities, local.CapabilitySources = true, &agentcapabilities.Input{Plugins: []agentplugin.Metadata{qualifyPlugin}} + installed.LocalEnvironment = &local + sb.reset(t, cfg) + s := sb.session(h, cfg, installed) + s.prepare(t, pluginArchive(t, word, code)) + _, answer, _ := s.turn(t, "skill", "Use the qualify-word skill.\nAnswer with exactly one line: WORD=", k) + if !strings.Contains(answer, "WORD="+word) { + t.Errorf("the answer %q does not report WORD=%s", answer, word) + } + _, answer, _ = s.turn(t, "stdio-mcp", "Call the reveal_code tool of the qualify MCP server once.\nAnswer with exactly one line: CODE=", k) if !strings.Contains(answer, "CODE="+code) { t.Errorf("the answer %q does not report CODE=%s", answer, code) } } } +var qualifyPlugin = agentplugin.Metadata{Type: "inline", Name: "qualify", Description: "Qualification plugin."} + +// pluginArchive is qualifyPlugin's archive. Its Skill tells word, and its +// stdio MCP server runs mcpServer from the package with code. +func pluginArchive(t *testing.T, word, code string) []byte { + t.Helper() + var b bytes.Buffer + w := zip.NewWriter(&b) + for name, body := range map[string]string{ + ".codex-plugin/plugin.json": `{"name":"qualify","description":"Qualification plugin.","skills":"./skills/"}`, + ".mcp.json": `{"mcpServers":{"qualify":{"command":"python3","args":["server.py","` + code + `"]}}}`, + "server.py": mcpServer, + "skills/qualify-word/SKILL.md": "---\nname: qualify-word\ndescription: Tells the qualification word.\n---\nThe qualification word is " + word + ".\n", + } { + f, err := w.Create("qualify/" + name) + if err == nil { + _, err = f.Write([]byte(body)) + } + if err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + // mcpServer is a stdio MCP server whose one tool returns the code in its // argument. const mcpServer = `import json, sys @@ -287,9 +327,6 @@ type session struct { binding agenthost.Binding id string configuration proto.PromptRequestPayload - // mcp is the installed MCP that the Environment's preparation resolves - // into each request; the wire does not carry it. - mcp []proto.EnvironmentMCP } func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, configuration proto.PromptRequestPayload) *session { @@ -303,7 +340,7 @@ func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, configuratio // with is the Session with configuration for its next Executors. func (s *session) with(configuration proto.PromptRequestPayload) *session { next := *s - next.configuration, next.mcp = configuration, nil + next.configuration = configuration next.configuration.AgentStateKey = s.configuration.AgentStateKey return &next } @@ -312,11 +349,7 @@ func (s *session) with(configuration proto.PromptRequestPayload) *session { // Session bound to it. func (s *session) router(t *testing.T, out sender, id string) (*dispatch.Router, proto.AssignmentRef) { t.Helper() - reg := s.h.Registry() - if s.mcp != nil { - reg = withMCP(t, reg, s.mcp) - } - router, err := dispatch.New(dispatch.Config{Sender: out, Environments: s.h.Environments, Log: s.cfg.Log, Registry: reg}) + router, err := dispatch.New(dispatch.Config{Sender: out, Environments: s.h.Environments, Log: s.cfg.Log, Registry: s.h.Registry()}) if err != nil { t.Fatal(err) } @@ -337,30 +370,47 @@ func (s *session) router(t *testing.T, out sender, id string) (*dispatch.Router, } // prepare applies each step to the Session's Environment with -// runtime_prepare, then the empty selection's finalize. -func (s *session) prepare(t *testing.T, steps ...proto.RuntimeInitialization) { +// runtime_prepare, installs plugin, the archive of the selection's one plugin, +// unless it is nil, and finalizes the selection. +func (s *session) prepare(t *testing.T, plugin []byte, steps ...proto.RuntimeInitialization) { t.Helper() out := make(sender, 64) router, ref := s.router(t, out, uuid.NewString()) defer router.Shutdown(context.Background()) - transfer := func(p proto.RuntimePreparePayload) { + transfer := func(p proto.RuntimePreparePayload, data []byte) { p.Step, p.EnvironmentID, p.SessionID = "begin", s.configuration.EnvironmentID(), s.id + frames := []proto.RuntimePreparePayload{p} + if data != nil { + digest := sha256.Sum256(data) + frames[0].SizeBytes, frames[0].SHA256 = len(data), hex.EncodeToString(digest[:]) + frames = append(frames, proto.RuntimePreparePayload{Step: "chunk", Data: data}) + } id := uuid.NewString() - for i, step := range []proto.RuntimePreparePayload{p, {Step: "commit"}} { + for i, step := range append(frames, proto.RuntimePreparePayload{Step: "commit"}) { handle(t, router, ref, proto.TypeRuntimePrepare, id, step) var result proto.RuntimePrepareResultPayload if err := out.next(t, id, time.After(turnLimit)).DecodePayload(&result); err != nil { t.Fatal(err) } - if want := []string{"ready", "completed"}[i]; result.Outcome != want { + want := "received" + if i == 0 { + want = "ready" + } else if i == len(frames) { + want = "completed" + } + if result.Outcome != want { t.Fatalf("runtime_prepare %s %s: %+v, want %s", p.Action, step.Step, result, want) } } } for _, step := range steps { - transfer(proto.RuntimePreparePayload{Action: "initialize", Initialization: &step}) + transfer(proto.RuntimePreparePayload{Action: "initialize", Initialization: &step}, nil) + } + sources := s.configuration.LocalEnvironment.CapabilitySources + if plugin != nil { + transfer(proto.RuntimePreparePayload{Action: "plugin", Plugin: &sources.Plugins[0]}, plugin) } - transfer(proto.RuntimePreparePayload{Action: "finalize", Sources: s.configuration.LocalEnvironment.CapabilitySources}) + transfer(proto.RuntimePreparePayload{Action: "finalize", Sources: sources}, nil) } // turn binds the Session's assignment, prepares an Executor of the Session, @@ -388,26 +438,6 @@ func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePa return done, answer, calls } -// withMCP wraps reg so that each request's Environment carries mcp. -func withMCP(t *testing.T, reg *agent.Registry, mcp []proto.EnvironmentMCP) *agent.Registry { - wrapped := agent.NewRegistry() - for _, info := range reg.SupportedAgentKinds() { - configuration, err := reg.Configuration(info.Kind) - factory, factoryErr := reg.ResolveExecutor(info.Kind) - if err := errors.Join(err, factoryErr); err != nil { - t.Fatal(err) - } - wrapped.RegisterKind(info, configuration) - wrapped.RegisterExecutor(info.Kind, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - local := *req.LocalEnvironment - local.MCP = mcp - req.LocalEnvironment = &local - return factory(ctx, req) - }) - } - return wrapped -} - // checkCwd checks that the Harness's native state in the Session home names // cwd, which the adapter writes into none of its files there. func (s *session) checkCwd(t *testing.T, cwd string) { diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 308654fc1..00b1e6254 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -284,7 +284,7 @@ An agent host runs the Harness outside the sandbox, in a per-Session view. The v ### Capabilities -A view runs every request that the kind's declaration admits, so the adapter declares only what both its local Executor and its view run, and dispatch checks each request against that declaration. The agent host serves a local Environment and environment none, and every view runs [stdio MCP](#stdio-mcp). Whatever the kind declares, the agent host rejects with `ErrUnsupportedOperation` a request with Skills, 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`. +A view runs every request that the kind's declaration admits, so the adapter declares only what both its local Executor and its view run, and dispatch checks each request against that declaration. The agent host serves a local Environment and environment none, and every view runs the Environment's installed Skills and [stdio MCP](#stdio-mcp). The Environment owner fills `LocalEnvironment.Skills` and `CapabilityRoot` as sandbox paths, and the adapter hands them to its Harness as a local Executor does; only the Harness reads them, through the view, and the adapter opens none of them on the agent host. Whatever the kind declares, the agent host rejects with `ErrUnsupportedOperation` a request 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 @@ -351,9 +351,9 @@ 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. | -| Declaration | Each declared feature runs a Turn through dispatch: environment none in the empty-root view, function calls and results, tool search, and each stdio binding under its alias. | +| Declaration | Each declared feature runs a Turn through dispatch: environment none in the empty-root view, function calls and results, tool search, an installed Skill, and each stdio binding under its alias. | -`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. Each Session's Environment is prepared through `runtime_prepare` as Core prepares it: a configure step freezes the tool environment, and a setup step writes a file with one of its values, which the test checks; every later Executor of the Session reopens that preparation. 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 kind 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 kind declares tool search, a Turn in a new Executor without native history finds the deferred function with tool search and calls it. When the kind declares environment none, a Turn in a Session without an Environment answers through the model, and the Harness's native state in the Session home must name its working directory, `/.oac/home/work`. The test gives a Session's Environment one installed stdio MCP server, a script that runs in the sandbox, and the answer must report the code its one tool returns. 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. Each Session's Environment is prepared through `runtime_prepare` as Core prepares it: a configure step freezes the tool environment, and a setup step writes a file with one of its values, which the test checks; every later Executor of the Session reopens that preparation. 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 kind 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 kind declares tool search, a Turn in a new Executor without native history finds the deferred function with tool search and calls it. When the kind declares environment none, a Turn in a Session without an Environment answers through the model, and the Harness's native state in the Session home must name its working directory, `/.oac/home/work`. In another Session, `runtime_prepare` installs a plugin with one Skill and one stdio MCP server, a script in the plugin that runs in the sandbox. One Turn uses the Skill and must report the word that only its `SKILL.md` holds, and a Turn in a new Executor calls the server's one tool and must report the code it returns. 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 a6aab70cc..a48b76f52 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "添加 Harness" source: contracts/agents-api/harness-onboarding.md -source_hash: 4142f7fe1dba17a09e6cf3d528edb445522b2bf16e8c5f646491cc5ee32ea49f +source_hash: 1951ed3ead52e659e96b8b341dd0d6bbacb32d8eb564425591f8f4307b038b67 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -286,7 +286,7 @@ agent host 在沙箱之外、在每个 Session 一个的视图中运行 Harness ### 能力 {#capabilities} -视图运行该 kind 的声明所准入的每个请求,因此 adapter 只声明其本地 Executor 和视图都能运行的内容,dispatch 按该声明检查每个请求。agent host 提供本地 Environment 和 environment none,且每个视图都运行 [stdio MCP](#stdio-mcp)。无论 kind 如何声明,agent host 都以 `ErrUnsupportedOperation` 拒绝带 Skills 的请求、已安装的 Capabilities 未经任何准备解析的请求,以及带受限网络的请求,因为只有 Provider 的工作负载网络边界才能约束进程自己的 socket。它以 `ErrViewHandoff` 拒绝需要凭据的 stdio 绑定。 +视图运行该 kind 的声明所准入的每个请求,因此 adapter 只声明其本地 Executor 和视图都能运行的内容,dispatch 按该声明检查每个请求。agent host 提供本地 Environment 和 environment none,且每个视图都运行 Environment 已安装的 Skills 和 [stdio MCP](#stdio-mcp)。Environment owner 以沙箱路径填写 `LocalEnvironment.Skills` 和 `CapabilityRoot`,adapter 像本地 Executor 那样把它们交给 Harness;只有 Harness 通过视图读取它们,adapter 不在 agent host 上打开其中任何路径。无论 kind 如何声明,agent host 都以 `ErrUnsupportedOperation` 拒绝已安装的 Capabilities 未经任何准备解析的请求,以及带受限网络的请求,因为只有 Provider 的工作负载网络边界才能约束进程自己的 socket。它以 `ErrViewHandoff` 拒绝需要凭据的 stdio 绑定。 ### Environment none {#environment-none} @@ -353,9 +353,9 @@ stdio 绑定在沙箱中以其别名运行。`ViewSession.MCP` 中索引为 `i` | `ForwardEnv` | 在沙箱中运行的进程保留每个声明的变量,且不保留任何其他 Harness 变量。 | | `Proxy` | 使用 `ViewProxyEnv` 时,每个本地请求(例如网页抓取、下载和更新检查)都经过代理。使用 `ViewProxyNone` 时,启用需要代理的功能的请求会被拒绝。 | | `Home` | 原生历史和配置保存在 `/.oac/home` 下,同一 Session 中后续的 Executor 从中继续。 | -| 声明 | 每项声明的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、函数调用及其结果、工具搜索,以及每个以别名运行的 stdio 绑定。 | +| 声明 | 每项声明的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、函数调用及其结果、工具搜索、一个已安装的 Skill,以及每个以别名运行的 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 运行,沙箱镜像提供沙箱。每个 Session 的 Environment 像 Core 那样通过 `runtime_prepare` 准备:configure 步骤冻结工具环境,setup 步骤写入一个文件,内容是工具环境中的一个值,由测试检查;Session 之后的每个 Executor 都重新打开这次准备。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。kind 声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。kind 声明工具搜索时,一个没有原生历史的新 Executor 中的 Turn 用工具搜索找到延迟加载的函数并调用它。kind 声明 environment none 时,一个没有 Environment 的 Session 中的 Turn 通过模型作答,且 Session home 中 Harness 的原生状态必须写明其工作目录 `/.oac/home/work`。测试为一个 Session 的 Environment 提供一个已安装的 stdio MCP 服务器,即在沙箱中运行的脚本,回答必须报告其唯一工具返回的代码。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 运行,沙箱镜像提供沙箱。每个 Session 的 Environment 像 Core 那样通过 `runtime_prepare` 准备:configure 步骤冻结工具环境,setup 步骤写入一个文件,内容是工具环境中的一个值,由测试检查;Session 之后的每个 Executor 都重新打开这次准备。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。kind 声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。kind 声明工具搜索时,一个没有原生历史的新 Executor 中的 Turn 用工具搜索找到延迟加载的函数并调用它。kind 声明 environment none 时,一个没有 Environment 的 Session 中的 Turn 通过模型作答,且 Session home 中 Harness 的原生状态必须写明其工作目录 `/.oac/home/work`。在另一个 Session 中,`runtime_prepare` 安装一个 plugin,其中有一个 Skill 和一个 stdio MCP 服务器,即 plugin 中在沙箱里运行的脚本。一个 Turn 使用该 Skill,必须报告只有其 `SKILL.md` 包含的词;新 Executor 中的一个 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}