From 11dac94cd92230cbbdd4cf08d677feea0d26f0d5 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 07:49:00 +0000 Subject: [PATCH 1/3] Delete the guest Runtime daemon Relocate the rules the agent host and launcher share with the guest: the workspace path rule to proto, a Skill's root to agentcapabilities, the export bounds and setup grace to agenthost, and the private-file reader beside executorCredential. --- apps/daemon/internal/agent/codex/skills.go | 3 +- apps/daemon/internal/agent/mcode/options.go | 3 +- .../internal/agenthost/environment_linux.go | 34 ++++++++++++------- apps/daemon/internal/agenthost/setup_linux.go | 7 ++-- apps/daemon/internal/agenthost/world_linux.go | 4 +-- .../internal/cli/connect_environment.go | 5 +++ .../cli/connect_environment_binding.go | 6 +--- .../daemon/internal/localworkspace/binding.go | 2 +- .../internal/localworkspace/directory.go | 2 +- .../internal/localworkspace/native_files.go | 9 +---- apps/daemon/internal/localworkspace/skills.go | 13 ------- .../internal/localworkspace/skills_test.go | 15 -------- apps/daemon/internal/localworkspace/write.go | 2 +- internal/agentcapabilities/manifest.go | 4 +++ internal/agentdaemon/proto/workspace_write.go | 9 ++++- 15 files changed, 52 insertions(+), 66 deletions(-) delete mode 100644 apps/daemon/internal/localworkspace/skills.go delete mode 100644 apps/daemon/internal/localworkspace/skills_test.go diff --git a/apps/daemon/internal/agent/codex/skills.go b/apps/daemon/internal/agent/codex/skills.go index 56acabf64..de5767a49 100644 --- a/apps/daemon/internal/agent/codex/skills.go +++ b/apps/daemon/internal/agent/codex/skills.go @@ -8,7 +8,6 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" ) @@ -21,7 +20,7 @@ import ( 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)) + roots = append(roots, skill.Root()) } if _, err := rpc.Request(ctx, "skills/extraRoots/set", SkillsExtraRootsSetParams{ExtraRoots: roots}); err != nil { return fmt.Errorf("codex: register skill roots: %w", err) diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 93f4f14c5..ad2f72ca2 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -12,7 +12,6 @@ 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" ) @@ -116,7 +115,7 @@ func writeNativeConfig(req agent.PrepareRequest, data *os.Root, dataDir string, } names := make([]string, 0, len(tools.skills)) for _, skill := range tools.skills { - link, target := filepath.Join("skills", skill.Metadata.Name), localworkspace.SkillPath(skill) + link, target := filepath.Join("skills", skill.Metadata.Name), skill.Root() actual, err := data.Readlink(link) switch { case errors.Is(err, fs.ErrNotExist): diff --git a/apps/daemon/internal/agenthost/environment_linux.go b/apps/daemon/internal/agenthost/environment_linux.go index 9c14f99ef..c9e4533a5 100644 --- a/apps/daemon/internal/agenthost/environment_linux.go +++ b/apps/daemon/internal/agenthost/environment_linux.go @@ -23,7 +23,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -460,7 +459,7 @@ func checkPluginCredentials(tree agentcapabilities.Tree) error { func (o *environment) installFile(ctx context.Context, w *world, target string, data []byte) error { relative, ok := strings.CutPrefix(target, sandboxWorkspace+"/") - if !ok || !localworkspace.ValidPath(relative) || len(data) > proto.RuntimePrepareMaxBytes { + if !ok || !proto.ValidWorkspacePath(relative) || len(data) > proto.RuntimePrepareMaxBytes { return agentcapabilities.ErrInvalid } workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) @@ -490,7 +489,7 @@ func (o *environment) initialize(ctx context.Context, w *world, initialization s cwd := sandboxWorkspace if input.CWD != "" && input.CWD != sandboxWorkspace { relative, ok := strings.CutPrefix(input.CWD, sandboxWorkspace+"/") - if !ok || !localworkspace.ValidPath(relative) { + if !ok || !proto.ValidWorkspacePath(relative) { return agentcapabilities.ErrInvalid } cwd = input.CWD @@ -576,7 +575,7 @@ func (o *environment) ListWorkspaceDirectory(ctx context.Context, p string, limi switch { case o.id == "": return result, dispatch.ErrWorkspaceReadUnavailable - case p != "" && !localworkspace.ValidPath(p) || limit < 1: + case p != "" && !proto.ValidWorkspacePath(p) || limit < 1: return result, dispatch.ErrWorkspaceReadInvalid } if err := o.acquire(ctx); err != nil { @@ -629,7 +628,7 @@ func (o *environment) ListWorkspaceDirectory(ctx context.Context, p string, limi func (o *environment) WriteWorkspaceFile(ctx context.Context, p string, data []byte) (dispatch.WorkspaceWriteResult, error) { var result dispatch.WorkspaceWriteResult switch { - case len(data) > proto.WorkspaceWriteMaxBytes || !localworkspace.ValidPath(p): + case len(data) > proto.WorkspaceWriteMaxBytes || !proto.ValidWorkspacePath(p): return result, dispatch.ErrWorkspaceWriteInvalid case o.id == "": return result, dispatch.ErrEnvironmentUnavailable @@ -671,8 +670,8 @@ func (o *environment) WriteWorkspaceFile(ctx context.Context, p string, data []b } // ExportOutputs writes the workspace's outputs directory to out as a tar -// stream, as the guest does: regular files and directories, without -// symbolic links, within the guest's bounds. +// stream: regular files and directories, without symbolic links, within the +// export bounds. func (o *environment) ExportOutputs(ctx context.Context, out io.Writer) error { if o.id == "" { return errors.New("the Session has no Environment") @@ -707,7 +706,16 @@ func (o *environment) ExportOutputs(ctx context.Context, out io.Writer) error { return archive.Close() } -// export is the guest's output export over File; PR6 deletes the guest copy. +// The bounds of an output export: each file's bytes, the export's bytes, its +// entries and its directory depth. +const ( + exportFileBytes int64 = 200 << 20 + exportBatchBytes int64 = 500 << 20 + exportEntries = 4096 + exportDepth = 64 +) + +// export walks the outputs directory over File into a tar archive. type export struct { w *world archive *tar.Writer @@ -716,19 +724,19 @@ type export struct { } func (x *export) walk(ctx context.Context, dir sandboxfs.NodeRef, name string, depth int) error { - if depth > localworkspace.ExportDepth { + if depth > exportDepth { return errors.New("workspace export exceeds traversal bound") } - entries, err := x.w.sorted(ctx, dir, localworkspace.ExportEntries) + entries, err := x.w.sorted(ctx, dir, exportEntries) if err != nil { return err } - if x.entries += len(entries); x.entries > localworkspace.ExportEntries { + if x.entries += len(entries); x.entries > exportEntries { return errors.New("workspace export exceeds entry bound") } for _, e := range entries { child := name + "/" + string(e.Name) - if !localworkspace.ValidPath(child) { + if !proto.ValidWorkspacePath(child) { return fs.ErrInvalid } switch e.Entry.Attr.Mode & sandboxfs.ModeType { @@ -759,7 +767,7 @@ func (x *export) append(ctx context.Context, e sandboxfs.Entry, name string) err return err } size := int64(before.Attr.Size) - if !isType(before.Attr, sandboxfs.ModeRegular) || size > localworkspace.ExportFileBytes || size > localworkspace.ExportBatchBytes-x.bytes { + if !isType(before.Attr, sandboxfs.ModeRegular) || size > exportFileBytes || size > exportBatchBytes-x.bytes { return errors.New("workspace export exceeds file bound") } x.bytes += size diff --git a/apps/daemon/internal/agenthost/setup_linux.go b/apps/daemon/internal/agenthost/setup_linux.go index 8a00ff3cf..7ed4930eb 100644 --- a/apps/daemon/internal/agenthost/setup_linux.go +++ b/apps/daemon/internal/agenthost/setup_linux.go @@ -11,13 +11,16 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" sp "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" ) +// initializationGrace is how long a cancelled setup step may run before it is +// killed. +const initializationGrace = 250 * time.Millisecond + // strongestScope is the strongest process scope caps declare. func strongestScope(caps sp.Capabilities) (sp.Scope, error) { for _, scope := range []sp.Scope{sp.ScopeCgroupV2, sp.ScopePOSIXSession} { @@ -99,7 +102,7 @@ func (o *environment) run(ctx context.Context, id sandboxwire.ID, program string } case <-cancelled: cancelled = nil - if err := op.Cancel(late, uint32(localworkspace.InitializationGrace/time.Millisecond)); err != nil { + if err := op.Cancel(late, uint32(initializationGrace/time.Millisecond)); err != nil { return o.lose(err) } case <-late.Done(): diff --git a/apps/daemon/internal/agenthost/world_linux.go b/apps/daemon/internal/agenthost/world_linux.go index fa510f2b0..59f0b9fd7 100644 --- a/apps/daemon/internal/agenthost/world_linux.go +++ b/apps/daemon/internal/agenthost/world_linux.go @@ -11,8 +11,8 @@ import ( "slices" "strings" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" "github.com/google/uuid" @@ -131,7 +131,7 @@ func components(p string) ([]string, error) { if p = strings.Trim(p, "/"); p == "" || p == "." { return nil, nil } - if !localworkspace.ValidPath(p) { + if !proto.ValidWorkspacePath(p) { return nil, fs.ErrInvalid } return strings.Split(p, "/"), nil diff --git a/apps/daemon/internal/cli/connect_environment.go b/apps/daemon/internal/cli/connect_environment.go index cd2c0813b..423fd35ca 100644 --- a/apps/daemon/internal/cli/connect_environment.go +++ b/apps/daemon/internal/cli/connect_environment.go @@ -16,6 +16,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "github.com/google/uuid" ) @@ -71,6 +72,10 @@ func executorCredential(path, environment string) (string, string, error) { return key.KeyID, key.Token, nil } +func readEnvironmentPrivateFile(path string) ([]byte, error) { + return runtimefs.ReadPrivatePath(path, 16*1024) +} + func decodeEnvironmentJSON(raw []byte, value any) error { decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.DisallowUnknownFields() diff --git a/apps/daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go index b661092e0..6bee17eb5 100644 --- a/apps/daemon/internal/cli/connect_environment_binding.go +++ b/apps/daemon/internal/cli/connect_environment_binding.go @@ -37,10 +37,6 @@ func checkEnvironmentTarget(remote, environment string) error { return nil } -func readEnvironmentPrivateFile(path string) ([]byte, error) { - return runtimefs.ReadPrivatePath(path, 16*1024) -} - func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credentialFile string) error { root, err := paths.Root() if err != nil || !filepath.IsAbs(root) { @@ -74,7 +70,7 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti } capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") if capabilityDirectory == "" { - capabilityDirectory = localworkspace.CapabilityDirectory + capabilityDirectory = agentcapabilities.Directory } if _, err := localworkspace.NewWithCapabilityDirectory(bound.EnvironmentID, bound.SessionID, workspace, capabilityDirectory); err != nil { return errors.New("connect: local Runtime layout unavailable") diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index e0ca04e05..e5b67cd90 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -42,7 +42,7 @@ func Load() (*Binding, error) { return nil, nil } if capabilityDirectory == "" { - capabilityDirectory = CapabilityDirectory + capabilityDirectory = agentcapabilities.Directory } b, err := NewWithCapabilityDirectory(values[0], values[1], values[2], capabilityDirectory) if err != nil { diff --git a/apps/daemon/internal/localworkspace/directory.go b/apps/daemon/internal/localworkspace/directory.go index 80b0594e8..a1cc7bb41 100644 --- a/apps/daemon/internal/localworkspace/directory.go +++ b/apps/daemon/internal/localworkspace/directory.go @@ -8,7 +8,7 @@ import ( ) func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit int) (dispatch.WorkspaceDirectoryResult, error) { - if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || path != "" && !ValidPath(path) { + if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || path != "" && !proto.ValidWorkspacePath(path) { return dispatch.WorkspaceDirectoryResult{}, dispatch.ErrWorkspaceReadInvalid } return b.listNativeDirectory(ctx, path, limit) diff --git a/apps/daemon/internal/localworkspace/native_files.go b/apps/daemon/internal/localworkspace/native_files.go index 832818445..5c23cb49b 100644 --- a/apps/daemon/internal/localworkspace/native_files.go +++ b/apps/daemon/internal/localworkspace/native_files.go @@ -16,20 +16,13 @@ import ( "github.com/google/uuid" ) -// ValidPath reports whether p is a workspace path as the API addresses it: -// slash-separated plain names below the workspace, without a backslash, NUL, -// CR or LF. -func ValidPath(p string) bool { - return p != "." && len(p) <= 4096 && fs.ValidPath(p) && !strings.ContainsAny(p, "\\\x00\r\n") -} - // Logical API paths stay slash-separated on every host. os.Root anchors API // file operations to the selected workspace; it does not constrain native tools. func nativeAPIPath(path string) (string, error) { if path == "" { return ".", nil } - if !ValidPath(path) { + if !proto.ValidWorkspacePath(path) { return "", fs.ErrInvalid } return filepath.Localize(path) diff --git a/apps/daemon/internal/localworkspace/skills.go b/apps/daemon/internal/localworkspace/skills.go deleted file mode 100644 index f6bbf4d98..000000000 --- a/apps/daemon/internal/localworkspace/skills.go +++ /dev/null @@ -1,13 +0,0 @@ -package localworkspace - -import ( - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" -) - -const CapabilityDirectory = agentcapabilities.Directory - -func SkillPath(skill agentcapabilities.InstalledSkill) string { - return filepath.Join(skill.InstallationRoot, skill.RelativeRoot) -} diff --git a/apps/daemon/internal/localworkspace/skills_test.go b/apps/daemon/internal/localworkspace/skills_test.go deleted file mode 100644 index be37c0358..000000000 --- a/apps/daemon/internal/localworkspace/skills_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package localworkspace - -import ( - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "path/filepath" - "testing" -) - -func TestSkillPathUsesRuntimeInstallationRoot(t *testing.T) { - root := t.TempDir() - skill := agentcapabilities.InstalledSkill{InstallationRoot: root, RelativeRoot: "plugins/0/skills/proof"} - if got := SkillPath(skill); got != filepath.Join(root, "plugins/0/skills/proof") { - t.Fatal("Runtime root lost", got) - } -} diff --git a/apps/daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go index 87e921c21..ba9924c14 100644 --- a/apps/daemon/internal/localworkspace/write.go +++ b/apps/daemon/internal/localworkspace/write.go @@ -14,7 +14,7 @@ const WriteMaxBytes = proto.WorkspaceWriteMaxBytes // WriteWorkspaceFile starts only after the caller supplies the complete bounded // body. Core must persist mutation ownership before invoking this operation. func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []byte) (result dispatch.WorkspaceWriteResult, err error) { - if len(data) > WriteMaxBytes || !ValidPath(path) { + if len(data) > WriteMaxBytes || !proto.ValidWorkspacePath(path) { return result, dispatch.ErrWorkspaceWriteInvalid } if ctx.Err() != nil { diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index cfe35d80f..5928962a4 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "io/fs" + "path" "strings" "unicode/utf8" @@ -34,6 +35,9 @@ type InstalledSkill struct { PackageRoot string `json:"package_root"` } +// Root is the Skill's directory as the Harness sees it. +func (s InstalledSkill) Root() string { return path.Join(s.InstallationRoot, s.RelativeRoot) } + // Identity binds an installation to one immutable Environment and Session. type Identity struct { EnvironmentID string `json:"environment_id"` diff --git a/internal/agentdaemon/proto/workspace_write.go b/internal/agentdaemon/proto/workspace_write.go index 13a594e30..069ea3a29 100644 --- a/internal/agentdaemon/proto/workspace_write.go +++ b/internal/agentdaemon/proto/workspace_write.go @@ -45,6 +45,13 @@ const ( WorkspaceWriteReasonUnsafe = "unsafe_destination" ) +// ValidWorkspacePath reports whether p is a workspace path as the API addresses +// it: at most 4096 bytes of slash-separated plain names below the workspace, +// without a backslash, NUL, CR or LF. +func ValidWorkspacePath(p string) bool { + return p != "." && len(p) <= 4096 && fs.ValidPath(p) && !strings.ContainsAny(p, "\\\x00\r\n") +} + func ValidWorkspaceWriteRequest(p WorkspaceWritePayload) bool { if p.Step == "begin" { for _, id := range []string{p.EnvironmentID, p.SessionID} { @@ -56,7 +63,7 @@ func ValidWorkspaceWriteRequest(p WorkspaceWritePayload) bool { digest, err := hex.DecodeString(p.SHA256) return err == nil && len(digest) == 32 && strings.ToLower(p.SHA256) == p.SHA256 && p.SizeBytes >= 0 && p.SizeBytes <= WorkspaceWriteMaxBytes && p.Offset == 0 && len(p.Data) == 0 && - len(p.Path) <= 4096 && p.Path != "." && fs.ValidPath(p.Path) && !strings.ContainsAny(p.Path, "\\\x00\r\n") + ValidWorkspacePath(p.Path) } if p.EnvironmentID != "" || p.SessionID != "" || p.Path != "" || p.SizeBytes != 0 || p.SHA256 != "" { return false From 49269f0427eb2880cfa558426f506e5a2588a844 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 08:43:24 +0000 Subject: [PATCH 2/3] Delete the guest Runtime daemon The agent host is the only Runtime: every Harness runs in a view of the Session's sandbox, and the sandbox serves only Sandbox I/O. - cli: delete connect, resume, runtime-mcp-exec, status, logout and the guest suspend control, with the auth, placement and localworkspace packages. Keep install, start, agent-host, stop, logs and version. - agent: Runtime is {Info, View}; Register composes the declaration and registers the kind and its view; discovery takes only the diagnostic writers. - adapters: delete the local Executor factories, workspace and tool environment paths and the HTTP MCP bearer rendering the gateway rule already forbids in a view. - dispatch: delete Router.Quiesce and Resume; suspension arrives only as environment_quiesce and environment_resume frames. - claude-sdk-adapter and mcode-harness: delete tool_env, runtime MCP exec and the guest launcher branches. - Docs and CI: describe the agent-host model in the Harness, Runtime protocol and Environment contracts, and narrow the native CI steps to the code that remains. --- .github/workflows/native.yml | 21 +- apps/daemon/internal/agent/binpath/binpath.go | 2 +- .../claudesdk/cancellation_live_linux_test.go | 162 ------- .../agent/claudesdk/cancellation_test.go | 13 +- .../agent/claudesdk/commands_session_test.go | 8 +- .../internal/agent/claudesdk/declaration.go | 57 +-- .../agent/claudesdk/declaration_test.go | 47 +- .../claudesdk/error_classification_test.go | 5 +- .../claudesdk/execution_controls_test.go | 24 +- .../internal/agent/claudesdk/executor.go | 23 - .../claudesdk/executor_confirmation_test.go | 2 +- .../agent/claudesdk/executor_fixture_test.go | 43 +- .../claudesdk/executor_live_linux_test.go | 211 --------- .../internal/agent/claudesdk/executor_test.go | 17 +- .../agent/claudesdk/functions_test.go | 5 +- .../agent/claudesdk/harness_config_test.go | 6 +- .../agent/claudesdk/live_linux_test.go | 404 ----------------- apps/daemon/internal/agent/claudesdk/local.go | 38 -- .../internal/agent/claudesdk/local_test.go | 67 --- apps/daemon/internal/agent/claudesdk/mcp.go | 35 +- .../agent/claudesdk/mcp_bearer_test.go | 65 +-- .../agent/claudesdk/mcp_environment.go | 54 +-- .../agent/claudesdk/mcp_environment_test.go | 67 --- .../internal/agent/claudesdk/mcp_test.go | 6 +- .../internal/agent/claudesdk/messages_test.go | 5 +- .../internal/agent/claudesdk/options.go | 61 +-- .../internal/agent/claudesdk/options_test.go | 36 +- .../claudesdk/preparation_fixture_test.go | 27 +- .../agent/claudesdk/preparation_test.go | 22 +- .../internal/agent/claudesdk/provider.go | 16 - .../internal/agent/claudesdk/readiness.go | 10 +- .../agent/claudesdk/readiness_test.go | 31 +- .../agent/claudesdk/restrictions_test.go | 15 +- .../internal/agent/claudesdk/session.go | 12 +- .../internal/agent/claudesdk/session_test.go | 30 +- .../internal/agent/claudesdk/steering_test.go | 5 +- .../agent/claudesdk/subagents_test.go | 10 +- .../agent/claudesdk/tool_environment_test.go | 36 -- .../internal/agent/claudesdk/usage_test.go | 5 +- apps/daemon/internal/agent/claudesdk/view.go | 8 +- .../internal/agent/claudesdk/view_test.go | 8 +- .../internal/agent/claudesdk/workspace.go | 129 +----- .../workspace_commands_live_linux_test.go | 52 --- .../agent/claudesdk/workspace_launch_test.go | 73 ---- .../claudesdk/workspace_live_linux_test.go | 223 ---------- .../claudesdk/workspace_structured_test.go | 8 +- .../agent/claudesdk/workspace_test.go | 140 +----- .../internal/agent/codex/declaration.go | 1 - .../internal/agent/codex/declaration_test.go | 8 +- .../internal/agent/codex/environment.go | 19 - .../agent/codex/environment_retired_test.go | 71 --- .../agent/codex/execution_controls_test.go | 8 +- apps/daemon/internal/agent/codex/executor.go | 10 - .../agent/codex/executor_native_test.go | 15 +- .../agent/codex/harness_config_test.go | 3 +- .../daemon/internal/agent/codex/mcp_config.go | 33 +- .../internal/agent/codex/mcp_config_test.go | 19 +- .../agent/codex/mcp_environment_test.go | 61 +-- apps/daemon/internal/agent/codex/mcp_http.go | 41 +- .../agent/codex/mcp_http_bearer_test.go | 128 ------ .../agent/codex/mcp_http_preflight.go | 24 +- .../agent/codex/mcp_http_preflight_test.go | 24 +- .../internal/agent/codex/mcp_http_test.go | 73 +--- .../internal/agent/codex/mcp_required_test.go | 8 +- .../codex/model_catalog_command_unix_test.go | 2 +- .../internal/agent/codex/model_verbosity.go | 9 +- .../agent/codex/model_verbosity_test.go | 18 +- apps/daemon/internal/agent/codex/options.go | 70 +-- .../internal/agent/codex/options_test.go | 41 +- .../internal/agent/codex/preparation.go | 16 +- .../agent/codex/preparation_close_test.go | 16 + .../agent/codex/preparation_helpers_test.go | 49 ++- .../agent/codex/preparation_router_test.go | 34 +- .../agent/codex/provider_config_test.go | 6 +- .../internal/agent/codex/recovery_test.go | 31 +- apps/daemon/internal/agent/codex/session.go | 2 +- .../internal/agent/codex/session_plan.go | 70 --- .../internal/agent/codex/session_plan_test.go | 58 --- .../agent/codex/session_policy_test.go | 3 +- apps/daemon/internal/agent/codex/version.go | 5 +- apps/daemon/internal/agent/codex/view.go | 7 +- apps/daemon/internal/agent/harness.go | 32 +- .../internal/agent/mcode/declaration.go | 12 +- .../internal/agent/mcode/declaration_test.go | 8 +- .../agent/mcode/discovery_workspace.go | 74 ---- .../internal/agent/mcode/environment_mcp.go | 38 +- .../agent/mcode/environment_mcp_test.go | 217 ++++----- apps/daemon/internal/agent/mcode/execution.go | 4 - .../internal/agent/mcode/execution_test.go | 14 +- apps/daemon/internal/agent/mcode/executor.go | 22 - .../agent/mcode/executor_native_test.go | 13 +- .../internal/agent/mcode/executor_test.go | 70 +-- .../agent/mcode/mcp_observations_test.go | 3 +- .../agent/mcode/model_provider_test.go | 2 +- .../agent/mcode/native_history_test.go | 9 +- .../internal/agent/mcode/native_test.go | 29 +- apps/daemon/internal/agent/mcode/options.go | 102 +---- .../internal/agent/mcode/options_test.go | 30 +- apps/daemon/internal/agent/mcode/session.go | 9 +- .../internal/agent/mcode/session_test.go | 80 ++-- apps/daemon/internal/agent/mcode/subagents.go | 26 +- .../internal/agent/mcode/subagents_test.go | 18 +- .../agent/mcode/tool_environment_test.go | 128 ------ apps/daemon/internal/agent/mcode/view.go | 33 +- apps/daemon/internal/agent/mcode/workspace.go | 78 ---- .../agent/mcode/workspace_network_test.go | 13 - .../agent/mcode/workspace_readiness.go | 28 -- .../agent/mcode/workspace_readiness_test.go | 28 +- .../agent/mcode/workspace_skills_test.go | 4 +- .../daemon/internal/agent/mcp_binding_test.go | 26 ++ apps/daemon/internal/agent/registry_test.go | 10 +- .../agenthost/agenthost_linux_test.go | 13 + .../agenthost/environment_linux_test.go | 12 +- .../internal/agenthost/executor_linux.go | 3 +- .../agenthostqualify/qualify_linux_test.go | 2 +- apps/daemon/internal/auth/store.go | 74 ---- apps/daemon/internal/auth/store_test.go | 126 ------ apps/daemon/internal/cli/agent_discovery.go | 44 -- apps/daemon/internal/cli/agent_host_linux.go | 135 +++++- .../internal/cli/agent_host_linux_test.go | 189 ++++++++ .../daemon/internal/cli/agent_registration.go | 9 - .../cli/claude_sdk_live_linux_test.go | 215 --------- apps/daemon/internal/cli/connect.go | 391 ----------------- apps/daemon/internal/cli/connect_bootstrap.go | 22 - .../internal/cli/connect_bootstrap_test.go | 87 ---- apps/daemon/internal/cli/connect_cleanup.go | 24 - .../internal/cli/connect_cleanup_test.go | 223 ---------- .../internal/cli/connect_environment.go | 96 +--- .../cli/connect_environment_binding.go | 163 ------- .../cli/connect_environment_binding_test.go | 140 ------ .../cli/connect_environment_park_test.go | 92 ++-- .../internal/cli/connect_environment_test.go | 170 +------- apps/daemon/internal/cli/connect_suspend.go | 275 ------------ .../internal/cli/connect_suspend_test.go | 369 ---------------- apps/daemon/internal/cli/logout.go | 46 -- apps/daemon/internal/cli/logs.go | 19 +- .../internal/cli/native_discovery_test.go | 75 ---- apps/daemon/internal/cli/native_install.go | 8 - .../internal/cli/native_install_test.go | 25 -- .../daemon/internal/cli/native_start_linux.go | 66 ++- apps/daemon/internal/cli/placement.go | 69 --- apps/daemon/internal/cli/root.go | 21 +- apps/daemon/internal/cli/root_test.go | 18 +- apps/daemon/internal/cli/runtime_mcp.go | 113 ----- apps/daemon/internal/cli/runtime_mcp_test.go | 51 --- apps/daemon/internal/cli/runtime_mcp_unix.go | 22 - .../internal/cli/runtime_mcp_windows.go | 27 -- .../internal/cli/runtime_mcp_windows_test.go | 179 -------- apps/daemon/internal/cli/status.go | 63 --- apps/daemon/internal/cli/stop.go | 12 +- apps/daemon/internal/cli/suspend_control.go | 146 ------- .../cli/suspend_control_linux_test.go | 67 --- .../internal/cli/suspend_process_linux.go | 56 --- .../internal/cli/suspend_process_other.go | 38 -- apps/daemon/internal/daemonize/fork.go | 2 +- .../daemon/internal/daemonize/logfile_test.go | 2 +- .../internal/dispatch/environment_test.go | 152 ++++++- .../dispatch/functions_native_test.go | 229 ---------- .../internal/dispatch/local_directory_test.go | 24 +- .../internal/dispatch/preparation_test.go | 37 +- .../runtime_preparation_execution_test.go | 58 +-- .../dispatch/runtime_preparation_test.go | 2 +- apps/daemon/internal/dispatch/suspend.go | 32 -- apps/daemon/internal/dispatch/suspend_test.go | 144 +++--- .../dispatch/workspace_directory_test.go | 12 +- .../internal/dispatch/workspace_write_test.go | 53 +-- .../daemon/internal/localworkspace/binding.go | 93 ---- .../internal/localworkspace/binding_test.go | 76 ---- .../internal/localworkspace/capabilities.go | 203 --------- .../localworkspace/capabilities_test.go | 12 - .../capability_preparation_test.go | 216 --------- .../internal/localworkspace/directory.go | 15 - .../localworkspace/directory_native_test.go | 36 -- .../internal/localworkspace/export_test.go | 137 ------ .../internal/localworkspace/initialization.go | 129 ------ apps/daemon/internal/localworkspace/mcp.go | 29 -- .../internal/localworkspace/mcp_test.go | 43 -- .../internal/localworkspace/native_binding.go | 34 -- .../internal/localworkspace/native_files.go | 284 ------------ .../localworkspace/native_open_unix.go | 14 - .../localworkspace/native_open_unix_test.go | 69 --- .../localworkspace/native_open_windows.go | 7 - .../daemon/internal/localworkspace/network.go | 32 -- .../localworkspace/network_policy_test.go | 24 - .../localworkspace/package_command.go | 45 -- .../package_command_windows_test.go | 81 ---- .../localworkspace/runtime_initialization.go | 197 --------- .../runtime_initialization_process.go | 146 ------- .../runtime_initialization_test.go | 365 ---------------- .../localworkspace/snapshot_marker.go | 119 ----- .../localworkspace/snapshot_marker_test.go | 221 ---------- apps/daemon/internal/localworkspace/write.go | 34 -- .../internal/localworkspace/write_binding.go | 8 - .../internal/localworkspace/write_test.go | 80 ---- apps/daemon/internal/paths/paths.go | 67 +-- apps/daemon/internal/paths/paths_test.go | 97 +---- .../internal/placement/controller_linux.go | 211 --------- .../placement/controller_linux_test.go | 411 ------------------ .../internal/placement/controller_other.go | 24 - .../daemon/internal/placement/docker_linux.go | 164 ------- apps/daemon/internal/placement/environment.go | 38 -- .../placement/environment_linux_test.go | 198 --------- .../daemon/internal/placement/mounts_linux.go | 69 --- .../internal/placement/mounts_linux_test.go | 56 --- .../internal/placement/observe_linux.go | 123 ------ apps/daemon/internal/placement/state_linux.go | 161 ------- apps/daemon/internal/placement/types.go | 41 -- apps/daemon/internal/transport/bootstrap.go | 32 -- .../internal/transport/bootstrap_test.go | 43 -- contracts/agents-api/environments.md | 62 +-- contracts/agents-api/harness-onboarding.md | 33 +- contracts/agents-api/zh/environments.md | 64 +-- contracts/agents-api/zh/harness-onboarding.md | 35 +- docs/runtime-protocol.md | 8 +- docs/zh/runtime-protocol.md | 10 +- .../{root.go => root_test.go} | 4 +- internal/agentnetwork/policy.go | 5 - internal/agentnetwork/policy_test.go | 12 +- .../harnessconfig/builtin/selection_test.go | 2 +- .../harnessconfig/claudesdk/configuration.go | 2 +- internal/runtimefs/runtimefs.go | 17 - packages/claude-sdk-adapter/README.md | 26 +- packages/claude-sdk-adapter/src/mcp.ts | 24 +- .../claude-sdk-adapter/src/mcp_environment.ts | 16 +- packages/claude-sdk-adapter/src/workspace.ts | 12 +- .../claude-sdk-adapter/tests/mcp.test.mjs | 5 +- .../tests/mcp_bearer.test.mjs | 43 -- .../tests/mcp_workspace.test.mjs | 44 +- .../tests/workspace.test.mjs | 12 - packages/mcode-harness/README.md | 4 +- packages/mcode-harness/cancellation.test.mjs | 2 +- packages/mcode-harness/launch.mjs | 31 +- packages/mcode-harness/launch.test.mjs | 20 +- packages/mcode-harness/snapshot.test.mjs | 74 ---- scripts/name-allowlist.json | 5 - 235 files changed, 1662 insertions(+), 12623 deletions(-) delete mode 100644 apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go delete mode 100644 apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go delete mode 100644 apps/daemon/internal/agent/claudesdk/live_linux_test.go delete mode 100644 apps/daemon/internal/agent/claudesdk/local.go delete mode 100644 apps/daemon/internal/agent/claudesdk/local_test.go delete mode 100644 apps/daemon/internal/agent/claudesdk/provider.go delete mode 100644 apps/daemon/internal/agent/claudesdk/tool_environment_test.go delete mode 100644 apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go delete mode 100644 apps/daemon/internal/agent/claudesdk/workspace_launch_test.go delete mode 100644 apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go delete mode 100644 apps/daemon/internal/agent/codex/environment_retired_test.go delete mode 100644 apps/daemon/internal/agent/codex/mcp_http_bearer_test.go delete mode 100644 apps/daemon/internal/agent/codex/session_plan.go delete mode 100644 apps/daemon/internal/agent/codex/session_plan_test.go delete mode 100644 apps/daemon/internal/agent/mcode/discovery_workspace.go delete mode 100644 apps/daemon/internal/agent/mcode/tool_environment_test.go delete mode 100644 apps/daemon/internal/agent/mcode/workspace_network_test.go delete mode 100644 apps/daemon/internal/auth/store.go delete mode 100644 apps/daemon/internal/auth/store_test.go delete mode 100644 apps/daemon/internal/cli/agent_discovery.go delete mode 100644 apps/daemon/internal/cli/agent_registration.go delete mode 100644 apps/daemon/internal/cli/claude_sdk_live_linux_test.go delete mode 100644 apps/daemon/internal/cli/connect.go delete mode 100644 apps/daemon/internal/cli/connect_bootstrap.go delete mode 100644 apps/daemon/internal/cli/connect_bootstrap_test.go delete mode 100644 apps/daemon/internal/cli/connect_cleanup.go delete mode 100644 apps/daemon/internal/cli/connect_cleanup_test.go delete mode 100644 apps/daemon/internal/cli/connect_environment_binding.go delete mode 100644 apps/daemon/internal/cli/connect_environment_binding_test.go delete mode 100644 apps/daemon/internal/cli/connect_suspend.go delete mode 100644 apps/daemon/internal/cli/connect_suspend_test.go delete mode 100644 apps/daemon/internal/cli/logout.go delete mode 100644 apps/daemon/internal/cli/native_discovery_test.go delete mode 100644 apps/daemon/internal/cli/placement.go delete mode 100644 apps/daemon/internal/cli/runtime_mcp.go delete mode 100644 apps/daemon/internal/cli/runtime_mcp_test.go delete mode 100644 apps/daemon/internal/cli/runtime_mcp_unix.go delete mode 100644 apps/daemon/internal/cli/runtime_mcp_windows.go delete mode 100644 apps/daemon/internal/cli/runtime_mcp_windows_test.go delete mode 100644 apps/daemon/internal/cli/status.go delete mode 100644 apps/daemon/internal/cli/suspend_control.go delete mode 100644 apps/daemon/internal/cli/suspend_control_linux_test.go delete mode 100644 apps/daemon/internal/cli/suspend_process_linux.go delete mode 100644 apps/daemon/internal/cli/suspend_process_other.go delete mode 100644 apps/daemon/internal/dispatch/functions_native_test.go delete mode 100644 apps/daemon/internal/localworkspace/binding.go delete mode 100644 apps/daemon/internal/localworkspace/binding_test.go delete mode 100644 apps/daemon/internal/localworkspace/capabilities.go delete mode 100644 apps/daemon/internal/localworkspace/capabilities_test.go delete mode 100644 apps/daemon/internal/localworkspace/capability_preparation_test.go delete mode 100644 apps/daemon/internal/localworkspace/directory.go delete mode 100644 apps/daemon/internal/localworkspace/directory_native_test.go delete mode 100644 apps/daemon/internal/localworkspace/export_test.go delete mode 100644 apps/daemon/internal/localworkspace/initialization.go delete mode 100644 apps/daemon/internal/localworkspace/mcp.go delete mode 100644 apps/daemon/internal/localworkspace/mcp_test.go delete mode 100644 apps/daemon/internal/localworkspace/native_binding.go delete mode 100644 apps/daemon/internal/localworkspace/native_files.go delete mode 100644 apps/daemon/internal/localworkspace/native_open_unix.go delete mode 100644 apps/daemon/internal/localworkspace/native_open_unix_test.go delete mode 100644 apps/daemon/internal/localworkspace/native_open_windows.go delete mode 100644 apps/daemon/internal/localworkspace/network.go delete mode 100644 apps/daemon/internal/localworkspace/network_policy_test.go delete mode 100644 apps/daemon/internal/localworkspace/package_command.go delete mode 100644 apps/daemon/internal/localworkspace/package_command_windows_test.go delete mode 100644 apps/daemon/internal/localworkspace/runtime_initialization.go delete mode 100644 apps/daemon/internal/localworkspace/runtime_initialization_process.go delete mode 100644 apps/daemon/internal/localworkspace/runtime_initialization_test.go delete mode 100644 apps/daemon/internal/localworkspace/snapshot_marker.go delete mode 100644 apps/daemon/internal/localworkspace/snapshot_marker_test.go delete mode 100644 apps/daemon/internal/localworkspace/write.go delete mode 100644 apps/daemon/internal/localworkspace/write_binding.go delete mode 100644 apps/daemon/internal/localworkspace/write_test.go delete mode 100644 apps/daemon/internal/placement/controller_linux.go delete mode 100644 apps/daemon/internal/placement/controller_linux_test.go delete mode 100644 apps/daemon/internal/placement/controller_other.go delete mode 100644 apps/daemon/internal/placement/docker_linux.go delete mode 100644 apps/daemon/internal/placement/environment.go delete mode 100644 apps/daemon/internal/placement/environment_linux_test.go delete mode 100644 apps/daemon/internal/placement/mounts_linux.go delete mode 100644 apps/daemon/internal/placement/mounts_linux_test.go delete mode 100644 apps/daemon/internal/placement/observe_linux.go delete mode 100644 apps/daemon/internal/placement/state_linux.go delete mode 100644 apps/daemon/internal/placement/types.go rename internal/agentcapabilities/{root.go => root_test.go} (98%) delete mode 100644 packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs delete mode 100644 packages/mcode-harness/snapshot.test.mjs diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index ffe31aba3..1a803da6f 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -76,37 +76,26 @@ jobs: run: ./deploy/test_install.ps1 -Binary "$env:RUNNER_TEMP/oac.exe" - name: Verify native download bootstrap and recovery run: go test ./services/core/internal/nativeinstaller -count=1 -timeout=3m - - name: Native filesystem, authentication and process lifecycle + - name: Native filesystem and process lifecycle id: filesystem run: >- go test -race -count=1 ./internal/runtimefs - ./apps/daemon/internal/auth ./apps/daemon/internal/paths ./apps/daemon/internal/daemonize ./apps/daemon/internal/agent/clirunner - - name: Native Harness ownership and environment identity + - name: Native Harness process ownership id: harness run: >- go test -race -count=1 ./apps/daemon/internal/agent/codex - ./apps/daemon/internal/agent/claudesdk - ./apps/daemon/internal/agent/mcode - -run 'TestJSONRPCClientOwnsToolDescendants|TestSelfHostedToolEnvironment' - - name: Native capability snapshots, files and installation + -run 'TestJSONRPCClientOwnsToolDescendants' + - name: Native installation id: files run: >- go test -race -count=1 - ./apps/daemon/internal/localworkspace ./apps/daemon/internal/cli - -run 'TestNative|TestRuntimeInit|TestRuntimePreparationRejects|TestPreparationFreezesLocalContentsAcrossReconnect|TestSnapshotMarker' - - name: Windows npm and npx stdio launchers without network - if: runner.os == 'Windows' - run: >- - go test -race -count=1 -timeout=2m - ./apps/daemon/internal/localworkspace - ./apps/daemon/internal/cli - -run 'TestWindowsPackageManager|TestWindowsRuntimeMCP' + -run 'TestNative' - name: Build pinned native components run: | pnpm --dir packages/claude-sdk-adapter install --frozen-lockfile diff --git a/apps/daemon/internal/agent/binpath/binpath.go b/apps/daemon/internal/agent/binpath/binpath.go index 792640034..f9bac9476 100644 --- a/apps/daemon/internal/agent/binpath/binpath.go +++ b/apps/daemon/internal/agent/binpath/binpath.go @@ -6,7 +6,7 @@ // control: e2b's base image, for instance, ships its own // /usr/local/bin entries that can shadow the ones we install, and a // bare-name lookup then resolves to the wrong (or no) binary. The -// symptom is the worst kind — `oac-daemon connect` reports +// symptom is the worst kind — the agent host reports // "no supported agent CLI available" and the device never dials in, // with no indication of which lookup failed. // diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go deleted file mode 100644 index 4585589ff..000000000 --- a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go +++ /dev/null @@ -1,162 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" - "github.com/google/uuid" -) - -func TestLiveClaudeSDKCancelResume(t *testing.T) { - entrypoint := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT") - keyFile := os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE") - if entrypoint == "" || keyFile == "" { - t.Skip("real cancellation acceptance requires explicit SDK entrypoint and private key file") - } - proofRoot := os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") - if !filepath.IsAbs(proofRoot) { - t.Fatal("OAC_TEST_CLAUDE_SDK_PROOF_DIR must be an absolute managed directory") - } - root, err := os.MkdirTemp(proofRoot, "claude-cancel-") - if err != nil { - t.Fatal(err) - } - t.Logf("real cancellation evidence: %s", root) - t.Setenv("OAC_RUNTIME_HOME", root) - key, err := os.ReadFile(keyFile) - if err != nil { - t.Fatal(err) - } - provider := &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "https://api.minimax.cn/anthropic", APIKey: strings.TrimSpace(string(key))} - config := Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", - "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3", - }} - readiness, err := CheckRuntime(context.Background(), config) - if err != nil { - t.Fatal("real runtime readiness failed", err) - } - readinessJSON, _ := json.MarshalIndent(readiness, "", " ") - if err := os.WriteFile(filepath.Join(root, "readiness.json"), readinessJSON, 0o600); err != nil { - t.Fatal(err) - } - type evidence struct { - NodePID int `json:"node_pid"` - NativePIDs []int `json:"native_pids"` - CancelMS int64 `json:"cancel_milliseconds,omitempty"` - Cancelled bool `json:"cancelled"` - Failure string `json:"failure,omitempty"` - Outcome proto.DonePayload `json:"outcome"` - Events []proto.Envelope `json:"events"` - } - run := func(prompt, resume string, cancelOnText bool) evidence { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."} - running, err := startSingleTurn(ctx, config, request, uuid.NewString(), proto.TextInput(prompt), out) - if err != nil { - t.Fatal(err) - } - s := running.(*session) - defer s.Cancel(ctx) - proof := evidence{NodePID: s.process.Cmd.Process.Pid} - done := false - for event := range out { - proof.Events = append(proof.Events, event) - if event.Type == proto.TypeDelta && len(proof.NativePIDs) == 0 { - raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", proof.NodePID, proof.NodePID)) - for _, value := range strings.Fields(string(raw)) { - pid, _ := strconv.Atoi(value) - args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) - if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { - proof.NativePIDs = append(proof.NativePIDs, pid) - } - } - } - switch event.Type { - case proto.TypeDelta: - if cancelOnText && !proof.Cancelled { - select { - case <-s.process.Done(): - t.Fatal("native execution ended before cancellation") - default: - } - started := time.Now() - if err := s.Cancel(ctx); err != nil { - t.Fatal("live cancellation failed", err) - } - proof.CancelMS = time.Since(started).Milliseconds() - proof.Cancelled = true - proof.Outcome = s.CancellationOutcome() - } - case proto.TypeError: - var payload proto.ErrorPayload - _ = event.DecodePayload(&payload) - proof.Failure = payload.Error - case proto.TypeDone: - done = true - var payload proto.DonePayload - if err := event.DecodePayload(&payload); err != nil { - t.Fatal(err) - } - if proof.Cancelled { - expected, _ := json.Marshal(proof.Outcome) - actual, _ := json.Marshal(payload) - if !bytes.Equal(expected, actual) { - t.Fatal("live cancellation outcome differs from Done") - } - } - proof.Outcome = payload - } - } - data, _ := json.MarshalIndent(proof, "", " ") - if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("execution-%d.json", proof.NodePID)), data, 0o600); err != nil { - t.Fatal(err) - } - if !done || len(proof.NativePIDs) == 0 || proof.Cancelled != cancelOnText { - t.Fatal("missing real execution/completion/cancellation evidence") - } - select { - case <-s.process.Done(): - default: - t.Fatal("completion preceded owned process release") - } - for _, pid := range append([]int{proof.NodePID}, proof.NativePIDs...) { - if value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil { - fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) - if len(fields) == 0 || fields[0] != "Z" { - t.Fatalf("execution process %d remains alive", pid) - } - } - } - return proof - } - nonce := "cancel-history-" + uuid.NewString() - first := run("Remember this exact verification value: "+nonce+". First repeat it, then write two hundred numbered sentences about trees. Do not use tools.", "", true) - id, _ := first.Outcome.Metadata[proto.DoneMetaAgentSessionID].(string) - if id == "" || messageText(first.Events) == "" || first.Failure == "" { - t.Fatal("live cancellation lost identity, partial output or interruption evidence") - } - second := run("Return only the exact cancel-history verification value in the earlier user request. Ignore the earlier request for numbered sentences.", id, false) - if second.Failure != "" || second.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(messageText(second.Events), nonce) || first.NodePID == second.NodePID { - t.Fatalf("cold continuation did not preserve identity/history; evidence %s", root) - } - data, _ := json.MarshalIndent(map[string]any{"scope": "private Go factory -> maintained SDK/native -> real MiniMax cancellation and cold continuation; public admission remains separate", "verification_value": nonce, "executions": []evidence{first, second}}, "", " ") - if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { - t.Fatal(err) - } -} diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_test.go index 549964499..6f389c97f 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_test.go @@ -21,7 +21,6 @@ import ( func TestCancellationWaitsForDrainAndPublishesOutcome(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) config := cancellationConfig(root, "wait") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -47,7 +46,7 @@ func TestCancellationWaitsForDrainAndPublishesOutcome(t *testing.T) { if err := running.SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "later", Input: proto.TextInput("later")}, func() {}); !errors.Is(err, agent.ErrSteeringInactive) { t.Fatal("cancelled execution accepted steering", err) } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(config.StateDir(), "release"), nil, 0o600); err != nil { t.Fatal(err) } if err := running.Cancel(ctx); err != nil { @@ -85,7 +84,6 @@ func TestFailureKeepsOnlyVerifiedNativeIdentity(t *testing.T) { for _, mode := range []string{"failure", "wrong-identity", "before-identity"} { t.Run(mode, func(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 8) @@ -120,7 +118,6 @@ func TestFailureKeepsOnlyVerifiedNativeIdentity(t *testing.T) { func TestCancellationDrainsIntoReadyConsumer(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) config := cancellationConfig(root, "wait") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -133,7 +130,7 @@ func TestCancellationDrainsIntoReadyConsumer(t *testing.T) { if event := <-out; event.Type != proto.TypeDelta { t.Fatal("missing native readiness barrier") } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(config.StateDir(), "release"), nil, 0o600); err != nil { t.Fatal(err) } if err := running.Cancel(ctx); err != nil { @@ -158,12 +155,12 @@ func TestCancellationDrainsIntoReadyConsumer(t *testing.T) { } } -func cancellationConfig(root, mode string) Config { - return Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=cancellation-" + mode, "GORACE=atexit_sleep_ms=0"}} +func cancellationConfig(root, mode string) testBridge { + return testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=cancellation-" + mode, "GORACE=atexit_sleep_ms=0"}}, Home: root} } func cancellationRequest() proto.PromptRequestPayload { - return proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} + return proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} } func runCancellationHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { diff --git a/apps/daemon/internal/agent/claudesdk/commands_session_test.go b/apps/daemon/internal/agent/claudesdk/commands_session_test.go index d5da4b08d..37700b9e6 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_session_test.go @@ -18,22 +18,22 @@ import ( func TestWorkspaceCommandsRequirePackagedFeature(t *testing.T) { config := preparationFixture(t, "old-command-runtime") - if _, err := NewExecutorFactory(config)(t.Context(), prepared(t, preparationRequest())); err == nil || !strings.Contains(err.Error(), "workspace preparation is unavailable") { + if _, err := config.factory()(t.Context(), prepared(t, preparationRequest())); err == nil || !strings.Contains(err.Error(), "workspace preparation is unavailable") { t.Fatal("old bridge accepted command observations", err) } - if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(config.StateDir(), "launched")); !os.IsNotExist(err) { t.Fatal("old bridge started execution before rejection") } } func TestWorkspaceCommandFramesKeepStartIdentityAndObservedOutput(t *testing.T) { config := preparationFixture(t, "commands-success") - resource, err := NewExecutorFactory(config)(t.Context(), prepared(t, preparationRequest())) + resource, err := config.factory()(t.Context(), prepared(t, preparationRequest())) if err != nil { t.Fatal(err) } defer resource.Close(context.Background()) - if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(config.StateDir(), "start.json")); !os.IsNotExist(err) { t.Fatal("preparation submitted a command") } out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index 2717c58d8..d7fc9798f 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -1,7 +1,5 @@ package claudesdk -import configuration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" - import ( "context" "fmt" @@ -10,17 +8,16 @@ import ( "path/filepath" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "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" + configuration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) const claudeSDKEntrypointEnv = "OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT" const claudeSDKNodeEnv = "OAC_RUNTIME_CLAUDE_SDK_NODE" -// Declaration owns Claude SDK discovery, configuration and execution -// factories. Discovery narrows the declared support to what the installed -// bundle serves. +// Declaration owns Claude SDK discovery, configuration and the agent-host +// view. Discovery narrows the declared support to what the installed bundle +// serves. var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "claude_sdk", Capabilities: configuration.Configuration().Declaration.Capabilities}, Configuration: configuration.Configuration(), Discover: discover} @@ -33,8 +30,6 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d return nil } out := &agent.Runtime{Info: descriptor} - var config Config - fail := func(err error) *agent.Runtime { fmt.Fprintf(options.Stderr, "oac-daemon: configured Claude SDK runtime unavailable: %v\n", err) return out @@ -42,18 +37,11 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d if !filepath.IsAbs(entrypoint) { return fail(fmt.Errorf("%s must be absolute", claudeSDKEntrypointEnv)) } - profileDir, err := paths.ProfileDir(options.Profile) - if err != nil { - return fail(err) - } - if !filepath.IsAbs(profileDir) { - return fail(fmt.Errorf("Claude SDK state requires an absolute OAC_RUNTIME_HOME")) - } node := os.Getenv(claudeSDKNodeEnv) if node == "" { node = "node" } - node, err = exec.LookPath(node) + node, err := exec.LookPath(node) if err != nil { return fail(fmt.Errorf("Claude SDK Node executable is unavailable")) } @@ -61,37 +49,15 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d if err != nil { return fail(err) } - config = Config{Node: node, Entrypoint: entrypoint, StateDir: filepath.Join(profileDir, "runtime", "claude-sdk")} - binding, err := localworkspace.Load() - if err != nil { - return fail(err) - } - if binding != nil { - root, err := paths.Root() - if err != nil { - return fail(err) - } - config.Node, err = filepath.EvalSymlinks(node) - if err != nil { - return fail(err) - } - config, err = ConfigureLocal(config, root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) - if err != nil { - return fail(err) - } - } + config := Config{Node: node, Entrypoint: entrypoint} info, err := check(parent, config) if err != nil { return fail(err) } - if config.Workspace != nil && !info.SupportsLocalRuntime() { - return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) - } caps := &out.Info.Capabilities - caps.NativeSessionRecovery = proto.CapabilityFromBool(config.Workspace != nil) - // One declaration holds for every Executor of the install: the workspace - // bridge, the agent-host view and a Runtime without a workspace, so each - // feature is its workspace variant, which the others also support. + // The view runs the bridge in workspace mode, and without a workspace for + // environment none, so each feature is its workspace variant, which + // environment none also supports. out.Info.Available, out.Info.Version = true, info.SDK caps.LocalEnvironment = proto.CapabilityFromBool(info.SupportsLocalRuntime()) caps.FunctionTools = proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()) @@ -103,9 +69,8 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d caps.MCPHTTPTools = proto.CapabilityFromBool(info.SupportsWorkspaceMCP()) caps.MCPHTTPBearerAuth = proto.CapabilityFromBool(info.SupportsWorkspaceMCP() && info.SupportsHTTPMCPBearer()) caps.MCPHTTPRequired = proto.CapabilityFromBool(info.SupportsWorkspaceMCP() && info.SupportsHTTPMCPRequired()) - out.Executor = NewExecutorFactory(config) - // The view runs the same install; its probe stays on this host. - if view, err := newView(Config{Node: node, Entrypoint: entrypoint}, info); err != nil { + // The view runs the probed install on this host. + if view, err := newView(config, info); err != nil { fmt.Fprintf(options.Stderr, "oac-daemon: Claude SDK agent-host view unavailable: %v\n", err) } else { out.View = view diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index d6988adc9..38d2f7068 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -13,39 +13,26 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" ) -func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { - for _, relative := range []string{"entrypoint", "home"} { - t.Run(relative, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) - if relative == "entrypoint" { - t.Setenv(claudeSDKEntrypointEnv, "main.js") - } else { - t.Setenv("OAC_RUNTIME_HOME", "relative-home") - } - out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info, func(context.Context, Config) (RuntimeInfo, error) { - t.Fatal("invalid paths reached runtime probe") - return RuntimeInfo{}, nil - }) - if out == nil || out.Info.Available { - t.Fatal("invalid runtime advertised as ready") - } - }) +func TestClaudeSDKRelativeEntrypointFailsBeforeProbe(t *testing.T) { + t.Setenv(claudeSDKEntrypointEnv, "main.js") + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info, func(context.Context, Config) (RuntimeInfo, error) { + t.Fatal("invalid paths reached runtime probe") + return RuntimeInfo{}, nil + }) + if out == nil || out.Info.Available { + t.Fatal("invalid runtime advertised as ready") } } func TestClaudeSDKFeatureDiscovery(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) + t.Setenv(claudeSDKEntrypointEnv, filepath.Join(t.TempDir(), "main.js")) node, err := os.Executable() if err != nil { t.Fatal(err) } t.Setenv(claudeSDKNodeEnv, node) for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}, {"subagent_resources"}, {"structured_output"}} { - out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info, func(context.Context, Config) (RuntimeInfo, error) { + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info, func(context.Context, Config) (RuntimeInfo, error) { // The bundle's workspace variants, which the declaration uses. workspace := []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "local_runtime_v2", "workspace_mcp_http", "workspace_structured_output"} info := RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: append(slices.Clone(features), workspace...)} @@ -61,19 +48,20 @@ func TestClaudeSDKFeatureDiscovery(t *testing.T) { if out.Info.Capabilities.SubagentObservations.IsSupported() != slices.Contains(features, "subagent_resources") { t.Fatal("Subagent feature discovery does not match the runtime contract") } + if out.Info.Capabilities.NativeSessionRecovery.IsSupported() { + t.Fatal("discovery claimed native session recovery") + } } } func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) node, err := os.Executable() if err != nil { t.Fatal(err) } t.Setenv(claudeSDKNodeEnv, node) - entrypoint := filepath.Join(root, "bundle", "main.js") - options := agent.DiscoveryOptions{Profile: "test", Stdout: io.Discard, Stderr: io.Discard} + entrypoint := filepath.Join(t.TempDir(), "bundle", "main.js") + options := agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard} for _, configured := range []bool{false, true} { for _, ready := range []bool{false, true} { t.Setenv(claudeSDKEntrypointEnv, "") @@ -83,7 +71,7 @@ func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { calls := 0 runtime := discoverWithCheck(t.Context(), options, Declaration.Info, func(_ context.Context, c Config) (RuntimeInfo, error) { calls++ - if c.Node != node || c.Entrypoint != entrypoint || c.StateDir != filepath.Join(root, "daemon", "test", "runtime", "claude-sdk") || c.Env != nil { + if c.Node != node || c.Entrypoint != entrypoint || c.Env != nil { t.Fatalf("configuration: %+v", c) } if !ready { @@ -97,7 +85,8 @@ func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { } continue } - if calls != 1 || runtime.Info.Available != ready || (runtime.Executor != nil) != ready || ready && runtime.Info.Capabilities.LocalEnvironment.IsSupported() { + // The probe reports no native binary, so no view is declared. + if calls != 1 || runtime.Info.Available != ready || runtime.View != nil || ready && runtime.Info.Capabilities.LocalEnvironment.IsSupported() { t.Fatalf("runtime: %+v", runtime) } } diff --git a/apps/daemon/internal/agent/claudesdk/error_classification_test.go b/apps/daemon/internal/agent/claudesdk/error_classification_test.go index f5f8a4d67..0d53edb43 100644 --- a/apps/daemon/internal/agent/claudesdk/error_classification_test.go +++ b/apps/daemon/internal/agent/claudesdk/error_classification_test.go @@ -19,9 +19,8 @@ func TestClassifiedBridgeFailurePreservesTerminalEvidence(t *testing.T) { for _, mode := range []string{"valid", "unconfirmed", "wrong-session", "wrong-result", "success-usage", "cancelled", "unknown", "malformed-code", "after-terminal", "scanner-error", "process-error"} { t.Run(mode, func(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=classified-" + mode, "GORACE=atexit_sleep_ms=0"}} - req := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} + config := testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=classified-" + mode, "GORACE=atexit_sleep_ms=0"}}, Home: root} + req := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index e33dadef5..e55da7840 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -14,17 +14,14 @@ import ( ) func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native-session", Model: "native-model", SystemPrompt: "Keep these exact instructions.\nDo not replace them."} - ordinary, _, err := prepareConfiguration(config, prepared(t, request)) + ordinary, _, err := prepareOptions(prepared(t, request)) if err != nil { t.Fatal(err) } request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium"} before, _ := json.Marshal(request) - controlled, _, err := prepareConfiguration(config, prepared(t, request)) + controlled, _, err := prepareOptions(prepared(t, request)) if err != nil { t.Fatal(err) } @@ -36,26 +33,22 @@ func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + config := testBridge{Config: Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker")}, Home: root, Workspace: root} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} - request := proto.PromptRequestPayload{MCPHTTPServers: &servers, Model: "fixture", ModelProvider: fixtureProvider()} + request := proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: "environment"}, MCPHTTPServers: &servers, Model: "fixture", ModelProvider: fixtureProvider()} _, err := startSingleTurn(t.Context(), config, request, "run", proto.TextInput("Input"), make(chan proto.Envelope, 1)) if err == nil || !strings.Contains(err.Error(), "service-origin MCP requires a service execution host") { t.Fatal("MCP reached an unsupported environment", err) } - if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { + if _, err := os.Stat(config.StateDir()); !os.IsNotExist(err) { t.Fatal("MCP reached native setup", err) } } func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} schema := json.RawMessage(`{"type":"object","properties":{"n":{"const":9007199254740992}}}`) request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableSubagents: true, Model: "model", SystemPrompt: "Original instructions.", ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} - start, _, err := prepareConfiguration(config, prepared(t, request)) + start, _, err := prepareOptions(prepared(t, request)) if err != nil { t.Fatal(err) } @@ -67,15 +60,12 @@ func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { // Tool discovery keeps the frozen definitions; a typeless parameters root // becomes an object root, which admits the same arguments. func TestToolDiscoveryPreservesFrozenFunctions(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableSubagents: true, ToolSearch: true, Model: "model", FunctionTools: []proto.FunctionTool{ {Name: "lookup", Description: "Lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"const":"original"}}}`), DeferLoading: true}, {Name: "clock", Description: "Clock", Parameters: json.RawMessage(`{"properties":{}}`)}, {Name: "note", Description: "Note", Parameters: json.RawMessage(`{"type":["object","null"]}`)}, }} - start, _, err := prepareConfiguration(config, prepared(t, request)) + start, _, err := prepareOptions(prepared(t, request)) if err != nil || !start.ToolSearch || !reflect.DeepEqual(start.Functions[0], request.FunctionTools[0]) || string(start.Functions[1].Parameters) != `{"properties":{},"type":"object"}` || string(start.Functions[2].Parameters) != `{"type":"object"}` { t.Fatal("function discovery changed native definitions", err) } diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index 694b8f449..cf53eb6e1 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "io" - "slices" "strings" "sync" "time" @@ -26,25 +25,6 @@ type executor struct { nativeID string } -func NewExecutorFactory(config Config) agent.ExecutorFactory { - config.Env = slices.Clone(config.Env) - if config.Workspace != nil { - workspace := *config.Workspace - config.Workspace = &workspace - } - checked := &runtimeCheckCache{} - return func(ctx context.Context, req agent.PrepareRequest) (agent.Executor, error) { - if ctx == nil { - ctx = context.Background() - } - start, env, err := prepareConfiguration(config, req) - if err != nil { - return nil, err - } - return startExecutor(ctx, checked, config, start, func() (*session, error) { return launch(ctx, config, start, env) }) - } -} - // startExecutor checks the installed bridge against probe, starts it through // run and waits until it is ready for Turns. func startExecutor(ctx context.Context, checked *runtimeCheckCache, probe Config, start startRequest, run func() (*session, error)) (agent.Executor, error) { @@ -109,9 +89,6 @@ func validateExecutorFeatures(info RuntimeInfo, start startRequest) error { if server.Required && !info.SupportsHTTPMCPRequired() { return errors.New("claudesdk: packaged runtime does not support required HTTP MCP") } - if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { - return errors.New("claudesdk: packaged runtime does not support authenticated HTTP MCP") - } } } return nil diff --git a/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go index 2ae8beda7..aed09a702 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_confirmation_test.go @@ -17,7 +17,7 @@ func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { if mode == "pending_function" || mode == "pending_function_unconfirmed" { req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} } - owner, err := NewExecutorFactory(config)(t.Context(), prepared(t, req)) + owner, err := config.factory()(t.Context(), prepared(t, req)) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go index 9a6392c25..5f2241fa4 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_fixture_test.go @@ -7,20 +7,59 @@ import ( "context" "encoding/json" "os" + "path/filepath" "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" ) +// testBridge runs a bridge fixture as a view Executor whose view paths are +// host paths. +type testBridge struct { + // Config is the probe. Its Env also reaches the bridge. + Config + // Home is the Session home, at the same path in the view. + Home string + // Workspace is the workspace root of a local Environment request. + Workspace string +} + +// StateDir is the bridge's CLAUDE_CONFIG_DIR. +func (b testBridge) StateDir() string { return filepath.Join(b.Home, "config") } + +// factory is the view Executor factory over b, as the agent host runs it for +// each Session of b's home. +func (b testBridge) factory() agent.ExecutorFactory { + factory := newViewExecutorFactory(b.Config, viewLayout{node: b.Node, bridge: b.Entrypoint}) + return func(ctx context.Context, req agent.PrepareRequest) (agent.Executor, error) { + mcp, err := viewMCP(req) + if err != nil { + return nil, err + } + if req.LocalEnvironment != nil && req.WorkspaceRoot == "" { + req.WorkspaceRoot = b.Workspace + } + return factory(ctx, req, agent.ViewSession{Home: agent.ViewDir{Host: b.Home, View: b.Home}, Proxy: testProxy, MCP: mcp, + Launch: func(options clirunner.StartOptions) (*clirunner.Process, error) { + if err := os.MkdirAll(options.Dir, 0o700); err != nil { + return nil, err + } + options.Env = append(options.Env, b.Env...) + return clirunner.Start(options) + }}) + } +} + // startSingleTurn prepares an Executor as the registry does, starts one Turn // and closes the Executor once that Turn settles, so each test observes the // complete native lifecycle. -func startSingleTurn(ctx context.Context, config Config, req proto.PromptRequestPayload, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { +func startSingleTurn(ctx context.Context, config testBridge, req proto.PromptRequestPayload, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { configuration, err := Declaration.Configuration.Prepare(req) if err != nil { return nil, err } - resource, err := NewExecutorFactory(config)(ctx, agent.PrepareRequest{PromptRequestPayload: req, Prepared: configuration}) + resource, err := config.factory()(ctx, agent.PrepareRequest{PromptRequestPayload: req, Prepared: configuration}) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go deleted file mode 100644 index cc87b82c5..000000000 --- a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go +++ /dev/null @@ -1,211 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" - "github.com/google/uuid" -) - -// Explicit opt-in acceptance; normal test runs never contact a provider. -func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { - entry, keyPath, proof := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_KEY_FILE"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_PROOF_DIR") - if entry == "" || keyPath == "" || proof == "" { - t.Skip("explicit installed runtime, private key file and proof directory required") - } - endpoint, model := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_BASE_URL"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_MODEL") - if endpoint == "" || model == "" { - t.Fatal("explicit provider endpoint and model required") - } - if !filepath.IsAbs(proof) { - t.Fatal("absolute proof directory required") - } - if err := os.MkdirAll(proof, 0700); err != nil { - t.Fatal(err) - } - key, err := os.ReadFile(keyPath) - if err != nil { - t.Fatal("cannot read selected credential file") - } - t.Setenv("OAC_RUNTIME_HOME", proof) - provider := &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: endpoint, APIKey: strings.TrimSpace(string(key))} - config := Config{Node: os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_NODE"), Entrypoint: entry, StateDir: filepath.Join(proof, "state"), Env: []string{ - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", - "ANTHROPIC_DEFAULT_SONNET_MODEL=" + model, "ANTHROPIC_DEFAULT_OPUS_MODEL=" + model, "ANTHROPIC_DEFAULT_HAIKU_MODEL=" + model, - }} - if proxy := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_HTTP_PROXY"); proxy != "" { - config.Env = append(config.Env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy, "http_proxy="+proxy, "https_proxy="+proxy) - } - ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) - defer cancel() - readiness, err := CheckRuntime(ctx, config) - if err != nil { - t.Fatal("installed runtime readiness failed", err) - } - type turnEvidence struct { - Run string `json:"run"` - NodePID int `json:"node_pid"` - NativePIDs []int `json:"native_pids"` - FirstTextMS int64 `json:"first_text_ms"` - ElapsedMS int64 `json:"elapsed_ms"` - CancelMS int64 `json:"cancel_ms,omitempty"` - Cancelled bool `json:"cancelled"` - CancellationError string `json:"cancellation_error,omitempty"` - Settlement agent.TurnSettlement `json:"settlement"` - SettlementError string `json:"settlement_error,omitempty"` - Done proto.DonePayload `json:"done"` - Text string `json:"text"` - Errors []string `json:"errors,omitempty"` - } - evidence := struct { - Runtime RuntimeInfo `json:"runtime"` - PrepareMS int64 `json:"prepare_ms"` - Turns []turnEvidence `json:"turns"` - Recovered bool `json:"recovered_after_cancel"` - Closed bool `json:"closed"` - }{Runtime: readiness} - persist := func() { - raw, _ := json.MarshalIndent(evidence, "", " ") - _ = os.WriteFile(filepath.Join(proof, "executor-evidence.json"), raw, 0600) - } - defer persist() - request := proto.PromptRequestPayload{DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, Model: model, ModelProvider: provider, SystemPrompt: "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."} - factory := NewExecutorFactory(config) - started := time.Now() - owner, err := factory(ctx, prepared(t, request)) - if err != nil { - t.Fatal("executor preparation failed", err) - } - evidence.PrepareMS = time.Since(started).Milliseconds() - defer func() { - closeCtx, stop := context.WithTimeout(context.Background(), 10*time.Second) - defer stop() - evidence.Closed = owner.Close(closeCtx) == nil - persist() - }() - nativeChildren := func(pid int) []int { - raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)) - var ids []int - for _, value := range strings.Fields(string(raw)) { - child, _ := strconv.Atoi(value) - args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", child)) - if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { - ids = append(ids, child) - } - } - slices.Sort(ids) - return ids - } - run := func(id, prompt string, cancelOnText bool) turnEvidence { - t.Helper() - started := time.Now() - record := turnEvidence{Run: id, NodePID: owner.(*executor).base.process.Cmd.Process.Pid} - output := make(chan proto.Envelope, 128) - turn, err := owner.StartTurn(ctx, id, proto.TextInput(prompt), output) - if err != nil || turn == nil { - t.Fatal("Turn start failed", err) - } - cancellation := make(chan error, 1) - var cancelAt time.Time - for event := range output { - if event.ID != id { - t.Fatal("output crossed Turn identity") - } - if event.Type == proto.TypeDelta { - var delta proto.DeltaPayload - _ = event.DecodePayload(&delta) - record.Text += delta.Delta - } - if event.Type == proto.TypeDelta && record.FirstTextMS == 0 { - record.FirstTextMS = time.Since(started).Milliseconds() - record.NativePIDs = nativeChildren(record.NodePID) - if cancelOnText { - record.Cancelled = true - cancelAt = time.Now() - go func() { - cancelCtx, stop := context.WithTimeout(ctx, 30*time.Second) - defer stop() - cancellation <- turn.Cancel(cancelCtx) - }() - } - } - if event.Type == proto.TypeError { - var failure proto.ErrorPayload - _ = event.DecodePayload(&failure) - record.Errors = append(record.Errors, failure.Error) - } - if event.Type == proto.TypeDone { - _ = event.DecodePayload(&record.Done) - } - } - if record.Cancelled { - err := <-cancellation - record.CancelMS = time.Since(cancelAt).Milliseconds() - if err != nil { - record.CancellationError = err.Error() - } - } - record.Settlement, err = turn.AwaitSettlement(ctx) - if err != nil { - record.SettlementError = err.Error() - } - record.ElapsedMS = time.Since(started).Milliseconds() - evidence.Turns = append(evidence.Turns, record) - persist() - t.Logf("turn=%s bridge_pid=%d native_pids=%v reusable=%t first_text_ms=%d total_ms=%d", id, record.NodePID, record.NativePIDs, record.Settlement.Reusable, record.FirstTextMS, record.ElapsedMS) - return record - } - marker := "REUSE-" + uuid.NewString() - first := run("first", "Remember this marker: "+marker+". Reply with exactly the marker.", false) - if first.SettlementError != "" || !first.Settlement.Reusable || !strings.Contains(first.Text, marker) { - t.Fatal("first Turn failed or was not reusable") - } - second := run("second", "What exact marker did I give you? Reply with only that marker.", false) - if second.SettlementError != "" || !second.Settlement.Reusable || !strings.Contains(second.Text, marker) || first.NodePID != second.NodePID || len(first.NativePIDs) == 0 || !slices.Equal(first.NativePIDs, second.NativePIDs) { - t.Fatal("ordinary Turns did not retain native execution and history") - } - interrupted := run("cancel", "List the numbers 1 through 10000, one number per line, without stopping early.", true) - if !interrupted.Cancelled { - t.Fatal("native output ended before cancellation boundary") - } - if interrupted.SettlementError != "" || !interrupted.Settlement.Reusable { - if interrupted.Settlement.Reason == "" && interrupted.SettlementError == "" { - t.Fatal("invalid executor omitted its reason") - } - if err := owner.Close(ctx); err != nil { - t.Fatal("invalid owner cleanup was not confirmed", err) - } - native, _ := interrupted.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - if native == "" { - native, _ = second.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - } - request.AgentSessionID = native - request.RequireExistingNativeSession = true - owner, err = factory(ctx, prepared(t, request)) - if err != nil { - t.Fatal("history recovery failed", err) - } - evidence.Recovered = true - } - continued := run("continued", "What exact marker did I originally give you? Reply with only the marker.", false) - if continued.SettlementError != "" || !continued.Settlement.Reusable || !strings.Contains(continued.Text, marker) { - t.Fatal("history did not continue after cancellation") - } - if !evidence.Recovered && (continued.NodePID != second.NodePID || !slices.Equal(continued.NativePIDs, second.NativePIDs)) { - t.Fatal("reusable cancellation replaced native execution") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/executor_test.go b/apps/daemon/internal/agent/claudesdk/executor_test.go index 4c8870bd7..56bc2bcea 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_test.go @@ -19,15 +19,14 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) -func persistentConfig(t *testing.T, mode string) (Config, proto.PromptRequestPayload) { +func persistentConfig(t *testing.T, mode string) (testBridge, proto.PromptRequestPayload) { t.Helper() root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) entry := filepath.Join(root, "worker") if err := os.WriteFile(entry, []byte("version-one"), 0600); err != nil { t.Fatal(err) } - return Config{Node: os.Args[0], Entrypoint: entry, StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_EXECUTOR_HELPER=1", "SDK_EXECUTOR_DIR=" + root, "SDK_EXECUTOR_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, Model: "fixture"} + return testBridge{Config: Config{Node: os.Args[0], Entrypoint: entry, Env: []string{"GO_CLAUDE_EXECUTOR_HELPER=1", "SDK_EXECUTOR_DIR=" + root, "SDK_EXECUTOR_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, Home: root}, proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, Model: "fixture"} } func runPersistentExecutorHelper() { @@ -152,7 +151,7 @@ func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope, func TestExecutorRetainsProcessAcrossTurnsAndCancellation(t *testing.T) { config, req := persistentConfig(t, "") req.ModelProvider = &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "https://provider.example/anthropic", APIKey: "fixture-key"} - owner, err := NewExecutorFactory(config)(t.Context(), prepared(t, req)) + owner, err := config.factory()(t.Context(), prepared(t, req)) if err != nil { t.Fatal(err) } @@ -188,7 +187,7 @@ func TestExecutorRetainsProcessAcrossTurnsAndCancellation(t *testing.T) { } func TestExecutorLateTurnEventInvalidatesWithoutRetargeting(t *testing.T) { config, req := persistentConfig(t, "late") - owner, err := NewExecutorFactory(config)(t.Context(), prepared(t, req)) + owner, err := config.factory()(t.Context(), prepared(t, req)) if err != nil { t.Fatal(err) } @@ -211,7 +210,7 @@ func TestExecutorLateTurnEventInvalidatesWithoutRetargeting(t *testing.T) { } func TestExecutorCachesReadinessUntilInstalledArtifactChanges(t *testing.T) { config, req := persistentConfig(t, "") - factory := NewExecutorFactory(config) + factory := config.factory() for range 2 { owner, err := factory(t.Context(), prepared(t, req)) if err != nil { @@ -244,7 +243,7 @@ func TestExecutorCachesReadinessUntilInstalledArtifactChanges(t *testing.T) { func TestExecutorSeparatesPreInputRejectionFromUnknownWrite(t *testing.T) { config, req := persistentConfig(t, "block") - owner, err := NewExecutorFactory(config)(t.Context(), prepared(t, req)) + owner, err := config.factory()(t.Context(), prepared(t, req)) if err != nil { t.Fatal(err) } @@ -278,7 +277,7 @@ func TestExecutorSeparatesPreInputRejectionFromUnknownWrite(t *testing.T) { func TestExecutorCancellationDeadlineInterruptsBlockedTransport(t *testing.T) { config, req := persistentConfig(t, "block") - owner, err := NewExecutorFactory(config)(t.Context(), prepared(t, req)) + owner, err := config.factory()(t.Context(), prepared(t, req)) if err != nil { t.Fatal(err) } @@ -314,7 +313,7 @@ func TestExecutorCancellationDeadlineInterruptsBlockedTransport(t *testing.T) { func TestSharedTextLifecycle(t *testing.T) { config, req := persistentConfig(t, "text_contract") - owner, err := NewExecutorFactory(config)(t.Context(), prepared(t, req)) + owner, err := config.factory()(t.Context(), prepared(t, req)) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/functions_test.go b/apps/daemon/internal/agent/claudesdk/functions_test.go index cc0bee53d..3a455af80 100644 --- a/apps/daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -19,9 +19,8 @@ func TestFunctionTurnNativeReceipts(t *testing.T) { for _, mode := range []string{"functions-success", "functions-wrong-receipt", "functions-no-receipt", "functions-cancel"} { t.Run(mode, func(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions", FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} + config := testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, Home: root} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions", FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/harness_config_test.go b/apps/daemon/internal/agent/claudesdk/harness_config_test.go index 9d20e2cc5..9777d12a5 100644 --- a/apps/daemon/internal/agent/claudesdk/harness_config_test.go +++ b/apps/daemon/internal/agent/claudesdk/harness_config_test.go @@ -2,18 +2,14 @@ package claudesdk import ( "encoding/json" - "path/filepath" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestHarnessConfigReachesBridge(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} req := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), Model: "fixture", HarnessConfig: proto.HarnessConfig(`{"effort":"high","thinking":{"type":"enabled","budgetTokens":1024}}`)} - start, _, err := prepareConfiguration(config, prepared(t, req)) + start, _, err := prepareOptions(prepared(t, req)) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/live_linux_test.go b/apps/daemon/internal/agent/claudesdk/live_linux_test.go deleted file mode 100644 index 2245b4100..000000000 --- a/apps/daemon/internal/agent/claudesdk/live_linux_test.go +++ /dev/null @@ -1,404 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/http/httputil" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" - "github.com/google/uuid" -) - -func TestLiveClaudeSDKTextResume(t *testing.T) { - entrypoint := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT") - keyFile := os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE") - if entrypoint == "" || keyFile == "" { - t.Skip("real SDK/provider acceptance requires explicit entrypoint and private key file") - } - key, err := os.ReadFile(keyFile) - if err != nil { - t.Fatal(err) - } - proofRoot := os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") - if !filepath.IsAbs(proofRoot) { - t.Fatal("OAC_TEST_CLAUDE_SDK_PROOF_DIR must be an absolute managed proof directory") - } - if err := os.MkdirAll(proofRoot, 0o700); err != nil { - t.Fatal(err) - } - root, err := os.MkdirTemp(proofRoot, "claude-adapter-") - if err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_HOME", root) - target, _ := url.Parse("https://api.minimax.cn/anthropic") - proxy := httputil.NewSingleHostReverseProxy(target) - director := proxy.Director - proxy.Director = func(req *http.Request) { director(req); req.Host = target.Host } - proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) { - http.Error(w, "provider transport failed", http.StatusBadGateway) - } - var mu sync.Mutex - type providerRequest struct { - Model string `json:"model"` - Tools []struct { - Name string `json:"name"` - } `json:"tools"` - } - var requests []providerRequest - var delayNext atomic.Bool - var delayedRequests atomic.Int32 - forwarder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.Method == "POST" && strings.HasSuffix(req.URL.Path, "/messages") { - body, err := io.ReadAll(req.Body) - if err != nil { - http.Error(w, "request read failed", 400) - return - } - _ = req.Body.Close() - req.Body = io.NopCloser(bytes.NewReader(body)) - var value providerRequest - _ = json.Unmarshal(body, &value) - mu.Lock() - requests = append(requests, value) - mu.Unlock() - } - if req.Method == "POST" && strings.HasSuffix(req.URL.Path, "/messages") && delayNext.Swap(false) { - delayedRequests.Add(1) - select { - case <-time.After(31 * time.Second): - case <-req.Context().Done(): - return - } - } - proxy.ServeHTTP(w, req) - })) - defer forwarder.Close() - provider := &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: forwarder.URL, APIKey: strings.TrimSpace(string(key))} - config := Config{Entrypoint: entrypoint, StateDir: filepath.Join(root, "state"), Env: []string{ - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", - "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3", - }} - - // Ambient project configuration must not add a model tool or start a server. - work := filepath.Join(config.StateDir, "work") - if err := os.MkdirAll(filepath.Join(work, ".claude"), 0o700); err != nil { - t.Fatal(err) - } - canary := filepath.Join(root, "ambient-mcp-started") - ambient, _ := json.Marshal(map[string]any{"mcpServers": map[string]any{"ambient": map[string]any{ - "command": "node", "args": []string{"-e", "require('node:fs').writeFileSync(process.argv[1], 'unexpected')", canary}, - }}}) - if err := os.WriteFile(filepath.Join(work, ".mcp.json"), ambient, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(work, ".claude", "settings.json"), []byte(`{"enableAllProjectMcpServers":true,"permissions":{"allow":["Bash","Read","Agent","WebSearch"]}}`), 0o600); err != nil { - t.Fatal(err) - } - type evidence struct { - ExecutionControls *proto.ExecutionControls `json:"execution_controls"` - SteeringText string `json:"steering_text,omitempty"` - SteeringWritten bool `json:"steering_written,omitempty"` - SteeringConfirmed bool `json:"steering_confirmed,omitempty"` - SteeringMilliseconds int64 `json:"steering_milliseconds,omitempty"` - SessionID string `json:"session_id"` - NodePID int `json:"node_pid"` - NativePIDs []int `json:"native_pids"` - ChildPIDs []int `json:"child_pids"` - Text string `json:"text"` - Failure string `json:"failure,omitempty"` - Events []proto.Envelope `json:"events"` - FunctionCalls int `json:"function_calls"` - AppliedResults int `json:"applied_results"` - ProviderRequests []providerRequest `json:"provider_requests"` - } - functionNonce := "function-" + uuid.NewString() - run := func(prompt, resume string, success *bool, steering ...string) evidence { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - mu.Lock() - requestStart := len(requests) - mu.Unlock() - out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Answer briefly and preserve the exact verification value in the conversation. Use no tools."} - if success != nil { - request.SystemPrompt = "Call lookup exactly once as requested, then report both result parts and any prior verification value. Never retry a failed tool." - request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a synthetic verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} - } - running, err := startSingleTurn(ctx, config, request, uuid.NewString(), proto.TextInput(prompt), out) - if err != nil { - t.Fatal(err) - } - s := running.(*session) - defer running.Cancel(context.Background()) - proof := evidence{NodePID: s.process.Cmd.Process.Pid, ExecutionControls: request.ExecutionControls} - if len(steering) > 0 { - proof.SteeringText = steering[0] - } - type steeringResult struct { - err error - elapsed int64 - written bool - } - steeringReply := make(chan steeringResult, 1) - var steeringAt time.Time - beginSteering := func() { - if proof.SteeringText == "" || !steeringAt.IsZero() { - return - } - steeringAt = time.Now() - if success != nil { - delayNext.Store(true) - } - go func() { - callCtx, cancel := context.WithCancel(ctx) - defer cancel() - timer := time.AfterFunc(10*time.Second, cancel) - defer timer.Stop() - written := false - err := s.SteerWithReceipt(callCtx, proto.PromptSteerPayload{InputID: uuid.NewString(), Input: proto.TextInput(proof.SteeringText)}, func() { written = timer.Stop() }) - steeringReply <- steeringResult{err: err, elapsed: time.Since(steeringAt).Milliseconds(), written: written} - }() - } - type children struct{ all, native []int } - observed := make(chan children, 1) - go func() { - pids := map[int]bool{} - native := map[int]bool{} - ticker := time.NewTicker(10 * time.Millisecond) - defer ticker.Stop() - for { - raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", proof.NodePID, proof.NodePID)) - for _, value := range strings.Fields(string(raw)) { - if pid, err := strconv.Atoi(value); err == nil { - pids[pid] = true - // SDK history lookup may also spawn Git helpers; identify the execution transport. - args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) - if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && - bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { - native[pid] = true - } - } - } - select { - case <-s.process.Done(): - var result children - for pid := range pids { - result.all = append(result.all, pid) - } - for pid := range native { - result.native = append(result.native, pid) - } - observed <- result - return - case <-ticker.C: - } - } - }() - done := false - for event := range out { - proof.Events = append(proof.Events, event) - switch event.Type { - case proto.TypeDelta: - if success == nil { - beginSteering() - } - case proto.TypeToolCall: - var tool proto.ToolCallPayload - if err := event.DecodePayload(&tool); err != nil { - t.Fatal(err) - } - if tool.Observation == nil || tool.Observation.Kind != "function" || (tool.Stage != "before" && tool.Stage != "after") { - t.Fatal("invalid live neutral function observation") - } - if tool.Stage == "after" { - expectedStatus := "completed" - if success != nil && !*success { - expectedStatus = "failed" - } - if tool.Observation.Status != expectedStatus || tool.Observation.Content == nil || len(*tool.Observation.Content) != 2 { - t.Fatal("live result observation lost status or content") - } - } - case proto.TypeFunctionCall: - var call proto.FunctionCallPayload - if err := event.DecodePayload(&call); err != nil { - t.Fatal(err) - } - proof.FunctionCalls++ - beginSteering() - if success == nil || proof.FunctionCalls != 1 || call.Name != "lookup" { - t.Fatal("unexpected live function call") - } - first, second := functionNonce, "ordered-second-part" - if !*success { - first, second = "synthetic-current-failure", "do-not-retry" - } - value := proto.FunctionResultPayload{CallID: call.CallID, DeliveryID: uuid.NewString(), Success: *success, Content: []proto.InputContent{{Type: "input_text", Text: &first}, {Type: "input_text", Text: &second}}} - if err := s.SubmitFunctionResult(ctx, value); err != nil { - t.Fatalf("live native result receipt failed: %v; proof root %s", err, root) - } - proof.AppliedResults++ - case proto.TypeError: - var payload proto.ErrorPayload - _ = json.Unmarshal(event.Payload, &payload) - proof.Failure = payload.Error - case proto.TypeDone: - done = true - var payload proto.DonePayload - _ = json.Unmarshal(event.Payload, &payload) - proof.Text = messageText(proof.Events) - proof.SessionID, _ = payload.Metadata[proto.DoneMetaAgentSessionID].(string) - } - } - // Done reports the Turn outcome; startSingleTurn closes its Executor - // after output settlement. Verify release at that boundary. - if _, err := s.AwaitSettlement(ctx); err != nil { - t.Fatal(err) - } - select { - case <-s.process.Done(): - case <-time.After(5 * time.Second): - t.Fatal("single-Turn Executor retained its process after settlement") - } - if !steeringAt.IsZero() { - receipt := <-steeringReply - if receipt.err != nil { - proof.Failure = "steering receipt: " + receipt.err.Error() - } else { - proof.SteeringConfirmed = true - } - proof.SteeringMilliseconds = receipt.elapsed - proof.SteeringWritten = receipt.written - } - released := <-observed - proof.NativePIDs, proof.ChildPIDs = released.native, released.all - if !done { - t.Fatal("no daemon completion before timeout") - } - for _, pid := range proof.ChildPIDs { - if value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil { - fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) - if len(fields) == 0 || fields[0] != "Z" { - t.Fatalf("SDK child %d remains alive after Done", pid) - } - } - } - - mu.Lock() - proof.ProviderRequests = append([]providerRequest{}, requests[requestStart:]...) - mu.Unlock() - for _, sent := range proof.ProviderRequests { - if sent.Model != "MiniMax-M3" { - t.Fatal("unexpected provider model", sent.Model) - } - want := 0 - if success != nil { - want = 1 - } - if len(sent.Tools) != want { - t.Fatal("native tool inventory widened", sent.Tools) - } - for _, tool := range sent.Tools { - if tool.Name != "mcp__functions__lookup" { - t.Fatal("undeclared native tool", tool.Name) - } - } - } - if _, err := os.Stat(canary); !os.IsNotExist(err) { - t.Fatal("ambient MCP configuration was not excluded", err) - } - data, err := json.MarshalIndent(proof, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("execution-%d.json", proof.NodePID)), data, 0o600); err != nil { - t.Fatal(err) - } - return proof - } - nonce := "sdk-adapter-" + uuid.NewString() - first := run("Remember this exact verification value and reply with it: "+nonce, "", nil) - if first.Failure != "" || first.SessionID == "" || !strings.Contains(first.Text, nonce) || len(first.NativePIDs) == 0 { - t.Fatalf("first execution failed: %+v; evidence root %s", first, root) - } - verifyMessageEvents(t, first.Events, first.Text) - second := run("Return only the exact verification value from the previous user message.", first.SessionID, nil) - if second.Failure != "" || second.SessionID != first.SessionID || !strings.Contains(second.Text, nonce) || first.NodePID == second.NodePID || len(second.NativePIDs) == 0 { - t.Fatalf("cold resume failed: %+v; evidence root %s", second, root) - } - verifyMessageEvents(t, second.Events, second.Text) - for _, a := range first.NativePIDs { - for _, b := range second.NativePIDs { - if a == b { - t.Fatal("native process was reused") - } - } - } - accepted, rejected := true, false - functionFirst := run("Call lookup once with id 42 as a string. Report both returned parts verbatim.", "", &accepted) - if functionFirst.Failure != "" || functionFirst.FunctionCalls != 1 || functionFirst.AppliedResults != 1 || !strings.Contains(functionFirst.Text, functionNonce) || !strings.Contains(functionFirst.Text, "ordered-second-part") { - t.Fatalf("live function failed: %+v", functionFirst) - } - functionSecond := run("Call lookup once with id 42 as a string. Report the prior verification value and both current result parts. Do not retry.", functionFirst.SessionID, &rejected) - if functionSecond.Failure != "" || functionSecond.FunctionCalls != 1 || functionSecond.AppliedResults != 1 || functionSecond.SessionID != functionFirst.SessionID || !strings.Contains(functionSecond.Text, functionNonce) || !strings.Contains(functionSecond.Text, "synthetic-current-failure") || !strings.Contains(functionSecond.Text, "do-not-retry") || functionSecond.NodePID == functionFirst.NodePID { - t.Fatalf("live function resume failed: %+v", functionSecond) - } - steeringNonce := "live-steering-" + uuid.NewString() - steered := run("Write twelve short numbered observations about trees. Use no tools.", "", nil, "Remember this additional verification value and return it verbatim: "+steeringNonce) - if steered.Failure != "" || !steered.SteeringConfirmed || !steered.SteeringWritten || !strings.Contains(steered.Text, steeringNonce) { - t.Fatalf("live steering failed: %+v", steered) - } - steeredResume := run("Return only the exact live-steering verification value from the previous conversation.", steered.SessionID, nil) - if steeredResume.Failure != "" || steeredResume.SessionID != steered.SessionID || !strings.Contains(steeredResume.Text, steeringNonce) || steeredResume.NodePID == steered.NodePID { - t.Fatalf("steered cold continuation failed: %+v", steeredResume) - } - functionSteered := run("Call lookup once with id 42 as a string. Report both returned parts verbatim.", "", &accepted, "Also remember and report this value: "+steeringNonce) - if functionSteered.Failure != "" || !functionSteered.SteeringConfirmed || !functionSteered.SteeringWritten || functionSteered.SteeringMilliseconds < 31000 || delayedRequests.Load() != 1 || functionSteered.FunctionCalls != 1 || functionSteered.AppliedResults != 1 || !strings.Contains(functionSteered.Text, steeringNonce) || !strings.Contains(functionSteered.Text, functionNonce) { - t.Fatalf("live function steering failed: %+v", functionSteered) - } - for _, completed := range []evidence{first, second, functionFirst, functionSecond, steered, steeredResume, functionSteered} { - verifyLiveUsageEvents(t, completed.Events) - } - mu.Lock() - before := len(requests) - mu.Unlock() - missing := run("Say hello.", uuid.NewString(), nil) - mu.Lock() - measured := append([]providerRequest{}, requests...) - mu.Unlock() - if !strings.Contains(missing.Failure, "history_unavailable") || missing.SessionID != "" || len(missing.NativePIDs) != 0 || len(measured) != before { - t.Fatalf("missing history did not fail before native/model start: %+v", missing) - } - if len(measured) < 2 { - t.Fatal("expected real model requests") - } - for _, request := range measured { - if request.Model != "MiniMax-M3" { - t.Fatalf("unexpected requested model %q", request.Model) - } - } - data, _ := json.MarshalIndent(map[string]any{"scope": "private Go factory -> official SDK -> real MiniMax with default typed execution controls, active input, native continuation and disabled environment/subagent tools; public API not enabled; no filesystem isolation claim", "turns": []evidence{first, second, functionFirst, functionSecond, steered, steeredResume, functionSteered}, "missing_history": missing, "model_requests": measured, "controlled_provider_delay_seconds": 31, "delayed_requests": delayedRequests.Load()}, "", " ") - if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { - t.Fatal(err) - } - t.Logf("real adapter proof: %s", filepath.Join(root, "proof.json")) -} diff --git a/apps/daemon/internal/agent/claudesdk/local.go b/apps/daemon/internal/agent/claudesdk/local.go deleted file mode 100644 index d3340f3f9..000000000 --- a/apps/daemon/internal/agent/claudesdk/local.go +++ /dev/null @@ -1,38 +0,0 @@ -package claudesdk - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" -) - -// ConfigureLocal selects the qualified, dedicated Runtime layout. The shared -// localworkspace binding still authorizes every request against its Session. -func ConfigureLocal(config Config, root, workspace string, network agentnetwork.Policy) (Config, error) { - config.StateDir = filepath.Join(root, "runtime", "claude-sdk", "history") - config.Workspace = &WorkspaceConfig{ - Directory: workspace, PublicDirectory: workspace, NetworkAccess: network.Access, AllowedDomains: network.Hosts(), - HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), - ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), - } - if network.Validate() != nil { - return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") - } - for _, dir := range []string{config.StateDir, config.Workspace.HomeDir, config.Workspace.ScratchDir} { - if err := os.MkdirAll(dir, 0700); err != nil { - return Config{}, err - } - } - config.Env = nil - for _, entry := range os.Environ() { - name, _, _ := strings.Cut(entry, "=") - if workspaceEnvName(name) { - config.Env = append(config.Env, entry) - } - } - _, _, err := workspaceEnvironment(config) - return config, err -} diff --git a/apps/daemon/internal/agent/claudesdk/local_test.go b/apps/daemon/internal/agent/claudesdk/local_test.go deleted file mode 100644 index 86ea1677a..000000000 --- a/apps/daemon/internal/agent/claudesdk/local_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package claudesdk - -import ( - "encoding/json" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" -) - -func TestLocalWorkspaceBindingAndRequiredHistory(t *testing.T) { - config := workspaceFixture(t) - config.Workspace.PublicDirectory = config.Workspace.Directory - config.Workspace.NetworkAccess = "enabled" - req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment"} - req.RequireExistingNativeSession = true - bound := prepared(t, req) - bound.WorkspaceRoot = config.Workspace.Directory - start, _, err := prepareConfiguration(config, bound) - if err != nil || !start.RequireHistory || start.Workspace.NetworkAccess != "enabled" { - t.Fatal(start, err) - } - config.Workspace.PublicDirectory = config.Workspace.HomeDir - if _, _, err := prepareConfiguration(config, bound); err == nil { - t.Fatal("accepted a different public workspace") - } - alias := filepath.Join(filepath.Dir(config.Workspace.Directory), "alias") - if err := os.Symlink(config.Workspace.Directory, alias); err != nil { - t.Fatal(err) - } - config.Workspace.PublicDirectory = alias - if _, _, err := prepareConfiguration(config, bound); err != nil { - t.Fatal("same workspace alias rejected", err) - } -} - -func TestRestrictedWorkspacePolicyIsRejected(t *testing.T) { - config := workspaceFixture(t) - config.Workspace.NetworkAccess = "restricted" - config.Workspace.AllowedDomains = []string{"api.example.com"} - if _, _, err := prepareConfiguration(config, prepared(t, workspaceRequest())); err == nil { - t.Fatal("Runtime must not promise inner network isolation") - } -} - -func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { - config := workspaceFixture(t) - original := slices.Clone(config.Env) - req := workspaceRequest() - req.ModelProvider = &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "https://provider.example/anthropic", APIKey: "selected-secret"} - start, env, err := prepareConfiguration(config, prepared(t, req)) - if err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(start) - if strings.Contains(string(raw), "selected-secret") || !slices.Equal(original, config.Env) { - t.Fatal("provider leaked or mutated shared configuration") - } - if !slices.Contains(env, "ANTHROPIC_API_KEY=selected-secret") || slices.ContainsFunc(env, func(entry string) bool { return strings.HasPrefix(entry, "ANTHROPIC_AUTH_TOKEN=") }) { - t.Fatal("provider selection was not exclusive") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/mcp.go b/apps/daemon/internal/agent/claudesdk/mcp.go index 7185da3bb..01d766131 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp.go +++ b/apps/daemon/internal/agent/claudesdk/mcp.go @@ -2,7 +2,6 @@ package claudesdk import ( "bytes" - "crypto/rand" "encoding/json" "fmt" "net/url" @@ -41,35 +40,13 @@ func validateMCPServers(servers []proto.MCPHTTPServer) error { return nil } -// Only generated references cross the private bridge; secrets stay in the owned -// process environment and are expanded by the native HTTP client. +// mcpHTTPServer is an HTTP MCP server as the bridge receives it: a +// credential-free endpoint, to which the Session's gateway adds any credential. type mcpHTTPServer struct { - ServerLabel string `json:"server_label"` - ServerURL string `json:"server_url,omitempty"` - AllowedTools *[]string `json:"allowed_tools"` - Required bool `json:"required,omitempty"` - BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` -} - -func prepareMCPHTTP(declarations *[]proto.MCPHTTPServer) (*[]mcpHTTPServer, []string) { - if declarations == nil { - return nil, nil - } - servers := make([]mcpHTTPServer, len(*declarations)) - var env []string - for i, declaration := range *declarations { - server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL, Required: declaration.Required} - if declaration.AllowedTools != nil { - tools := append([]string{}, (*declaration.AllowedTools)...) - server.AllowedTools = &tools - } - if declaration.BearerToken != nil { - server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() - env = append(env, server.BearerTokenEnvVar+"="+*declaration.BearerToken) - } - servers[i] = server - } - return &servers, env + ServerLabel string `json:"server_label"` + ServerURL string `json:"server_url,omitempty"` + AllowedTools *[]string `json:"allowed_tools"` + Required bool `json:"required,omitempty"` } type mcpState struct { diff --git a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go index 250de7702..76142fc34 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go @@ -1,80 +1,19 @@ package claudesdk import ( - "encoding/json" - "os" - "path/filepath" - "slices" - "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestMCPBearerUsesFreshOwnedEnvironmentReferences(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} - tokens := []string{"first.synthetic+/==", "second-synthetic_token~"} - tools := []string{"echo.v1"} - servers := []proto.MCPHTTPServer{ - {ConnectionOrigin: "service", ServerLabel: "first", ServerURL: "https://first.example/mcp", AllowedTools: &tools, BearerToken: &tokens[0]}, - {ConnectionOrigin: "service", ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, - {ConnectionOrigin: "service", ServerLabel: "anonymous", ServerURL: "http://anonymous.example/mcp"}, - } - req := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Model: "fixture"} - seen := map[string]bool{} - for range 2 { - start, env, err := prepareConfiguration(config, prepared(t, req)) - if err != nil { - t.Fatal(err) - } - raw, err := json.Marshal(start) - if err != nil { - t.Fatal(err) - } - for i, token := range tokens { - reference := (*start.MCPHTTPServers)[i].BearerTokenEnvVar - if !strings.HasPrefix(reference, "OAC_RUNTIME_MCP_BEARER_") || seen[reference] || !slices.Contains(env, reference+"="+token) { - t.Fatal("missing exact isolated credential or reused environment reference") - } - seen[reference] = true - if _, exists := os.LookupEnv(reference); exists { - t.Fatal("credential entered parent process environment") - } - if strings.Contains(string(raw), token) || !strings.Contains(string(raw), reference) { - t.Fatal("bridge serialization contains a secret or omitted its reference") - } - } - if (*start.MCPHTTPServers)[2].BearerTokenEnvVar != "" || strings.Contains(string(raw), `"bearer_token":`) { - t.Fatal("anonymous declaration or bridge secret boundary changed") - } - tools[0] = "changed" - if (*(*start.MCPHTTPServers)[0].AllowedTools)[0] != "echo.v1" { - t.Fatal("tool selection was not copied") - } - tools[0] = "echo.v1" - if servers[0].BearerToken != &tokens[0] || *servers[0].BearerToken != tokens[0] { - t.Fatal("caller credential changed") - } - } -} - -func TestMCPBearerRejectsInvalidCredentialBeforeStateCreation(t *testing.T) { +func TestMCPBearerRejectsInvalidCredential(t *testing.T) { for _, token := range []string{"", "=", " space", "space ", "has space", "line\r\ninjection", "nul\x00byte", "opaque中文", "middle=padding", "punctuation:invalid"} { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}} req := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Model: "fixture"} - if _, _, err := prepareConfiguration(config, prepared(t, req)); err == nil || err.Error() != "claudesdk: unsupported HTTPS MCP bearer credential" { + if _, _, err := prepareOptions(prepared(t, req)); err == nil || err.Error() != "claudesdk: unsupported HTTPS MCP bearer credential" { t.Fatal("invalid bearer accepted or unsafe error returned") } - entries, err := os.ReadDir(root) - if err != nil || len(entries) != 0 { - t.Fatal("invalid credential wrote execution state", err) - } } for _, url := range []string{"http://example.invalid/mcp", "https://example.invalid/mcp#", "https://example.invalid/mcp?", "https://user:secret@example.invalid/mcp"} { token := "synthetic-token" diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment.go b/apps/daemon/internal/agent/claudesdk/mcp_environment.go index e107d85d9..74b4ec4d8 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment.go @@ -1,55 +1,33 @@ package claudesdk -import ( - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) +import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" // Environment servers originate in frozen installed packages. Only native // transport projection crosses this private bridge, never package configuration. type environmentMCPServer struct { mcpHTTPServer - Command string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` -} - -func prepareRuntimeMCP(req agent.PrepareRequest) ([]environmentMCPServer, []string, error) { - bindings, err := agent.ResolveMCPBindings(req) - if err != nil { - return nil, nil, err - } - return mcpServers(bindings, localworkspace.MCPStdioCommand) + Command string `json:"command,omitempty"` } -// mcpServers renders resolved bindings, each stdio binding with the command -// and arguments stdio gives it and each credential in a private environment -// variable. -func mcpServers(bindings []agent.MCPBinding, stdio func(agent.EnvironmentMCP) (string, []string)) ([]environmentMCPServer, []string, error) { +// mcpServers renders a view's bindings: each HTTP binding as its gateway +// endpoint, and each stdio binding as its alias, which the Harness runs +// without arguments. +func mcpServers(bindings []agent.MCPBinding) []environmentMCPServer { var servers []environmentMCPServer - var env []string for _, binding := range bindings { + server := environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel}} if binding.Stdio != nil { - command, args := stdio(*binding.Stdio) - servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel}, Command: command, Args: args}) - continue + server.Command = binding.Stdio.Server.Command + } else { + server.ServerURL, server.Required = binding.ServerURL, binding.Required + if binding.AllowedTools != nil { + tools := append([]string{}, (*binding.AllowedTools)...) + server.AllowedTools = &tools + } } - // Native header interpolation and redirect behavior cannot preserve literal - // custom-header authority. Reject this unqualified combination explicitly. - if len(binding.HTTPHeaders) != 0 { - return nil, nil, fmt.Errorf("claudesdk: literal MCP HTTP headers are not supported") - } - declarations := []proto.MCPHTTPServer{{ServerLabel: binding.ServerLabel, ServerURL: binding.ServerURL, AllowedTools: binding.AllowedTools, Required: binding.Required, BearerToken: binding.BearerToken}} - if err := validateMCPServers(declarations); err != nil { - return nil, nil, err - } - projected, credentials := prepareMCPHTTP(&declarations) - servers = append(servers, environmentMCPServer{mcpHTTPServer: (*projected)[0]}) - env = append(env, credentials...) + servers = append(servers, server) } - return servers, env, nil + return servers } func (start startRequest) declaredMCP() []mcpHTTPServer { diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go index ac8800552..c239776ee 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -2,78 +2,11 @@ package claudesdk import ( "encoding/json" - "os" - "strings" "testing" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) -func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) { - config := workspaceFixture(t) - config.Workspace.NetworkAccess = "enabled" - req := workspaceRequest() - token := "selected-user-token" - t.Setenv("MCP_TOKEN", "unselected-native-token") - req.LocalEnvironment = &proto.LocalEnvironment{} - bound := prepared(t, req) - bound.CapabilityRoot, bound.WorkspaceRoot = "/private/runtime/capabilities", config.Workspace.Directory - bound.MCP = []agent.EnvironmentMCP{ - {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/local", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "untrusted-package-command", Args: []string{"package-argument"}, EnvVars: []string{"MCP_TOKEN"}}}, - {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp", BearerTokenEnvVar: "MCP_TOKEN"}, BearerToken: &token}, - } - start, env, err := prepareConfiguration(config, bound) - if err != nil { - t.Fatal(err) - } - if start.MCPHTTPServers != nil || len(start.Workspace.MCP) != 2 || start.Workspace.CapabilityRoot != bound.CapabilityRoot { - t.Fatal("environment declarations changed authority") - } - stdio := start.Workspace.MCP[0] - executable, _ := os.Executable() - if stdio.Command != executable || len(stdio.Args) != 4 || stdio.Args[0] != "runtime-mcp-exec" || stdio.Args[1] != "/private/runtime/capabilities" || stdio.Args[2] != "plugins/local" || stdio.Args[3] != "local" { - t.Fatal("stdio bypassed the shared installed entry") - } - raw, _ := json.Marshal(start) - for _, forbidden := range []string{token, "unselected-native-token", "untrusted-package-command", "package-argument", `"MCP_TOKEN"`} { - if strings.Contains(string(raw), forbidden) { - t.Fatal("private request contains package input or credential values") - } - } - reference := start.Workspace.MCP[1].BearerTokenEnvVar - found := false - for _, entry := range env { - if entry == reference+"="+token { - found = true - } - } - if !found || !strings.HasPrefix(reference, "OAC_RUNTIME_MCP_BEARER_") || len(start.declaredMCP()) != 2 { - t.Fatal("selected credential or observation declarations missing") - } -} - -func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { - for _, mutate := range []func(*agent.PrepareRequest){ - func(r *agent.PrepareRequest) { r.MCP = append(r.MCP, r.MCP[0]) }, - func(r *agent.PrepareRequest) { r.MCP[0].Server.Type = "sse" }, - func(r *agent.PrepareRequest) { r.MCP[0].Server.HTTPHeaders = map[string]string{"X-Key": "literal"} }, - func(r *agent.PrepareRequest) { r.MCP[0].Server.BearerTokenEnvVar = "MISSING" }, - func(r *agent.PrepareRequest) { - token := "token" - r.MCP[0].BearerToken = &token - r.MCP[0].Server.URL = "http://example.invalid/mcp" - }, - } { - req := agent.PrepareRequest{PromptRequestPayload: proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}}, MCP: []agent.EnvironmentMCP{{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}} - mutate(&req) - if _, _, err := prepareRuntimeMCP(req); err == nil { - t.Fatal("unsupported declaration accepted") - } - } -} - func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { start := startRequest{Workspace: &workspaceProfile{MCP: []environmentMCPServer{{mcpHTTPServer: mcpHTTPServer{ServerLabel: "installed"}}}}} state := mcpState{calls: map[string]proto.ToolObservation{}} diff --git a/apps/daemon/internal/agent/claudesdk/mcp_test.go b/apps/daemon/internal/agent/claudesdk/mcp_test.go index 1abb522f8..a058a9d2a 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_test.go @@ -2,7 +2,6 @@ package claudesdk import ( "encoding/json" - "path/filepath" "strings" "testing" @@ -12,9 +11,6 @@ import ( func TestHTTPMCPDeclaration(t *testing.T) { for _, mode := range []string{"unrestricted", "selected", "empty", "nil-slice", "required", "auth", "url-auth", "query", "duplicate", "environment"} { t.Run(mode, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}} req := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Model: "fixture"} tools := []string{"echo"} @@ -41,7 +37,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { case "environment": req.DisableExecutionEnvironment = false } - start, _, err := prepareConfiguration(config, prepared(t, req)) + start, _, err := prepareTestView(t, prepared(t, req)) valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" if (err == nil) != valid { t.Fatalf("unexpected admission: %v", err) diff --git a/apps/daemon/internal/agent/claudesdk/messages_test.go b/apps/daemon/internal/agent/claudesdk/messages_test.go index ef392280f..0e8d9c7de 100644 --- a/apps/daemon/internal/agent/claudesdk/messages_test.go +++ b/apps/daemon/internal/agent/claudesdk/messages_test.go @@ -18,9 +18,8 @@ func TestMessageObservations(t *testing.T) { for _, mode := range []string{"messages-success", "messages-partial", "messages-missing-id", "messages-invalid-snapshot"} { t.Run(mode, func(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} + config := testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, Home: root} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index a74d87d7f..81106aeba 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -2,21 +2,18 @@ package claudesdk import ( "fmt" - "os" - "path/filepath" - "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) +// Config locates the installed bridge: the Node executable and the bridge's +// absolute entrypoint. Env adds to the runtime check's environment, as the +// native installer's check sets it. type Config struct { Node string Entrypoint string - StateDir string Env []string - Workspace *WorkspaceConfig } type subagentOptions struct { @@ -39,58 +36,6 @@ type startRequest struct { RequireHistory bool `json:"require_history,omitempty"` } -func prepareConfiguration(config Config, req agent.PrepareRequest) (startRequest, []string, error) { - start, provider, err := prepareOptions(req) - if err != nil { - return startRequest{}, nil, err - } - if !filepath.IsAbs(config.Entrypoint) { - return startRequest{}, nil, fmt.Errorf("claudesdk: SDK entrypoint must be absolute") - } - config.Env = withProvider(config.Env, provider) - if config.Workspace != nil { - profile, env, err := prepareWorkspace(config, req) - if err != nil { - return startRequest{}, nil, err - } - start.Workspace = profile - start.Cwd = workspaceCwd(config.Workspace) - return start, env, nil - } - if req.LocalEnvironment != nil { - return startRequest{}, nil, fmt.Errorf("claudesdk: local execution requires a dedicated workspace") - } - root, err := paths.Root() - if err != nil { - return startRequest{}, nil, err - } - relative, err := filepath.Rel(root, config.StateDir) - if err != nil || !filepath.IsAbs(root) || !filepath.IsAbs(config.StateDir) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return startRequest{}, nil, fmt.Errorf("claudesdk: SDK state must be in a managed runtime subdirectory") - } - start.Cwd = filepath.Join(config.StateDir, "work") - for _, dir := range []string{config.StateDir, filepath.Join(config.StateDir, "tmp"), start.Cwd} { - if err := os.MkdirAll(dir, 0o700); err != nil { - return startRequest{}, nil, err - } - } - env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) - env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") - projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) - if err != nil { - return startRequest{}, nil, err - } - if req.MCPHTTPServers != nil { - servers := make([]mcpHTTPServer, 0, len(projectedMCP)) - for _, server := range projectedMCP { - servers = append(servers, server.mcpHTTPServer) - } - start.MCPHTTPServers = &servers - } - env = append(env, mcpEnv...) - return start, env, nil -} - // prepareOptions renders the request's execution configuration and the // selected model provider. The registered factory already admitted the // selection against the declaration. diff --git a/apps/daemon/internal/agent/claudesdk/options_test.go b/apps/daemon/internal/agent/claudesdk/options_test.go index afd9971a9..d1103eb3d 100644 --- a/apps/daemon/internal/agent/claudesdk/options_test.go +++ b/apps/daemon/internal/agent/claudesdk/options_test.go @@ -1,11 +1,12 @@ package claudesdk import ( - "path/filepath" + "errors" "slices" "testing" "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/modelprovider" ) @@ -14,16 +15,39 @@ import ( func TestPreparedModelAndProviderReachTheBridge(t *testing.T) { t.Setenv("ANTHROPIC_API_KEY", "device-key") for _, prompt := range []string{"", "instructions"} { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} - start, env, err := prepareConfiguration(config, prepared(t, proto.PromptRequestPayload{ModelProvider: fixtureProvider(), Model: "test-model", SystemPrompt: prompt})) - if err != nil || start.Model != "test-model" || start.SystemPrompt != prompt || !slices.Contains(env, "ANTHROPIC_API_KEY=fixture-key") { + start, env, err := prepareTestView(t, prepared(t, proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, Model: "test-model", SystemPrompt: prompt})) + if err != nil || start.Model != "test-model" || start.SystemPrompt != prompt || !slices.Contains(env, "ANTHROPIC_API_KEY=fixture-key") || slices.Contains(env, "ANTHROPIC_API_KEY=device-key") { t.Fatalf("model %q, system prompt %q, error %v", start.Model, start.SystemPrompt, err) } } } +// testProxy is the gateway proxy of a test view. +const testProxy = "http://127.0.0.1:9" + +// viewMCP is req's MCP as the agent host gives it to a view: the gateway +// keeps each credential and header. +func viewMCP(req agent.PrepareRequest) ([]agent.MCPBinding, error) { + bindings, err := agent.ResolveMCPBindings(req) + for i := range bindings { + bindings[i].BearerToken, bindings[i].HTTPHeaders = nil, nil + } + return bindings, err +} + +// prepareTestView prepares req as a view Executor does, in a new Session +// home, without launching the bridge. +func prepareTestView(t testing.TB, req agent.PrepareRequest) (startRequest, []string, error) { + t.Helper() + mcp, err := viewMCP(req) + if err != nil { + return startRequest{}, nil, err + } + home := t.TempDir() + return prepareView(viewLayout{node: "/node", bridge: "/bridge.js"}, req, agent.ViewSession{Home: agent.ViewDir{Host: home, View: home}, Proxy: testProxy, MCP: mcp, + Launch: func(clirunner.StartOptions) (*clirunner.Process, error) { return nil, errors.New("not launched") }}) +} + // prepared is req as the registry hands it to the factory. func prepared(t testing.TB, req proto.PromptRequestPayload) agent.PrepareRequest { t.Helper() diff --git a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 65b9baf85..f120b9506 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -14,9 +14,32 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func preparationFixture(t *testing.T, mode string) Config { +// workspaceBridge is an installed bridge fixture with a Session home and a +// local Environment's workspace. +func workspaceBridge(t *testing.T) testBridge { t.Helper() - config := workspaceFixture(t) + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"workspace", "home", "bin", "runtime/dist"} { + if err := os.MkdirAll(filepath.Join(root, name), 0o700); err != nil { + t.Fatal(err) + } + } + config := testBridge{Config: Config{Node: filepath.Join(root, "bin", "node"), Entrypoint: filepath.Join(root, "runtime", "dist", "main.js")}, + Home: filepath.Join(root, "home"), Workspace: filepath.Join(root, "workspace")} + for _, name := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { + if err := os.WriteFile(name, nil, 0o700); err != nil { + t.Fatal(err) + } + } + return config +} + +func preparationFixture(t *testing.T, mode string) testBridge { + t.Helper() + config := workspaceBridge(t) binary, err := filepath.EvalSymlinks(os.Args[0]) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 255a15222..271212858 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -24,14 +24,14 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { result := make(chan agent.Executor, 1) failed := make(chan error, 1) go func() { - e, err := NewExecutorFactory(config)(ctx, prepared(t, req)) + e, err := config.factory()(ctx, prepared(t, req)) if err != nil { failed <- err return } result <- e }() - raw := waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")) + raw := waitPreparationFile(t, filepath.Join(config.StateDir(), "prepare.json")) select { case <-result: t.Fatal("preparation returned before its native receipt") @@ -39,7 +39,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { t.Fatal(err) default: } - if err := os.WriteFile(filepath.Join(config.StateDir, "ready"), nil, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(config.StateDir(), "ready"), nil, 0o600); err != nil { t.Fatal(err) } var resource agent.Executor @@ -53,7 +53,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { e := resource.(*executor) defer e.Close(context.Background()) pid := e.base.process.Cmd.Process.Pid - if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(config.StateDir(), "start.json")); !os.IsNotExist(err) { t.Fatal("preparation submitted input") } var frozen startRequest @@ -63,8 +63,6 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { if frozen.Model != "fixture" || frozen.Resume != "native-session" || frozen.Workspace == nil { t.Fatal("configuration-only request was not retained") } - config.Env[0] = "HTTPS_PROXY=http://changed.example" - config.Workspace.Directory = "/changed" req.Model = "changed" req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) @@ -91,30 +89,28 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { if done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { t.Fatal("prepared execution lost ordinary output or frozen resume", done) } - if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { + if _, err := os.Stat(filepath.Join(config.StateDir(), "released")); err != nil { t.Fatal("completion preceded process release", err) } } func TestPreparationRejectsUnavailableProfilesBeforeLaunch(t *testing.T) { - for _, name := range []string{"subagents", "none", "functions", "mcp", "old-runtime"} { + for _, name := range []string{"subagents", "functions", "mcp", "old-runtime"} { t.Run(name, func(t *testing.T) { config := preparationFixture(t, name) req := preparationRequest() switch name { case "subagents": req.ObserveSubagentIdentities = true - case "none": - req.DisableExecutionEnvironment = true case "functions": req.FunctionTools = []proto.FunctionTool{{Name: "hello", Parameters: json.RawMessage(`{"type":"object"}`)}} case "mcp": req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} } - if _, err := NewExecutorFactory(config)(t.Context(), prepared(t, req)); err == nil { + if _, err := config.factory()(t.Context(), prepared(t, req)); err == nil { t.Fatal("invalid preparation was accepted") } - if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(config.StateDir(), "launched")); !os.IsNotExist(err) { t.Fatal("rejection launched native preparation") } }) @@ -127,7 +123,7 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { config := preparationFixture(t, mode) owner, stop := context.WithCancel(t.Context()) defer stop() - resource, err := NewExecutorFactory(config)(owner, prepared(t, preparationRequest())) + resource, err := config.factory()(owner, prepared(t, preparationRequest())) if mode == "history-missing" || mode == "invalid-receipt" { if err == nil || mode == "history-missing" && !strings.Contains(err.Error(), "history_unavailable") { t.Fatal("preparation failure was lost", err) diff --git a/apps/daemon/internal/agent/claudesdk/provider.go b/apps/daemon/internal/agent/claudesdk/provider.go deleted file mode 100644 index 64d31cb4b..000000000 --- a/apps/daemon/internal/agent/claudesdk/provider.go +++ /dev/null @@ -1,16 +0,0 @@ -package claudesdk - -import "strings" - -// withProvider replaces every model credential and endpoint in env with the -// Session's provider. -func withProvider(env, provider []string) []string { - out := make([]string, 0, len(env)+len(provider)) - for _, entry := range env { - key, _, _ := strings.Cut(entry, "=") - if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" && key != "CLAUDE_CODE_OAUTH_TOKEN" { - out = append(out, entry) - } - } - return append(out, provider...) -} diff --git a/apps/daemon/internal/agent/claudesdk/readiness.go b/apps/daemon/internal/agent/claudesdk/readiness.go index 33e24a426..09b2959a3 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness.go +++ b/apps/daemon/internal/agent/claudesdk/readiness.go @@ -104,19 +104,11 @@ func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) { if binary == "" { binary = "node" } - env := append(append([]string{}, os.Environ()...), config.Env...) - if config.Workspace != nil { - var err error - _, env, err = workspaceEnvironment(config) - if err != nil { - return RuntimeInfo{}, err - } - } process, err := clirunner.Start(clirunner.StartOptions{ Parent: ctx, Binary: binary, Args: []string{filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), config.Entrypoint}, Dir: filepath.Dir(config.Entrypoint), - Env: env, + Env: append(os.Environ(), config.Env...), KillTimeout: 250 * time.Millisecond, }) if err != nil { diff --git a/apps/daemon/internal/agent/claudesdk/readiness_test.go b/apps/daemon/internal/agent/claudesdk/readiness_test.go index cd5ec52ed..b1ebb0565 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/daemon/internal/agent/claudesdk/readiness_test.go @@ -19,10 +19,9 @@ const readyReport = `{"type":"runtime_ready","protocol":3,"node":"22.22.2","sdk" func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ + config := testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), Env: []string{ "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready-http-mcp", "GORACE=atexit_sleep_ms=0", - }} + }}, Home: root} req := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, Model: "fixture", MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", Required: true}}} if _, err := startSingleTurn(t.Context(), config, req, "run", proto.TextInput("hello"), make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support required HTTP MCP" { @@ -32,11 +31,10 @@ func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { func TestHTTPMCPRejectsOldPackagedRuntime(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ + config := testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), Env: []string{ "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready", "GORACE=atexit_sleep_ms=0", - }} - info, err := CheckRuntime(t.Context(), config) + }}, Home: root} + info, err := CheckRuntime(t.Context(), config.Config) if err != nil || info.SupportsHTTPMCP() { t.Fatal("old runtime acquired MCP support", err) } @@ -74,20 +72,6 @@ func TestRuntimeReadiness(t *testing.T) { } } -func TestMCPBearerRejectsAnonymousOnlyRuntimeWithoutProbeSecrets(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ - "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready-http-mcp", "GORACE=atexit_sleep_ms=0", - }} - token := "private-fixture-token" - req := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, - Model: "fixture", MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}}} - if _, err := startSingleTurn(t.Context(), config, req, "run", proto.TextInput("hello"), make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support authenticated HTTP MCP" { - t.Fatalf("old runtime executed authenticated request or readiness received its secret: %v", err) - } -} - func TestRuntimeReadinessRejectsPathsAndMissingNode(t *testing.T) { for _, config := range []Config{ {Node: "must-not-start", Entrypoint: "relative/main.js"}, @@ -123,11 +107,6 @@ func runReadinessHelper() { case "ready": _, _ = fmt.Fprintln(os.Stdout, readyReport) case "ready-http-mcp": - for _, value := range os.Environ() { - if strings.HasPrefix(value, "OAC_RUNTIME_MCP_BEARER_") { - os.Exit(5) - } - } _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":3`, `"protocol":3,"features":["mcp_http_tools"]`, 1)) case "malformed": _, _ = fmt.Fprintln(os.Stdout, "not-json") diff --git a/apps/daemon/internal/agent/claudesdk/restrictions_test.go b/apps/daemon/internal/agent/claudesdk/restrictions_test.go index 9232c4882..f8c96699f 100644 --- a/apps/daemon/internal/agent/claudesdk/restrictions_test.go +++ b/apps/daemon/internal/agent/claudesdk/restrictions_test.go @@ -14,18 +14,17 @@ import ( func TestTextTurnAcceptsRestrictiveCapabilities(t *testing.T) { for _, test := range []struct { - name string - environment, subagents bool - controls *proto.ExecutionControls + name string + subagents bool + controls *proto.ExecutionControls }{ - {"environment", true, false, nil}, {"subagents", false, true, nil}, {"both", true, true, nil}, - {"execution-controls", true, true, &proto.ExecutionControls{TextVerbosity: "medium"}}, + {"environment", false, nil}, {"subagents", true, nil}, + {"execution-controls", true, &proto.ExecutionControls{TextVerbosity: "medium"}}, } { t.Run(test.name, func(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=success", "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native-session", DisableExecutionEnvironment: test.environment, DisableSubagents: test.subagents, ExecutionControls: test.controls, Model: "fake-model", SystemPrompt: "instructions"} + config := testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=success", "GORACE=atexit_sleep_ms=0"}}, Home: root} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native-session", DisableExecutionEnvironment: true, DisableSubagents: test.subagents, ExecutionControls: test.controls, Model: "fake-model", SystemPrompt: "instructions"} ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 7bf1beb3d..59d02117c 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -1,7 +1,6 @@ package claudesdk import ( - "context" "encoding/json" "fmt" "sync" @@ -57,16 +56,7 @@ type bridgeEvent struct { Observation *proto.ToolObservation `json:"observation"` } -func launch(ctx context.Context, config Config, start startRequest, env []string) (*session, error) { - binary := config.Node - if binary == "" { - binary = "node" - } - return startSession(clirunner.Start, clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true}) -} - -// startSession runs the bridge through start: clirunner.Start, or an agent-host -// view's Launch. +// startSession runs the bridge through start, the agent-host view's Launch. func startSession(start func(clirunner.StartOptions) (*clirunner.Process, error), options clirunner.StartOptions) (*session, error) { process, err := start(options) if err != nil { diff --git a/apps/daemon/internal/agent/claudesdk/session_test.go b/apps/daemon/internal/agent/claudesdk/session_test.go index 9069f23dd..6051092f7 100644 --- a/apps/daemon/internal/agent/claudesdk/session_test.go +++ b/apps/daemon/internal/agent/claudesdk/session_test.go @@ -20,9 +20,8 @@ func TestTextTurnCompletionAndFailures(t *testing.T) { for _, mode := range []string{"success", "wrong-resume", "missing", "malformed", "process-failed", "after-result", "bridge-error"} { t.Run(mode, func(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} + config := testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, Home: root} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) @@ -53,7 +52,7 @@ func TestTextTurnCompletionAndFailures(t *testing.T) { if payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || deltas != "partial" { t.Fatalf("bad completion: %+v, deltas %q", payload, deltas) } - if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { + if _, err := os.Stat(filepath.Join(config.StateDir(), "released")); err != nil { t.Fatal("Done preceded process release") } } else if payload.Metadata[proto.DoneMetaAgentSessionID] != nil { @@ -69,23 +68,12 @@ func TestTextTurnCompletionAndFailures(t *testing.T) { } func TestUnsupportedRequestRejectedBeforeLaunch(t *testing.T) { - for _, kind := range []string{"tool", "outside"} { - t.Run(kind, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), Model: "fake"} - switch kind { - case "tool": - request.FunctionTools = []proto.FunctionTool{{}} - case "outside": - config.StateDir = filepath.Dir(root) - } - _, err := startSingleTurn(context.Background(), config, request, "run", proto.TextInput("hello"), make(chan proto.Envelope, 1)) - if err == nil || !strings.HasPrefix(err.Error(), "claudesdk:") { - t.Fatalf("expected pre-launch rejection, got %v", err) - } - }) + root := t.TempDir() + config := testBridge{Config: Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker")}, Home: root} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, Model: "fake", FunctionTools: []proto.FunctionTool{{}}} + _, err := startSingleTurn(context.Background(), config, request, "run", proto.TextInput("hello"), make(chan proto.Envelope, 1)) + if err == nil || !strings.HasPrefix(err.Error(), "claudesdk:") { + t.Fatalf("expected pre-launch rejection, got %v", err) } } diff --git a/apps/daemon/internal/agent/claudesdk/steering_test.go b/apps/daemon/internal/agent/claudesdk/steering_test.go index d855838c7..f050c647f 100644 --- a/apps/daemon/internal/agent/claudesdk/steering_test.go +++ b/apps/daemon/internal/agent/claudesdk/steering_test.go @@ -22,12 +22,11 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { for _, mode := range []string{"success", "phased", "timeout", "wrong-receipt", "duplicate-usage", "cancel", "blocked-write"} { t.Run(mode, func(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=steering-" + mode, "GORACE=atexit_sleep_ms=0"}} + config := testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=steering-" + mode, "GORACE=atexit_sleep_ms=0"}}, Home: root} if mode == "phased" { config.Env[1] = "SDK_HELPER_MODE=steering-timeout" } - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native", Model: "fake-model", SystemPrompt: "instructions"} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, AgentSessionID: "native", Model: "fake-model", SystemPrompt: "instructions"} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/subagents_test.go b/apps/daemon/internal/agent/claudesdk/subagents_test.go index 6475ad9e4..badb8b3cb 100644 --- a/apps/daemon/internal/agent/claudesdk/subagents_test.go +++ b/apps/daemon/internal/agent/claudesdk/subagents_test.go @@ -7,20 +7,19 @@ import ( ) func TestSubagentConfigurationUsesExplicitRequestAndFrozenLimit(t *testing.T) { - config := workspaceFixture(t) req := workspaceRequest() - start, _, err := prepareConfiguration(config, prepared(t, req)) + start, _, err := prepareOptions(prepared(t, req)) if err != nil || start.Subagents != nil { t.Fatal("ordinary execution changed", err) } req.DisableSubagents, req.ObserveSubagentIdentities = false, true - start, _, err = prepareConfiguration(config, prepared(t, req)) + start, _, err = prepareOptions(prepared(t, req)) if err != nil || start.Subagents == nil || start.Subagents.MaxConcurrent != 6 { t.Fatal("missing default native admission limit", err) } limit := 2 req.MaxConcurrentSubagents = &limit - start, _, err = prepareConfiguration(config, prepared(t, req)) + start, _, err = prepareOptions(prepared(t, req)) limit = 4 if err != nil || start.Subagents.MaxConcurrent != 2 { t.Fatal("subagent configuration was not frozen", err) @@ -28,7 +27,6 @@ func TestSubagentConfigurationUsesExplicitRequestAndFrozenLimit(t *testing.T) { } func TestSubagentConfigurationRejectsUnqualifiedAuthority(t *testing.T) { - config := workspaceFixture(t) for _, change := range []func(*proto.PromptRequestPayload){ func(r *proto.PromptRequestPayload) { r.DisableSubagents = true }, func(r *proto.PromptRequestPayload) { n := 0; r.MaxConcurrentSubagents = &n }, @@ -36,7 +34,7 @@ func TestSubagentConfigurationRejectsUnqualifiedAuthority(t *testing.T) { req := workspaceRequest() req.DisableSubagents, req.ObserveSubagentIdentities = false, true change(&req) - if _, _, err := prepareConfiguration(config, prepared(t, req)); err == nil { + if _, _, err := prepareOptions(prepared(t, req)); err == nil { t.Fatal("unqualified subagent combination accepted") } } diff --git a/apps/daemon/internal/agent/claudesdk/tool_environment_test.go b/apps/daemon/internal/agent/claudesdk/tool_environment_test.go deleted file mode 100644 index c809688bd..000000000 --- a/apps/daemon/internal/agent/claudesdk/tool_environment_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package claudesdk - -import ( - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestSelfHostedToolEnvironment(t *testing.T) { - config := workspaceFixture(t) - config.Workspace.NetworkAccess = "enabled" - file := filepath.Join(t.TempDir(), "tool-env.json") - if err := os.WriteFile(file, []byte(`{"USER_VALUE":"ready"}`), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) - req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{} - bound := prepared(t, req) - bound.WorkspaceRoot = config.Workspace.Directory - profile, _, err := prepareWorkspace(config, bound) - if err != nil { - t.Fatal(err) - } - if profile.ToolEnv["USER_VALUE"] != "ready" { - t.Fatal("self-hosted tool configuration was not applied") - } - if err := os.WriteFile(file, []byte(`[]`), 0600); err != nil { - t.Fatal(err) - } - if _, _, err := prepareWorkspace(config, bound); err == nil { - t.Fatal("invalid explicit tool configuration was ignored") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/usage_test.go b/apps/daemon/internal/agent/claudesdk/usage_test.go index 420119b75..18787eb4f 100644 --- a/apps/daemon/internal/agent/claudesdk/usage_test.go +++ b/apps/daemon/internal/agent/claudesdk/usage_test.go @@ -21,9 +21,8 @@ func TestUsageTransportPreservesSnapshotOnFailureAndDone(t *testing.T) { for _, mode := range []string{"success", "native-error", "process-error", "missing", "malformed", "duplicate", "wrong-session", "changed-result"} { t.Run(mode, func(t *testing.T) { root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=usage-" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} + config := testBridge{Config: Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=usage-" + mode, "GORACE=atexit_sleep_ms=0"}}, Home: root} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index bbbb7839e..2510e61bf 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -113,12 +113,8 @@ func prepareView(layout viewLayout, req agent.PrepareRequest, view agent.ViewSes if (environment == nil) != req.DisableExecutionEnvironment || environment != nil && !workspacePathSyntax(req.WorkspaceRoot) || view.Launch == nil || view.Proxy == "" { return startRequest{}, nil, errors.New("claudesdk: a view Executor requires the sandbox workspace or environment none, Launch and the gateway proxy") } - // The gateway adds each credential and header, and the Harness runs each - // stdio alias without arguments. - servers, _, err := mcpServers(view.MCP, func(stdio agent.EnvironmentMCP) (string, []string) { return stdio.Server.Command, nil }) - if err != nil { - return startRequest{}, nil, err - } + // The gateway adds each credential and header. + servers := mcpServers(view.MCP) start, provider, err := prepareOptions(req) if err != nil { return startRequest{}, nil, err diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index cf87aef87..49a8b0ff0 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -88,7 +88,7 @@ func TestViewExecutorLaunchesAClosedGatewayEnvironment(t *testing.T) { var start startRequest if err := json.Unmarshal(request, &start); err != nil || start.Type != "executor_prepare" || start.Cwd != "/workspace" || start.Workspace == nil || start.Workspace.Home != env["HOME"] || start.Workspace.State != env["CLAUDE_CONFIG_DIR"] || len(start.Workspace.MCP) != 1 || - start.Workspace.MCP[0].ServerURL != "http://127.0.0.1:17102/mcp/docs" || start.Workspace.MCP[0].BearerTokenEnvVar != "" { + start.Workspace.MCP[0].ServerURL != "http://127.0.0.1:17102/mcp/docs" { t.Fatalf("bridge request = %s, %v", request, err) } for _, name := range viewHomeDirs { @@ -123,7 +123,7 @@ func TestViewExecutorLaunchesAClosedGatewayEnvironment(t *testing.T) { 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 || + stdioStart.Workspace.MCP[0].Command != agent.ViewAlias(0) || 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) } @@ -187,8 +187,10 @@ func resolveTestView(t *testing.T) agent.View { lib := agent.ViewMount{Name: viewloader.MountName, HostDir: filepath.Join(root, "lib")} loader := viewloader.Fragment{Closure: []agent.ViewMount{lib}, Overlays: []agent.ViewOverlay{{Path: "/lib64/ld-linux-x86-64.so.2", Source: filepath.Join(root, "lib", "ld.so"), Exec: true}}, LibraryPath: lib.Path()} declared := declareView(probe, RuntimeInfo{NativePath: "native/claude"}, probe.Node, filepath.Join(root, "bundle"), "dist/main.js", loader) + info := Declaration.Info + info.Available = true registry := agent.NewRegistry() - registry.Register(Declaration, agent.Runtime{Info: Declaration.Info, View: declared}, agent.EnvironmentSupport{Local: true, None: true}) + registry.Register(Declaration, agent.Runtime{Info: info, View: declared}, agent.EnvironmentSupport{Local: true, None: true}) view, err := registry.ResolveView(Declaration.Info.Kind) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go index bd41cd035..a37d8b734 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace.go +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -1,32 +1,16 @@ package claudesdk import ( - "fmt" - "os" "path/filepath" "strings" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" ) -// WorkspaceConfig binds one trusted private placement. It does not create an -// isolation boundary or authorize a public Environment. The operator must place -// the entire factory inside the qualified outer mount/process boundary first. -// State directories must already exist. -// PublicDirectory may name a second mount of the same workspace inode. -type WorkspaceConfig struct { - Directory string - PublicDirectory string - NetworkAccess string - AllowedDomains []string - HomeDir string - ScratchDir string -} - +// workspaceProfile is the bridge's workspace execution profile: the native +// directories, the names of the environment entries the Harness keeps, and +// the Environment's installed MCP and Skills. type workspaceProfile struct { - ToolEnv map[string]string `json:"tool_env,omitempty"` CapabilityRoot string `json:"capability_root,omitempty"` MCP []environmentMCPServer `json:"mcp,omitempty"` Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` @@ -38,115 +22,10 @@ type workspaceProfile struct { AllowedDomains []string `json:"allowed_domains,omitempty"` } -func prepareWorkspace(config Config, req agent.PrepareRequest) (*workspaceProfile, []string, error) { - if req.DisableExecutionEnvironment { - return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") - } - if req.LocalEnvironment != nil && req.WorkspaceRoot != config.Workspace.Directory { - return nil, nil, fmt.Errorf("claudesdk: workspace root conflicts with the trusted workspace binding") - } - profile, env, err := workspaceEnvironment(config) - if err != nil { - return nil, nil, err - } - if req.LocalEnvironment != nil { - profile.ToolEnv, err = localworkspace.ReadOptionalToolEnvironment() - if err != nil { - return nil, nil, err - } - } - - if req.LocalEnvironment != nil { - profile.Skills = req.Skills - profile.CapabilityRoot = req.CapabilityRoot - } - servers, credentials, err := prepareRuntimeMCP(req) - if err != nil { - return nil, nil, err - } - profile.MCP = servers - return profile, append(env, credentials...), nil -} - -func workspaceCwd(w *WorkspaceConfig) string { - if w.PublicDirectory != "" { - return w.PublicDirectory - } - return w.Directory -} - -// Harness state paths and selected model credentials overlay the user environment. -func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { - fail := func() (*workspaceProfile, []string, error) { - return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") - } - w := config.Workspace - if w == nil || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { - return fail() - } - for _, path := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { - info, err := os.Stat(path) - if !filepath.IsAbs(path) || err != nil || !info.Mode().IsRegular() { - return fail() - } - } - for _, path := range []string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir} { - if !canonicalWorkspaceDir(path) { - return fail() - } - } - if w.PublicDirectory != "" { - actual, err := os.Stat(w.Directory) - alias, aliasErr := os.Stat(w.PublicDirectory) - if err != nil || aliasErr != nil || !os.SameFile(actual, alias) { - return fail() - } - } - profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} - env := []string{} - for _, entry := range os.Environ() { - name, _, _ := strings.Cut(entry, "=") - if name != "ANTHROPIC_API_KEY" && name != "ANTHROPIC_AUTH_TOKEN" && name != "CLAUDE_CODE_OAUTH_TOKEN" && name != "CLAUDE_CONFIG_DIR" && name != "HOME" && name != "TMPDIR" { - env = append(env, entry) - } - } - env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir) - env = append(env, nativeFlags...) - seen := map[string]bool{} - for _, entry := range config.Env { - name, _, ok := strings.Cut(entry, "=") - if !ok || seen[name] || strings.ContainsRune(entry, '\x00') || !workspaceEnvName(name) { - return fail() - } - seen[name] = true - profile.EnvNames = append(profile.EnvNames, name) - env = append(env, entry) - } - return profile, env, nil -} - // nativeFlags turn off Claude Code's telemetry, error reports, updates and -// background work in a workspace profile. +// background work. var nativeFlags = []string{"DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1"} -func workspaceEnvName(name string) bool { - switch name { - case "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": - return true - default: - return false - } -} - -func canonicalWorkspaceDir(dir string) bool { - if !workspacePathSyntax(dir) { - return false - } - info, err := os.Stat(dir) - return err == nil && info.IsDir() -} func workspacePathSyntax(dir string) bool { return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 } diff --git a/apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go deleted file mode 100644 index b2ff0fabe..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_commands_live_linux_test.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func liveWorkspaceCommands(t *testing.T, runID string, events []proto.Envelope, commands []string) []proto.ToolCallPayload { - t.Helper() - started := map[string]string{} - finished := map[string]bool{} - var complete []proto.ToolCallPayload - terminal := false - for _, event := range events { - if event.Type == proto.TypeDone { - terminal = true - } - if event.Type != proto.TypeToolCall { - continue - } - var call proto.ToolCallPayload - if terminal || event.ID != runID || event.DecodePayload(&call) != nil || call.ID == "" || call.Observation == nil || call.Observation.Kind != "command" { - t.Fatal("invalid command frame or execution identity") - } - o := call.Observation - if o.ExitCode != nil || o.Cwd != nil || o.DurationMS != nil { - t.Fatal("command observation invented unavailable native metadata") - } - switch call.Stage { - case "before": - if len(started) >= len(commands) || started[call.ID] != "" || o.Command != commands[len(started)] || o.Status != "in_progress" || len(o.Output) != 0 { - t.Fatal("unexpected, duplicate or fabricated command start") - } - started[call.ID] = o.Command - case "after": - if started[call.ID] != o.Command || finished[call.ID] || o.Status == "in_progress" { - t.Fatal("command completion lacks a unique matching start") - } - finished[call.ID] = true - complete = append(complete, call) - default: - t.Fatal("invalid command stage") - } - } - if len(complete) != len(commands) || len(started) != len(commands) { - t.Fatal("command observations missing or replayed from an earlier query") - } - return complete -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go deleted file mode 100644 index 92c69c17d..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go +++ /dev/null @@ -1,73 +0,0 @@ -//go:build unix - -package claudesdk - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestWorkspaceLaunchAndReadinessInheritsUserEnvironment(t *testing.T) { - config := workspaceFixture(t) - t.Setenv("OAC_TEST_PARENT_SECRET", "must-not-inherit") - t.Setenv("ANTHROPIC_API_KEY", "unselected") - // An owned process fixture verifies both real subprocess launch paths; it is - // not a native sandbox or provider acceptance test. - script := `#!/bin/sh -test "$OAC_TEST_PARENT_SECRET" = must-not-inherit || exit 21 -test "${ANTHROPIC_API_KEY-}" != unselected || exit 22 -test -z "${ANTHROPIC_AUTH_TOKEN+x}" && test "$HTTPS_PROXY" = http://proxy.example || exit 23 -test "$TMPDIR" != "$CLAUDE_CONFIG_DIR/tmp" || exit 24 -case "$1" in - */runtime_check.js) - printf '%s\n' '{"type":"runtime_ready","protocol":3,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare","workspace_command_observations"]}' ;; - *) - IFS= read -r request - printf '%s\n' '{"type":"executor_ready","protocol":3}' - IFS= read -r request - printf '%s\n' '{"type":"result","turn_id":"run","session_id":"native"}' - printf '%s\n' '{"type":"turn_settled","turn_id":"run","confirmed":true,"reusable":true,"reason":""}' ;; -esac -` - if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { - t.Fatal(err) - } - info, err := CheckRuntime(t.Context(), config) - if err != nil || !info.supportsWorkspace() { - t.Fatal("readiness did not receive replacement environment", err) - } - out := make(chan proto.Envelope, 8) - s, err := startSingleTurn(t.Context(), config, workspaceRequest(), "run", proto.TextInput("hello"), out) - if err != nil { - t.Fatal(err) - } - defer s.Cancel(t.Context()) - done := 0 - for event := range out { - if event.Type == proto.TypeError { - t.Fatal("execution fixture rejected replacement environment") - } - if event.Type == proto.TypeDone { - done++ - } - } - if done != 1 { - t.Fatal("expected one settled completion") - } - // Feature checking must reject an older bridge without starting execution. - script = strings.ReplaceAll(script, `"features":["workspace_tools","workspace_prepare","workspace_command_observations"]`, `"features":[]`) - script = strings.ReplaceAll(script, "IFS= read -r request", "touch '"+filepath.Join(config.StateDir, "unexpected-start")+"'") - if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { - t.Fatal(err) - } - if _, err := startSingleTurn(t.Context(), config, workspaceRequest(), "run", proto.TextInput("hello"), out); err == nil { - t.Fatal("old packaged bridge accepted workspace execution") - } - if _, err := os.Stat(filepath.Join(config.StateDir, "unexpected-start")); !os.IsNotExist(err) { - t.Fatal("old packaged bridge started execution") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go deleted file mode 100644 index b12385438..000000000 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ /dev/null @@ -1,223 +0,0 @@ -//go:build linux - -package claudesdk - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -// Run only inside a separately qualified outer placement, with its pinned native -// dependencies. This fixture does not create isolation or public admission. -func TestLiveClaudeWorkspaceTurns(t *testing.T) { - configFile := os.Getenv("OAC_TEST_CLAUDE_WORKSPACE_LIVE_CONFIG") - if configFile == "" { - t.Skip("requires explicit qualified placement and real provider configuration") - } - var placement struct { - Node, Entrypoint, Proof, Scratch, KeyFile, DependencyPath, Proxy string - } - raw, err := os.ReadFile(configFile) - if err != nil || json.Unmarshal(raw, &placement) != nil { - t.Fatal("invalid private live configuration") - } - root, err := os.MkdirTemp(placement.Proof, "factory-") - if err != nil { - t.Fatal(err) - } - t.Logf("workspace factory proof: %s", root) - t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv("OAC_TEST_PARENT_SECRET", "parent-must-not-enter-workspace") - key, err := os.ReadFile(placement.KeyFile) - if err != nil || len(bytes.TrimSpace(key)) == 0 { - t.Fatal("private real-provider key unavailable") - } - scratch, err := os.MkdirTemp(placement.Scratch, "f-") - if err != nil { - t.Fatal(err) - } - config := Config{Node: placement.Node, Entrypoint: placement.Entrypoint, StateDir: filepath.Join(root, "state"), - Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: scratch}, - Env: []string{"ANTHROPIC_BASE_URL=https://api.minimax.cn/anthropic", "ANTHROPIC_API_KEY=", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3"}} - if placement.Proxy != "" { - config.Env = append(config.Env, "HTTP_PROXY="+placement.Proxy, "HTTPS_PROXY="+placement.Proxy, "NO_PROXY=127.0.0.1,localhost") - } - for _, dir := range []string{config.StateDir, config.Workspace.Directory, config.Workspace.HomeDir} { - if err := os.Mkdir(dir, 0o700); err != nil { - t.Fatal(err) - } - } - heartbeat := filepath.Join(config.Workspace.Directory, "heartbeat.txt") - artifact := filepath.Join(config.Workspace.Directory, "value.txt") - type evidence struct { - RunID string `json:"run_id"` - Events []proto.Envelope `json:"events"` - Done proto.DonePayload `json:"done"` - Failure string `json:"failure,omitempty"` - Cancelled bool `json:"cancelled"` - CancelMS int64 `json:"cancel_ms,omitempty"` - BridgePID int `json:"bridge_pid"` - Terminals int `json:"terminals"` - Heartbeats []string `json:"heartbeats,omitempty"` - } - writeEvidence := func(name string, proof evidence) { - t.Helper() - encoded, err := json.MarshalIndent(proof, "", " ") - if err != nil || bytes.Contains(encoded, bytes.TrimSpace(key)) { - t.Fatal("cannot safely encode factory observations") - } - if err := os.WriteFile(filepath.Join(root, name+".json"), encoded, 0o600); err != nil { - t.Fatal(err) - } - } - run := func(name, prompt, resume string, cancelOnEffect bool) evidence { - t.Helper() - ctx, cancel := context.WithTimeout(t.Context(), 180*time.Second) - defer cancel() - out := make(chan proto.Envelope, 64) - req := workspaceRequest() - req.AgentSessionID = resume - req.Model, req.SystemPrompt = "MiniMax-M3", "Follow the exact verification instructions using the requested native tools. Preserve conversation facts. No other files, network operations or background work." - proof := evidence{RunID: uuid.NewString()} - running, err := startSingleTurn(ctx, config, req, proof.RunID, proto.TextInput(prompt), out) - if err != nil { - if name == "missing-history" && running == nil && strings.Contains(err.Error(), "history_unavailable") { - proof.Failure = err.Error() - writeEvidence(name, proof) - return proof - } - t.Fatal(err) - } - s := running.(*session) - defer s.Cancel(context.Background()) - proof.BridgePID = s.process.Cmd.Process.Pid - ticker := time.NewTicker(80 * time.Millisecond) - defer ticker.Stop() - for out != nil { - select { - case <-ctx.Done(): - t.Fatal("real factory deadline expired") - case <-ticker.C: - value, _ := os.ReadFile(heartbeat) - if cancelOnEffect && !proof.Cancelled && len(value) > 0 && string(value) != "0" && string(value) != "1" { - started := time.Now() - if err := s.Cancel(ctx); err != nil { - t.Fatal("factory cancellation failed", err) - } - proof.CancelMS = time.Since(started).Milliseconds() - proof.Cancelled = true - } - case event, ok := <-out: - if !ok { - out = nil - continue - } - proof.Events = append(proof.Events, event) - switch event.Type { - case proto.TypeError: - var failure proto.ErrorPayload - _ = event.DecodePayload(&failure) - proof.Failure = failure.Error - case proto.TypeDone: - proof.Terminals++ - _ = event.DecodePayload(&proof.Done) - } - } - } - if proof.Cancelled { - a, _ := os.ReadFile(heartbeat) - time.Sleep(1500 * time.Millisecond) - b, _ := os.ReadFile(heartbeat) - proof.Heartbeats = []string{string(a), string(b)} - if len(a) == 0 || !bytes.Equal(a, b) { - t.Fatal("native command effects continued after Cancel") - } - settled, _ := json.Marshal(s.CancellationOutcome()) - done, _ := json.Marshal(proof.Done) - if !bytes.Equal(settled, done) { - t.Fatal("cancellation outcome differs from terminal Done") - } - } - writeEvidence(name, proof) - if proof.Terminals != 1 || (cancelOnEffect && !proof.Cancelled) { - t.Fatal("missing actual cancellation or unique completion") - } - return proof - } - commands := []string{ - `python3 -c "import sys; print('OBS_SUCCESS_STDOUT'); print('OBS_SUCCESS_STDERR', file=sys.stderr)"`, - `python3 -c "import sys; print('OBS_FAILURE_STDOUT'); print('OBS_FAILURE_STDERR', file=sys.stderr); sys.exit(7)"`, - } - observed := run("commands", fmt.Sprintf("Execute exactly these two foreground Bash calls, sequentially, with timeout 10000. Preserve each command exactly. The second intentionally fails; do not retry or repair it. Use no other tools.\n1. %s\n2. %s", commands[0], commands[1]), "", false) - observedID, _ := observed.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - if observedID == "" || observed.Failure != "" { - t.Fatal("real command observation query failed") - } - completed := liveWorkspaceCommands(t, observed.RunID, observed.Events, commands) - for i, expected := range []struct{ status, output string }{ - {"completed", "OBS_SUCCESS_STDERR\nOBS_SUCCESS_STDOUT"}, - {"failed", "Exit code 7\nOBS_FAILURE_STDERR\nOBS_FAILURE_STDOUT"}, - } { - var output string - if completed[i].Observation.Status != expected.status || json.Unmarshal(completed[i].Observation.Output, &output) != nil || output != expected.output { - t.Fatal("native command result text/status was not preserved") - } - } - nonce := "conversation-" + uuid.NewString() - command := "python3 -u - <<'VERIFY_PY'\nimport secrets,time\nfrom pathlib import Path\nPath('value.txt').write_text(secrets.token_hex(16)+'\\n')\nfor n in range(180):\n Path('heartbeat.txt').write_text(str(n))\n time.sleep(1)\nVERIFY_PY" - first := run("first", fmt.Sprintf("Remember the conversation-only value %s; do not write it into any file. Execute exactly one foreground Bash call with timeout 120000 and this exact command. Wait for it; use no other tools.\n%s", nonce, command), observedID, true) - id, _ := first.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - if id == "" || id != observedID { - t.Fatal("cancelled native Session identity unavailable") - } - interrupted := liveWorkspaceCommands(t, first.RunID, first.Events, []string{command}) - if interrupted[0].ID == completed[0].ID || interrupted[0].ID == completed[1].ID || - (interrupted[0].Observation.Status != "incomplete" && interrupted[0].Observation.Status != "failed") || - (interrupted[0].Observation.Status == "failed" && len(interrupted[0].Observation.Output) == 0) { - t.Fatal("cancelled command lost its native failure or incomplete observation") - } - original, err := os.ReadFile(artifact) - if err != nil || len(bytes.TrimSpace(original)) != 32 { - t.Fatal("actual workspace artifact missing") - } - second := run("resumed", "Use native Read to read value.txt. Then use native Edit to append the literal suffix -resumed to its value, retaining a trailing newline. Do not use Bash. Reply with the original file value and the conversation-only value remembered earlier.", id, false) - liveWorkspaceCommands(t, second.RunID, second.Events, nil) - final, err := os.ReadFile(artifact) - if err != nil || string(final) != strings.TrimSpace(string(original))+"-resumed\n" || second.Failure != "" || - second.Done.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(messageText(second.Events), nonce) || - !strings.Contains(messageText(second.Events), strings.TrimSpace(string(original))) || first.BridgePID == second.BridgePID { - t.Fatal("fresh-process native workspace/history continuation failed") - } - retained := config.StateDir + "-retained" - if err := os.Rename(config.StateDir, retained); err != nil { - t.Fatal(err) - } - defer func() { - _ = os.Remove(config.StateDir) - _ = os.Rename(retained, config.StateDir) - }() - if err := os.Mkdir(config.StateDir, 0o700); err != nil { - t.Fatal(err) - } - missing := run("missing-history", "Continue the existing Session only; do not start another Session.", id, false) - entries, err := os.ReadDir(config.StateDir) - after, _ := os.ReadFile(artifact) - if missing.Failure != "claudesdk: history_unavailable" || missing.Terminals != 0 || err != nil || len(entries) != 0 || !bytes.Equal(final, after) || missing.Done.Metadata[proto.DoneMetaAgentSessionID] != nil { - t.Fatal("missing native history did not fail before new execution") - } - if err := os.RemoveAll(scratch); err != nil { - t.Fatal(err) - } -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index 86698b323..977a722e1 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -20,12 +20,12 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T req := preparationRequest() schema := `{"type":"object","properties":{"n":{"const":9007199254740992}}}` req.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(schema)}} - e, err := NewExecutorFactory(config)(t.Context(), prepared(t, req)) + e, err := config.factory()(t.Context(), prepared(t, req)) if mode == "structured-missing" { if err == nil || !strings.Contains(err.Error(), "workspace structured output") { t.Fatal("unqualified bundle admitted", err) } - if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(config.StateDir(), "launched")); !os.IsNotExist(err) { t.Fatal("unqualified request reached native launch", err) } return @@ -36,7 +36,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T defer e.Close(context.Background()) req.ExecutionControls.OutputFormat.Schema[0] = ' ' var frozen startRequest - if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")), &frozen); err != nil { + if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir(), "prepare.json")), &frozen); err != nil { t.Fatal(err) } if frozen.OutputFormat == nil || string(frozen.OutputFormat.Schema) != schema { @@ -52,7 +52,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T } } var started map[string]json.RawMessage - if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &started); err != nil || len(started) != 3 || started["output_format"] != nil { + if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir(), "start.json")), &started); err != nil || len(started) != 3 || started["output_format"] != nil { t.Fatal("Start replaced the prepared configuration", err) } }) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_test.go b/apps/daemon/internal/agent/claudesdk/workspace_test.go index 7d9504a9d..c662d44b4 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_test.go @@ -2,136 +2,23 @@ package claudesdk import ( "encoding/json" - "os" - "path/filepath" "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func workspaceFixture(t *testing.T) Config { - t.Helper() - root, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_HOME", root) - for _, name := range []string{"workspace", "home", "state", "scratch", "secrets", "bin", "runtime/dist", "runtime/node_modules"} { - if err := os.MkdirAll(filepath.Join(root, name), 0o700); err != nil { - t.Fatal(err) - } - } - config := Config{Node: filepath.Join(root, "bin", "node"), Entrypoint: filepath.Join(root, "runtime", "dist", "main.js"), StateDir: filepath.Join(root, "state"), - Env: []string{"HTTPS_PROXY=http://proxy.example"}, - Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: filepath.Join(root, "scratch")}} - for _, name := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { - if err := os.WriteFile(name, nil, 0o700); err != nil { - t.Fatal(err) - } - } - return config -} - func workspaceRequest() proto.PromptRequestPayload { - return proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableSubagents: true, + return proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{ID: "environment"}, Model: "fixture"} } -func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { - config := workspaceFixture(t) - t.Setenv("OAC_TEST_PARENT_SECRET", "parent-only") - t.Setenv("ANTHROPIC_API_KEY", "unselected-provider") - config.Env = append(config.Env, "ANTHROPIC_BASE_URL=https://unselected.example") - start, env, err := prepareConfiguration(config, prepared(t, workspaceRequest())) - if err != nil { - t.Fatal(err) - } - if start.Cwd != config.Workspace.Directory || start.Workspace == nil || start.MCPHTTPServers != nil { - t.Fatal("trusted binding was not retained") - } - raw, _ := json.Marshal(start) - if strings.Contains(string(raw), "fixture-key") || strings.Contains(string(raw), "parent-only") { - t.Fatal("secret value entered the private request") - } - values := map[string]string{} - for _, item := range env { - name, value, _ := strings.Cut(item, "=") - values[name] = value - } - if values["OAC_TEST_PARENT_SECRET"] != "parent-only" { - t.Fatal("lost user environment") - } - _, token := values["ANTHROPIC_AUTH_TOKEN"] - if token || values["ANTHROPIC_API_KEY"] != "fixture-key" || values["ANTHROPIC_BASE_URL"] != "https://model.example" { - t.Fatal("environment credentials replaced the Session provider") - } - if values["HOME"] != config.Workspace.HomeDir || values["CLAUDE_CONFIG_DIR"] != config.StateDir || - values["TMPDIR"] != config.Workspace.ScratchDir || values["HTTPS_PROXY"] != "http://proxy.example" { - t.Fatal("explicit runtime environment was not preserved") - } - entries, err := os.ReadDir(config.StateDir) - if err != nil || len(entries) != 0 { - t.Fatal("preparation created history or nested scratch") - } -} - -func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { - for _, name := range []string{"none", "workspace-root", "mcp", "relative", "missing", "ambient-setting", "duplicate-env", "bad-env"} { - t.Run(name, func(t *testing.T) { - config := workspaceFixture(t) - req, root := workspaceRequest(), "" - switch name { - case "none": - req.DisableExecutionEnvironment = true - case "workspace-root": - config.Workspace.NetworkAccess = "enabled" - req.LocalEnvironment, root = &proto.LocalEnvironment{ID: "environment"}, config.Workspace.ScratchDir - case "mcp": - req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} - case "relative": - config.Workspace.Directory = "relative" - case "missing": - config.Workspace.Directory = filepath.Join(config.Workspace.Directory, "missing") - case "overlap": - config.Workspace.ScratchDir = config.StateDir - case "symlink": - alias := filepath.Join(filepath.Dir(config.StateDir), "alias") - if err := os.Symlink(config.Workspace.Directory, alias); err != nil { - t.Fatal(err) - } - config.Workspace.Directory = alias - case "rule-pattern": - config.Workspace.Directory += "*" - if err := os.Mkdir(config.Workspace.Directory, 0o700); err != nil { - t.Fatal(err) - } - case "ambient-setting": - config.Env = append(config.Env, "NODE_OPTIONS=--require=untrusted") - case "duplicate-env": - config.Env = append(config.Env, "HTTPS_PROXY=http://second.example") - case "bad-env": - config.Env = append(config.Env, "NO_PROXY=bad\x00value") - } - bound := prepared(t, req) - bound.WorkspaceRoot = root - if _, _, err := prepareConfiguration(config, bound); err == nil { - t.Fatal("invalid binding or request accepted") - } - entries, err := os.ReadDir(config.StateDir) - if err != nil || len(entries) != 0 { - t.Fatal("rejection created execution state") - } - }) - } -} - func TestWorkspaceRetainsDeclaredFunctions(t *testing.T) { - config := workspaceFixture(t) req := workspaceRequest() req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} - start, _, err := prepareConfiguration(config, prepared(t, req)) + bound := prepared(t, req) + bound.WorkspaceRoot = t.TempDir() + start, _, err := prepareTestView(t, bound) if err != nil { t.Fatal(err) } @@ -141,16 +28,13 @@ func TestWorkspaceRetainsDeclaredFunctions(t *testing.T) { } func TestPublicMCPUsesWorkspaceProjectionWithoutCredentialCopy(t *testing.T) { - config := workspaceFixture(t) - config.Workspace.NetworkAccess = "enabled" req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{} token := "vault-selected-canary" tools := []string{"prove"} req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", AllowedTools: &tools, Required: true, BearerToken: &token}} bound := prepared(t, req) - bound.WorkspaceRoot = config.Workspace.Directory - start, env, err := prepareConfiguration(config, bound) + bound.WorkspaceRoot = t.TempDir() + start, env, err := prepareTestView(t, bound) if err != nil { t.Fatal(err) } @@ -158,16 +42,8 @@ func TestPublicMCPUsesWorkspaceProjectionWithoutCredentialCopy(t *testing.T) { t.Fatal("workspace policy lost") } raw, _ := json.Marshal(start) - if strings.Contains(string(raw), token) { - t.Fatal("bearer copied into bridge request") - } - ref := start.Workspace.MCP[0].BearerTokenEnvVar - found := false - for _, entry := range env { - found = found || entry == ref+"="+token - } - if ref == "" || !found { - t.Fatal("selected credential not bound") + if strings.Contains(string(raw)+strings.Join(env, "\n"), token) { + t.Fatal("bearer reached the bridge") } info := RuntimeInfo{Protocol: 3, Features: []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "local_runtime_v2", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required"}} if validateExecutorFeatures(info, start) == nil { diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index 244ee664d..22597f4cf 100644 --- a/apps/daemon/internal/agent/codex/declaration.go +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -34,7 +34,6 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, i recovery := proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) caps.NativeSessionRecovery, caps.LocalEnvironment, caps.MCPHTTPRequired = recovery, recovery, recovery caps.TextVerbosity = proto.CapabilityFromBool(SupportsTextVerbosity) - runtime.Executor = NewExecutorFactory() runtime.View = discoverView(version) fmt.Fprintf(options.Stdout, "Codex preflight ok (%s)\n", version) return runtime diff --git a/apps/daemon/internal/agent/codex/declaration_test.go b/apps/daemon/internal/agent/codex/declaration_test.go index 1e9d0608e..0e7baa7cf 100644 --- a/apps/daemon/internal/agent/codex/declaration_test.go +++ b/apps/daemon/internal/agent/codex/declaration_test.go @@ -13,8 +13,8 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return version, nil }) - if !runtime.Info.Available || runtime.Executor == nil { - t.Fatalf("factories: %+v", runtime) + if !runtime.Info.Available { + t.Fatalf("runtime: %+v", runtime) } if runtime.Info.Capabilities.MCPHTTPRequired.IsSupported() != (version == "codex-cli 0.153.4") { t.Fatal("unverified native combination advertised") @@ -25,9 +25,9 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { } } -func TestUnavailableRuntimeHasNoExecutionFactories(t *testing.T) { +func TestUnavailableRuntimeHasNoView(t *testing.T) { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return "", errors.New("missing") }) - if runtime.Info.Available || runtime.Executor != nil || runtime.View != nil { + if runtime.Info.Available || runtime.View != nil { t.Fatalf("unavailable runtime: %+v", runtime) } } diff --git a/apps/daemon/internal/agent/codex/environment.go b/apps/daemon/internal/agent/codex/environment.go index 725e5ef58..a459db36b 100644 --- a/apps/daemon/internal/agent/codex/environment.go +++ b/apps/daemon/internal/agent/codex/environment.go @@ -3,10 +3,7 @@ package codex import ( "context" "encoding/json" - "errors" "fmt" - "os" - "strings" ) // Check the native provider instead of assuming an older binary honors the flag. @@ -36,19 +33,3 @@ func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) } return result.Status, nil } - -// Native still recognizes the retired transport variables. Reject them before -// setup so the inherited environment cannot select a separate executor. -// The explicit none selector remains part of native execution isolation. -func validateNativeTransportEnvironment() error { - for _, entry := range os.Environ() { - key, value, _ := strings.Cut(entry, "=") - if value == "" { - continue - } - if (key == "CODEX_EXEC_SERVER_URL" && value != "none") || strings.HasPrefix(key, "CODEX_EXEC_SERVER_NOISE_") { - return errors.New("codex: retired executor transport configuration is not supported") - } - } - return nil -} diff --git a/apps/daemon/internal/agent/codex/environment_retired_test.go b/apps/daemon/internal/agent/codex/environment_retired_test.go deleted file mode 100644 index a7d2f1721..000000000 --- a/apps/daemon/internal/agent/codex/environment_retired_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package codex - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestReadOnlyPreparationRejectedBeforeNativeSetup(t *testing.T) { - req, _, root := preparationFixture(t) - req.WorkspaceReadOnly = true - prepared, err := PrepareExecutor(t.Context(), req) - if err == nil || prepared != nil { - t.Fatal("read-only request admitted", err) - } - if len(preparationFrames(t, root)) != 0 { - t.Fatal("read-only request started native child") - } - if _, err := os.Stat(filepath.Join(root, "daemon", "agent-sessions")); !os.IsNotExist(err) { - t.Fatal("read-only request created native state", err) - } -} - -func TestRetiredNativeTransportEnvironmentRejectedBeforeState(t *testing.T) { - for _, key := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { - for _, none := range []bool{false, true} { - t.Run(key+"/"+map[bool]string{false: "local", true: "none"}[none], func(t *testing.T) { - req, cfg, root := preparationFixture(t) - req.DisableExecutionEnvironment = none - if !none { - req.LocalEnvironment = &proto.LocalEnvironment{ID: "local"} - } - t.Setenv(key, "retired-private-value") - e, err := newExecutor(t.Context(), req, cfg) - if e != nil || err == nil || !strings.Contains(err.Error(), "retired executor transport") || strings.Contains(err.Error(), "retired-private-value") { - t.Fatal("transport override admitted or disclosed", err) - } - if len(preparationFrames(t, root)) != 0 { - t.Fatal("retired transport started native process") - } - if _, err := os.Stat(filepath.Join(root, "daemon", "agent-sessions")); !os.IsNotExist(err) { - t.Fatal("retired transport created state", err) - } - }) - } - } -} - -func TestNativeNoneSelectorRemainsSupported(t *testing.T) { - req, cfg, root := preparationFixture(t) - t.Setenv("CODEX_EXEC_SERVER_URL", "none") - e, err := newExecutor(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer e.Close(context.Background()) - assertPreparationOnly(t, root) - statuses := 0 - for _, frame := range preparationFrames(t, root) { - if frame.Method == "environment/status" { - statuses++ - } - } - if statuses != 2 { - t.Fatal("none did not verify both native execution environments") - } -} diff --git a/apps/daemon/internal/agent/codex/execution_controls_test.go b/apps/daemon/internal/agent/codex/execution_controls_test.go index c355fbf05..fc1eda136 100644 --- a/apps/daemon/internal/agent/codex/execution_controls_test.go +++ b/apps/daemon/internal/agent/codex/execution_controls_test.go @@ -8,8 +8,7 @@ import ( ) func TestExecutionControlsSelectNativeSettings(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, err := BuildSessionPlan(prepared(t, "state", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider()})) + plan, err := testPlan(t, prepared(t, "state", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider()})) if err != nil { t.Fatal(err) } @@ -18,7 +17,7 @@ func TestExecutionControlsSelectNativeSettings(t *testing.T) { t.Fatal("native settings without ExecutionControls", plan.ExtraConfig) } for _, verbosity := range []string{"low", "medium", "high"} { - plan, err := BuildSessionPlan(prepared(t, "state", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), ExecutionControls: &proto.ExecutionControls{TextVerbosity: verbosity}})) + plan, err := testPlan(t, prepared(t, "state", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), ExecutionControls: &proto.ExecutionControls{TextVerbosity: verbosity}})) if err != nil { t.Fatal(err) } @@ -31,9 +30,8 @@ func TestExecutionControlsSelectNativeSettings(t *testing.T) { } func TestExecutionControlsRejectIncompleteOrInvalidValues(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) for _, controls := range []proto.ExecutionControls{{}, {TextVerbosity: "invalid"}} { - if plan, err := BuildSessionPlan(prepared(t, "state", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), ExecutionControls: &controls})); err == nil { + if plan, err := testPlan(t, prepared(t, "state", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), ExecutionControls: &controls})); err == nil { plan.Cleanup() t.Fatal("invalid controls accepted", controls) } diff --git a/apps/daemon/internal/agent/codex/executor.go b/apps/daemon/internal/agent/codex/executor.go index 3e6d72c09..3c19ef279 100644 --- a/apps/daemon/internal/agent/codex/executor.go +++ b/apps/daemon/internal/agent/codex/executor.go @@ -25,14 +25,6 @@ type Executor struct { closeMu sync.Mutex } -func PrepareExecutor(ctx context.Context, req agent.PrepareRequest) (agent.Executor, error) { - e, err := newExecutor(ctx, req, defaultSessionConfig()) - if e == nil { - return nil, err - } - return e, err -} - func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { if out == nil || strings.TrimSpace(runID) == "" || input.Validate() != nil { return nil, errors.New("codex: start requires a run identity, input and output") @@ -190,5 +182,3 @@ func (e *Executor) Close(ctx context.Context) error { } var _ agent.Executor = (*Executor)(nil) - -func NewExecutorFactory() agent.ExecutorFactory { return PrepareExecutor } diff --git a/apps/daemon/internal/agent/codex/executor_native_test.go b/apps/daemon/internal/agent/codex/executor_native_test.go index 37d40a2b4..a2072da36 100644 --- a/apps/daemon/internal/agent/codex/executor_native_test.go +++ b/apps/daemon/internal/agent/codex/executor_native_test.go @@ -6,11 +6,13 @@ import ( "log/slog" "os" "os/exec" + "slices" "strings" "testing" "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -42,13 +44,14 @@ func TestExecutorNativeReuse(t *testing.T) { t.Fatal("cannot create isolated acceptance workspace") } t.Cleanup(func() { _ = os.RemoveAll(isolated) }) - t.Setenv("OAC_RUNTIME_HOME", isolated) - for _, name := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { - t.Setenv(name, "") - } - cfg := defaultSessionConfig() - cfg.codexBinary = binary + cfg := testView(t, binary, isolated) cfg.logger = slog.New(slog.DiscardHandler) + // The test reaches the provider directly, without the view's proxy. + launch := cfg.view.Launch + cfg.view.Launch = func(opts clirunner.StartOptions) (*clirunner.Process, error) { + opts.Env = slices.DeleteFunc(opts.Env, func(entry string) bool { return strings.Contains(strings.ToLower(entry), "_proxy=") }) + return launch(opts) + } req := prepared(t, "executor-native", proto.PromptRequestPayload{ AgentKind: "codex", DisableExecutionEnvironment: true, DisableSubagents: true, diff --git a/apps/daemon/internal/agent/codex/harness_config_test.go b/apps/daemon/internal/agent/codex/harness_config_test.go index 443fbd970..008e8eda4 100644 --- a/apps/daemon/internal/agent/codex/harness_config_test.go +++ b/apps/daemon/internal/agent/codex/harness_config_test.go @@ -12,8 +12,7 @@ import ( ) func TestHarnessConfigAppliedWithoutChangingProvider(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, err := BuildSessionPlan(prepared(t, "native-config", proto.PromptRequestPayload{ + plan, err := testPlan(t, prepared(t, "native-config", proto.PromptRequestPayload{ Model: "fixture", HarnessConfig: proto.HarnessConfig(`{"model_reasoning_effort":"high"}`), ModelProvider: &modelprovider.Provider{BaseURL: "https://provider.invalid/v1", Protocol: modelprovider.Responses, APIKey: "test-key"}, })) diff --git a/apps/daemon/internal/agent/codex/mcp_config.go b/apps/daemon/internal/agent/codex/mcp_config.go index 5e6705d4d..c660c0891 100644 --- a/apps/daemon/internal/agent/codex/mcp_config.go +++ b/apps/daemon/internal/agent/codex/mcp_config.go @@ -2,8 +2,6 @@ package codex import ( "fmt" - "maps" - "slices" "sort" "strings" ) @@ -12,15 +10,13 @@ import ( // McpServerConfig. Written into /config.toml before spawning the // app-server child. type mcpServerConfig struct { - Name string - URL string - Command string - Args []string - EnabledTools *[]string - Required bool - BearerTokenEnvVar string - EnvHTTPHeaders map[string]string - ApproveTools bool + Name string + URL string + Command string + Args []string + EnabledTools *[]string + Required bool + ApproveTools bool } // writeCodexMCPConfig writes a `[mcp_servers.]` TOML table per @@ -55,11 +51,6 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e b.WriteString(`url = `) b.WriteString(tomlQuoteString(srv.URL)) b.WriteByte('\n') - if srv.BearerTokenEnvVar != "" { - b.WriteString("bearer_token_env_var = ") - b.WriteString(tomlQuoteString(srv.BearerTokenEnvVar)) - b.WriteByte('\n') - } if srv.EnabledTools != nil { b.WriteString("enabled_tools = [") for i, name := range *srv.EnabledTools { @@ -70,16 +61,6 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e } b.WriteString("]\n") } - if len(srv.EnvHTTPHeaders) > 0 { - b.WriteString("env_http_headers = {") - for i, key := range slices.Sorted(maps.Keys(srv.EnvHTTPHeaders)) { - if i > 0 { - b.WriteString(", ") - } - b.WriteString(tomlQuoteString(key) + " = " + tomlQuoteString(srv.EnvHTTPHeaders[key])) - } - b.WriteString("}\n") - } b.WriteByte('\n') continue } diff --git a/apps/daemon/internal/agent/codex/mcp_config_test.go b/apps/daemon/internal/agent/codex/mcp_config_test.go index 22f78b3c9..0dad9c67c 100644 --- a/apps/daemon/internal/agent/codex/mcp_config_test.go +++ b/apps/daemon/internal/agent/codex/mcp_config_test.go @@ -58,9 +58,8 @@ func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { dir := t.TempDir() servers := map[string]mcpServerConfig{ "docs": { - Name: "docs", - URL: "https://docs.example.com/mcp", - EnvHTTPHeaders: map[string]string{"Authorization": "DOCS_AUTHORIZATION"}, + Name: "docs", + URL: "https://docs.example.com/mcp", }, } if err := writeCodexMCPConfig(dir, servers); err != nil { @@ -70,20 +69,14 @@ func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { if !strings.Contains(string(body), `url = "https://docs.example.com/mcp"`) || strings.Contains(string(body), "command =") { t.Fatalf("remote config: %s", body) } - if !strings.Contains(string(body), `env_http_headers = {"Authorization" = "DOCS_AUTHORIZATION"}`) { - t.Fatalf("remote headers: %s", body) - } } // TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries documents the -// "fresh entries only" guarantee: callers allocate a brand-new -// CODEX_HOME per prompt (BuildSessionPlan does this via allocCodexHome -// + plan.Cleanup), so the previous run's mcp_servers can't leak. +// "fresh entries only" guarantee: buildSessionPlan removes the generated +// config.toml before writing, so the previous run's mcp_servers can't leak. // -// writeCodexMCPConfig itself is APPEND semantics now — the truncation -// guarantee lives in allocCodexHome's RemoveAll, not in the writer. -// Test that workflow explicitly so a future refactor that breaks the -// fresh-home contract fails here. +// writeCodexMCPConfig itself is APPEND semantics — the truncation +// guarantee lives in resetGeneratedConfig, not in the writer. func TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries(t *testing.T) { dir1 := t.TempDir() first := map[string]mcpServerConfig{ diff --git a/apps/daemon/internal/agent/codex/mcp_environment_test.go b/apps/daemon/internal/agent/codex/mcp_environment_test.go index 9ab54aaec..7189830ad 100644 --- a/apps/daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/daemon/internal/agent/codex/mcp_environment_test.go @@ -2,8 +2,6 @@ package codex import ( "encoding/json" - "os" - "slices" "strings" "testing" @@ -12,54 +10,43 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) -func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing.T) { - token := "user-token" - req := agent.PrepareRequest{PromptRequestPayload: proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}}, MCP: []agent.EnvironmentMCP{ - {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "must-not-be-native-command", Args: []string{"private-argument"}}}, - {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/1", BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.com/mcp", HTTPHeaders: map[string]string{"X-Key": "literal-${DO_NOT_EXPAND}"}}}, - }} - servers, env, err := runtimeMCPServers(req) - if err != nil || len(servers) != 2 || len(env) != 2 { - t.Fatal("environment declarations were not projected", err) +// TestViewMCPProjectsStdioAliasAndGatewayURL checks that the view's stdio +// server runs as its alias without arguments and its HTTP server at the +// gateway URL, and that the qualified native configuration matches only that +// projection. +func TestViewMCPProjectsStdioAliasAndGatewayURL(t *testing.T) { + cfg := testView(t, "", t.TempDir()) + cfg.view.MCP = []agent.MCPBinding{ + {ServerLabel: "local", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", Stdio: &agent.EnvironmentMCP{ + Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: agent.ViewAlias(0)}}}, + {ServerLabel: "remote", ConnectionOrigin: "environment", CredentialAuthority: "environment_configuration", Transport: "http", ServerURL: "http://127.0.0.1:17102/mcp"}, } - executable, _ := os.Executable() - if servers["local"].Command != executable || !servers["local"].ApproveTools || - !slices.Equal(servers["local"].Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "local"}) { - t.Fatal("native stdio bypasses the packaged launcher") + req := prepared(t, "state", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), LocalEnvironment: &proto.LocalEnvironment{}}) + req.WorkspaceRoot = "/workspace" + plan, err := prepareViewPlan(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) } - remote := servers["remote"] - if remote.BearerTokenEnvVar == "" || remote.EnvHTTPHeaders["X-Key"] == "" || - !slices.Contains(env, remote.BearerTokenEnvVar+"="+token) || - !slices.Contains(env, remote.EnvHTTPHeaders["X-Key"]+"=literal-${DO_NOT_EXPAND}") { - t.Fatal("private header values changed") + defer plan.Cleanup() + servers := plan.mcpServers + local, remote := servers["local"], servers["remote"] + if len(servers) != 2 || local.Command != agent.ViewAlias(0) || local.Args == nil || len(local.Args) != 0 || !local.ApproveTools || + remote.URL != "http://127.0.0.1:17102/mcp" { + t.Fatalf("view MCP projection: %+v", servers) } fixture := map[string]any{"config": map[string]any{ "mcp_oauth_credentials_store": "file", "features": map[string]bool{"plugins": false, "apps": false}, "mcp_servers": map[string]any{ - "local": map[string]any{"command": servers["local"].Command, "args": servers["local"].Args, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve"}, - "remote": map[string]any{"url": remote.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve", "bearer_token_env_var": remote.BearerTokenEnvVar, "env_http_headers": remote.EnvHTTPHeaders}, + "local": map[string]any{"command": local.Command, "args": []string{}, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve"}, + "remote": map[string]any{"url": remote.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "default_tools_approval_mode": "approve"}, }, }} raw, _ := json.Marshal(fixture) if !matchesMCPConfig(raw, servers) { t.Fatal("qualified native projection rejected") } - corrupt := strings.Replace(string(raw), "runtime-mcp-exec", "untrusted-launcher", 1) + corrupt := strings.Replace(string(raw), agent.ViewAlias(0), "/usr/bin/untrusted-launcher", 1) if matchesMCPConfig(json.RawMessage(corrupt), servers) { t.Fatal("different native launcher accepted") } } - -func TestEnvironmentMCPRejectsPlaintextBearerAndCollisions(t *testing.T) { - token := "user-token" - req := agent.PrepareRequest{PromptRequestPayload: proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}}, - MCP: []agent.EnvironmentMCP{{BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "http://example.com/mcp"}}}} - if _, _, err := runtimeMCPServers(req); err == nil { - t.Fatal("plaintext bearer accepted") - } - req.MCP[0].Server.URL = "https://example.com/mcp" - req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.com/mcp"}} - if _, _, err := runtimeMCPServers(req); err == nil { - t.Fatal("service and environment identity collision accepted") - } -} diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index 4109a604c..81864b3db 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -1,54 +1,27 @@ package codex import ( - "crypto/rand" "errors" "os" "slices" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" ) -// One projection consumes both public and installed Runtime bindings. A non-nil -// empty public declaration still owns the complete native MCP configuration. -func runtimeMCPServers(req agent.PrepareRequest) (map[string]mcpServerConfig, []string, error) { - bindings, err := agent.ResolveMCPBindings(req) - if err != nil { - return nil, nil, err - } - if bindings == nil { - return nil, nil, nil - } - return mcpServersFromBindings(bindings, localworkspace.MCPStdioCommand) -} - -// mcpServersFromBindings renders resolved bindings, each stdio binding with -// the command and arguments stdio gives it and each credential in a private -// environment variable. -func mcpServersFromBindings(bindings []agent.MCPBinding, stdio func(agent.EnvironmentMCP) (string, []string)) (map[string]mcpServerConfig, []string, error) { +// mcpServersFromBindings renders a view's MCP bindings: each HTTP binding at +// its gateway URL and each stdio binding as its alias. The Harness runs the +// alias without arguments, which the native configuration reports as an empty +// list. The bindings carry no credentials; the gateway adds them. +func mcpServersFromBindings(bindings []agent.MCPBinding) map[string]mcpServerConfig { servers := make(map[string]mcpServerConfig, len(bindings)) - var env []string for _, binding := range bindings { server := mcpServerConfig{Name: binding.ServerLabel, URL: binding.ServerURL, Required: binding.Required, EnabledTools: binding.AllowedTools, ApproveTools: binding.ConnectionOrigin == "environment"} if binding.Stdio != nil { - server.Command, server.Args = stdio(*binding.Stdio) - } - if binding.BearerToken != nil { - server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() - env = append(env, server.BearerTokenEnvVar+"="+*binding.BearerToken) - } - if len(binding.HTTPHeaders) > 0 { - server.EnvHTTPHeaders = map[string]string{} - for name, value := range binding.HTTPHeaders { - reference := "OAC_RUNTIME_MCP_HEADER_" + rand.Text() - server.EnvHTTPHeaders[name] = reference - env = append(env, reference+"="+value) - } + server.Command, server.Args = binding.Stdio.Server.Command, []string{} } servers[server.Name] = server } - return servers, env, nil + return servers } func configureMCP(plan *SessionPlan, servers map[string]mcpServerConfig) error { diff --git a/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go b/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go deleted file mode 100644 index 41ab96cc4..000000000 --- a/apps/daemon/internal/agent/codex/mcp_http_bearer_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package codex - -import ( - "encoding/json" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestMCPHTTPBearerPlanSeparatesServersAndProcesses(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - tokens := []string{"first-synthetic.token+/==", "second-synthetic_token~"} - servers := []proto.MCPHTTPServer{ - {ConnectionOrigin: "service", ServerLabel: "first", ServerURL: "https://first.example/mcp", BearerToken: &tokens[0]}, - {ConnectionOrigin: "service", ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, - {ConnectionOrigin: "service", ServerLabel: "public", ServerURL: "http://public.example/mcp"}, - } - req := prepared(t, "retained-mcp", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, MCPHTTPServers: &servers}) - seen := map[string]bool{} - for range 2 { - plan, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - config, err := os.ReadFile(filepath.Join(plan.Cwd, "config.toml")) - if err != nil { - t.Fatal(err) - } - args, _ := json.Marshal(plan.ExtraConfig) - for i, server := range servers[:2] { - ref := plan.mcpServers[server.ServerLabel].BearerTokenEnvVar - if !strings.HasPrefix(ref, "OAC_RUNTIME_MCP_BEARER_") || seen[ref] || !slices.Contains(plan.Env, ref+"="+tokens[i]) { - t.Fatal("missing exact per-server secret or reused native reference") - } - seen[ref] = true - if _, present := os.LookupEnv(ref); present { - t.Fatal("secret entered parent environment") - } - if !strings.Contains(string(config), `bearer_token_env_var = "`+ref+`"`) || strings.Contains(string(config), tokens[i]) || strings.Contains(string(args), tokens[i]) { - t.Fatal("secret reached configuration/arguments or reference was omitted") - } - } - if plan.mcpServers["public"].BearerTokenEnvVar != "" || strings.Count(string(config), "bearer_token_env_var") != 2 { - t.Fatal("credential-free server received authentication") - } - plan.Cleanup() - } -} - -func TestMCPHTTPBearerRejectsInvalidTokensWithoutPersistence(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - 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 := prepared(t, "invalid-bearer", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, MCPHTTPServers: &servers}) - 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") - } - } - entries, err := os.ReadDir(root) - if err != nil || len(entries) != 0 { - t.Fatal("invalid credential wrote native state", err) - } -} - -func TestMCPHTTPBearerDoesNotReachModelCatalogProbe(t *testing.T) { - if !SupportsTextVerbosity { - t.Skip("catalog probe requires Unix") - } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - binary := filepath.Join(t.TempDir(), "catalog-probe") - script := "#!/bin/sh\nif env | grep -q '^OAC_RUNTIME_MCP_BEARER_'; then exit 9; fi\nprintf '%s' '{\"models\":[{\"slug\":\"fixture-model\",\"support_verbosity\":true}]}'\n" - if err := os.WriteFile(binary, []byte(script), 0o700); err != nil { - t.Fatal(err) - } - token := "synthetic-catalog-secret" - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} - req := prepared(t, "catalog", proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, - Model: "fixture-model", ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}}) - cfg := defaultSessionConfig() - cfg.codexBinary = binary - plan, err := prepareSessionPlan(t.Context(), req, cfg) - if err != nil { - t.Fatal("catalog probe inherited bearer or failed", err) - } - defer plan.Cleanup() - if !slices.Contains(plan.Env, plan.mcpServers["tools"].BearerTokenEnvVar+"="+token) { - t.Fatal("app-server did not receive bearer after catalog probe") - } -} - -func TestMCPHTTPBearerPreflightMatchesOnlyItsServerReference(t *testing.T) { - servers := map[string]mcpServerConfig{ - "first": {URL: "https://first.example/mcp", BearerTokenEnvVar: "OAC_RUNTIME_MCP_BEARER_FIRST"}, - "second": {URL: "https://second.example/mcp", BearerTokenEnvVar: "OAC_RUNTIME_MCP_BEARER_SECOND"}, - "public": {URL: "http://public.example/mcp"}, - } - for _, mutation := range []string{"none", "missing", "ambient", "swapped", "extra", "header", "helper"} { - t.Run(mutation, func(t *testing.T) { - response := mcpHTTPConfigResponse(servers) - entries := response["config"].(map[string]any)["mcp_servers"].(map[string]any) - first := entries["first"].(map[string]any) - switch mutation { - case "missing": - delete(first, "bearer_token_env_var") - case "ambient": - first["bearer_token_env_var"] = "OPERATOR_SECRET" - case "swapped": - first["bearer_token_env_var"] = servers["second"].BearerTokenEnvVar - case "extra": - entries["public"].(map[string]any)["bearer_token_env_var"] = servers["first"].BearerTokenEnvVar - case "header": - first["http_headers"] = map[string]string{"Authorization": "Bearer synthetic-private"} - case "helper": - first["http_headers_helper"] = "operator-helper" - } - raw, err := json.Marshal(response) - if err != nil || matchesMCPConfig(raw, servers) != (mutation == "none") { - t.Fatal("incorrect authenticated configuration decision", err) - } - }) - } -} diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight.go b/apps/daemon/internal/agent/codex/mcp_http_preflight.go index b499038fb..9a17434e5 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight.go @@ -46,7 +46,7 @@ func matchesMCPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) return false } if expected.URL != "" { - if server["url"] != expected.URL || !matchesMCPHeaderMap(server, "env_http_headers", expected.EnvHTTPHeaders) { + if server["url"] != expected.URL { return false } delete(server, "url") @@ -73,12 +73,6 @@ func matchesMCPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) } delete(server, "required") } - if expected.BearerTokenEnvVar != "" { - if server["bearer_token_env_var"] != expected.BearerTokenEnvVar { - return false - } - delete(server, "bearer_token_env_var") - } if expected.EnabledTools != nil { // Compare sets: native enabled_tools is an allowlist, not an ordered program. actual, ok := server["enabled_tools"].([]any) @@ -121,19 +115,3 @@ func matchesMCPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) } return true } - -func matchesMCPHeaderMap(server map[string]any, field string, expected map[string]string) bool { - actual, present := server[field] - if len(expected) == 0 { - return !present - } - want := make(map[string]any, len(expected)) - for key, value := range expected { - want[key] = value - } - if !reflect.DeepEqual(actual, want) { - return false - } - delete(server, field) - return true -} diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go index f7b5fc792..d3a15c88b 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -7,13 +7,14 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func TestPublicMCPHTTPEffectiveConfiguration(t *testing.T) { for _, allowlist := range []*[]string{nil, new([]string), {"lookup", "query"}} { servers := map[string]mcpServerConfig{"docs": {URL: "https://docs.example/mcp", EnabledTools: allowlist}} - for _, mutation := range []string{"none", "ambient", "url", "header", "header helper", "env header", "auth", "required", "tools", "disabled", "remote", "plugins", "apps", "keyring", "policy"} { + for _, mutation := range []string{"none", "ambient", "url", "header", "header helper", "env header", "bearer", "auth", "required", "tools", "disabled", "remote", "plugins", "apps", "keyring", "policy"} { t.Run(mutation+"/"+allowlistName(allowlist), func(t *testing.T) { response := mcpHTTPConfigResponse(servers) config := response["config"].(map[string]any) @@ -30,6 +31,8 @@ func TestPublicMCPHTTPEffectiveConfiguration(t *testing.T) { server["http_headers_helper"] = "operator-credentials" case "env header": server["env_http_headers"] = map[string]any{"Authorization": "OPERATOR_SECRET"} + case "bearer": + server["bearer_token_env_var"] = "OPERATOR_SECRET" case "auth": server["auth"] = "chatgpt" case "required": @@ -126,12 +129,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) req.ExecutionControls = nil - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} - if mode == "reject bearer reference" { - token := "synthetic-private-bearer" - servers[0].BearerToken = &token - } - req.MCPHTTPServers = &servers + cfg.view.MCP = []agent.MCPBinding{{ServerLabel: "docs", ConnectionOrigin: "service", CredentialAuthority: "project_vault", Transport: "http", ServerURL: "http://127.0.0.1:17102/mcp"}} if mode == "resume" { req.AgentSessionID = "fixture-native-thread" } @@ -139,10 +137,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { if err := os.WriteFile(filepath.Join(root, "unknown-status"), []byte("unknown"), 0o600); err != nil { t.Fatal(err) } - declarations, _, err := runtimeMCPServers(req) - if err != nil { - t.Fatal(err) - } + declarations := mcpServersFromBindings(cfg.view.MCP) response := mcpHTTPConfigResponse(declarations) if mode == "reject" { response["config"].(map[string]any)["mcp_servers"].(map[string]any)["operator"] = map[string]any{"url": "https://operator.example/private"} @@ -166,10 +161,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { t.Fatal(err) } assertPreparationOnly(t, root) - home, err := allocCodexHome(req.StateKey) - if err != nil { - t.Fatal(err) - } + work := filepath.Join(root, "home", agent.ViewWorkName) out := make(chan proto.Envelope, 20) turn, err := e.StartTurn(t.Context(), "actual-run", proto.TextInput("actual prompt"), out) if err != nil { @@ -193,7 +185,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { if err := json.Unmarshal(frame.Params, ¶ms); err != nil { t.Fatal(err) } - if !checked || params["cwd"] != home || (frame.Method == "thread/resume") != (mode == "resume") { + if !checked || params["cwd"] != work || (frame.Method == "thread/resume") != (mode == "resume") { t.Fatal("thread started before the check or with another cwd") } } diff --git a/apps/daemon/internal/agent/codex/mcp_http_test.go b/apps/daemon/internal/agent/codex/mcp_http_test.go index 0c0b28782..1a2639e91 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_test.go @@ -14,15 +14,16 @@ import ( ) func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + root := t.TempDir() + cfg := testView(t, "", root) tools := []string{"lookup.docs", `quote"tool`} denyAll := []string{} - servers := []proto.MCPHTTPServer{ - {ConnectionOrigin: "service", ServerLabel: "docs.server", ServerURL: "https://docs.example/mcp", AllowedTools: &tools, Required: true}, - {ConnectionOrigin: "service", ServerLabel: "blocked", ServerURL: "http://127.0.0.1:12345/mcp", AllowedTools: &denyAll}, + cfg.view.MCP = []agent.MCPBinding{ + {ServerLabel: "docs.server", ConnectionOrigin: "service", CredentialAuthority: "project_vault", Transport: "http", ServerURL: "http://127.0.0.1:17102/mcp", AllowedTools: &tools, Required: true}, + {ServerLabel: "blocked", ConnectionOrigin: "service", CredentialAuthority: "project_vault", Transport: "http", ServerURL: "http://127.0.0.1:17103/mcp", AllowedTools: &denyAll}, } - req := prepared(t, "public-mcp", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, MCPHTTPServers: &servers}) - plan, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + req := prepared(t, "public-mcp", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true}) + plan, err := prepareViewPlan(t.Context(), req, cfg) if err != nil { t.Fatal(err) } @@ -30,13 +31,10 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { if !slices.Contains(plan.DisableFeatures, "apps") || !slices.Contains(plan.DisableFeatures, "plugins") || !slices.Contains(plan.ExtraConfig, [2]string{"mcp_oauth_credentials_store", `"file"`}) { t.Fatal("native profile was not pinned") } - home, err := allocCodexHome(req.StateKey) - if err != nil { - t.Fatal(err) - } - if plan.Cwd != home { - t.Fatal("environment:none cwd is not the private home") + if plan.Cwd != filepath.Join(root, agent.ViewWorkName) { + t.Fatal("environment:none cwd is not the view's work directory", plan.Cwd) } + home := filepath.Join(root, viewCodexHome) config, err := os.ReadFile(filepath.Join(home, "config.toml")) if err != nil { t.Fatal(err) @@ -46,16 +44,12 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { t.Fatalf("missing native config %q", expected) } } - tools[0] = "mutated" - if (*plan.mcpServers["docs.server"].EnabledTools)[0] != "lookup.docs" { - t.Fatal("prepared allowlist retained caller-owned memory") - } history := filepath.Join(home, "retained-history.jsonl") if err := os.WriteFile(history, []byte("native-history"), 0o600); err != nil { t.Fatal(err) } - servers = []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "replacement", ServerURL: "https://new.example/mcp"}} - second, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) + cfg.view.MCP = []agent.MCPBinding{{ServerLabel: "replacement", ConnectionOrigin: "service", CredentialAuthority: "project_vault", Transport: "http", ServerURL: "http://127.0.0.1:17104/mcp"}} + second, err := prepareViewPlan(t.Context(), req, cfg) if err != nil { t.Fatal(err) } @@ -70,33 +64,21 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { } } -func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { +func TestPublicMCPHTTPRejectsStoredCredentials(t *testing.T) { valid := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} - if _, _, err := runtimeMCPServers(agent.PrepareRequest{PromptRequestPayload: proto.PromptRequestPayload{MCPHTTPServers: &valid}}); err == nil { - t.Fatal("non-service profile accepted") - } - for _, server := range []proto.MCPHTTPServer{ - {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://user:synthetic-secret@docs.example/mcp"}, - {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp?token=synthetic-secret"}, - {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "file:///tmp/mcp"}, - } { - servers := []proto.MCPHTTPServer{server} - if _, _, err := runtimeMCPServers(agent.PrepareRequest{PromptRequestPayload: proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}}); err == nil || strings.Contains(err.Error(), "synthetic-secret") { - t.Fatal("unsupported configuration was accepted or exposed", err) - } - } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - home, err := allocCodexHome("credentials") - if err != nil { + root := t.TempDir() + cfg := testView(t, "", root) + cfg.view.MCP = []agent.MCPBinding{{ServerLabel: "docs", ConnectionOrigin: "service", CredentialAuthority: "project_vault", Transport: "http", ServerURL: "http://127.0.0.1:17102/mcp"}} + if err := os.Mkdir(filepath.Join(root, viewCodexHome), 0o700); err != nil { t.Fatal(err) } - path := filepath.Join(home, ".credentials.json") + path := filepath.Join(root, viewCodexHome, ".credentials.json") stored := []byte(`{"synthetic":"private"}`) if err := os.WriteFile(path, stored, 0o600); err != nil { t.Fatal(err) } req := prepared(t, "credentials", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), DisableExecutionEnvironment: true, MCPHTTPServers: &valid}) - if _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil { + if _, err := prepareViewPlan(t.Context(), req, cfg); err == nil { t.Fatal("existing MCP credentials accepted") } if after, err := os.ReadFile(path); err != nil || !reflect.DeepEqual(after, stored) { @@ -112,9 +94,6 @@ func mcpHTTPConfigResponse(servers map[string]mcpServerConfig) map[string]any { if server.EnabledTools != nil { entry["enabled_tools"] = append([]string{}, (*server.EnabledTools)...) } - if server.BearerTokenEnvVar != "" { - entry["bearer_token_env_var"] = server.BearerTokenEnvVar - } entries[name] = entry } return map[string]any{"config": map[string]any{"mcp_servers": entries, "features": map[string]any{"plugins": false, "apps": false}, "mcp_oauth_credentials_store": "file"}} @@ -130,17 +109,3 @@ func writeMCPHTTPConfigResponse(t *testing.T, path string, response any) { t.Fatal(err) } } - -func TestPublicMCPBearerRequiresHTTPS(t *testing.T) { - req := agent.PrepareRequest{PromptRequestPayload: proto.PromptRequestPayload{DisableExecutionEnvironment: true}} - token := "synthetic-private-token" - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "http://tools.example/mcp", BearerToken: &token}} - req.MCPHTTPServers = &servers - if _, _, err := runtimeMCPServers(req); err == nil || strings.Contains(err.Error(), token) { - t.Fatal("plaintext bearer accepted or exposed") - } - servers[0].ServerURL = "https://tools.example/mcp" - if _, _, err := runtimeMCPServers(req); err != nil { - t.Fatal("HTTPS bearer declaration rejected", err) - } -} diff --git a/apps/daemon/internal/agent/codex/mcp_required_test.go b/apps/daemon/internal/agent/codex/mcp_required_test.go index cf8860f10..88832cb6c 100644 --- a/apps/daemon/internal/agent/codex/mcp_required_test.go +++ b/apps/daemon/internal/agent/codex/mcp_required_test.go @@ -18,17 +18,13 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) req.ExecutionControls = nil - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp", Required: true}} - req.MCPHTTPServers = &servers + cfg.view.MCP = []agent.MCPBinding{{ServerLabel: "docs", ConnectionOrigin: "service", CredentialAuthority: "project_vault", Transport: "http", ServerURL: "http://127.0.0.1:17102/mcp", Required: true}} method := "thread/start" if strings.HasPrefix(mode, "resume") { req.AgentSessionID = "fixture-native-thread" method = "thread/resume" } - declarations, _, err := runtimeMCPServers(req) - if err != nil { - t.Fatal(err) - } + declarations := mcpServersFromBindings(cfg.view.MCP) config := filepath.Join(root, "mcp-config.json") writeMCPHTTPConfigResponse(t, config, mcpHTTPConfigResponse(declarations)) t.Setenv("OAC_TEST_PREPARATION_MCP_CONFIG", config) diff --git a/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go b/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go index 79b74bf3a..c644cc45e 100644 --- a/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go +++ b/apps/daemon/internal/agent/codex/model_catalog_command_unix_test.go @@ -29,7 +29,7 @@ func checkCatalogDescendantCancellation(t *testing.T, finish string) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) - go func() { done <- prepareModelVerbosity(ctx, binary, &SessionPlan{Cwd: dir}) }() + go func() { done <- verifyModelVerbosity(ctx, catalogProbe{binary: binary, dir: dir}, &SessionPlan{}) }() deadline := time.Now().Add(3 * time.Second) for { if _, err := os.Stat(filepath.Join(dir, "ready")); err == nil { diff --git a/apps/daemon/internal/agent/codex/model_verbosity.go b/apps/daemon/internal/agent/codex/model_verbosity.go index ca9c23fc8..64751a7e2 100644 --- a/apps/daemon/internal/agent/codex/model_verbosity.go +++ b/apps/daemon/internal/agent/codex/model_verbosity.go @@ -18,12 +18,9 @@ type catalogProbe struct { env []string } -// Validate against the binary's active catalog and use that same snapshot for -// execution. A CLI override alone is silently ignored for unsupported models. -func prepareModelVerbosity(ctx context.Context, binary string, plan *SessionPlan) error { - return verifyModelVerbosity(ctx, catalogProbe{binary: binary, dir: plan.Cwd, env: append(os.Environ(), plan.Env...)}, plan) -} - +// verifyModelVerbosity validates the request against the binary's active +// catalog and uses that same snapshot for execution. A CLI override alone is +// silently ignored for unsupported models. func verifyModelVerbosity(ctx context.Context, probe catalogProbe, plan *SessionPlan) error { // Protocol medium is the default text amount: a model with verbosity support // applies the override and Codex ignores it for any other, so no catalog diff --git a/apps/daemon/internal/agent/codex/model_verbosity_test.go b/apps/daemon/internal/agent/codex/model_verbosity_test.go index 8a0bda9e2..7b62ec45a 100644 --- a/apps/daemon/internal/agent/codex/model_verbosity_test.go +++ b/apps/daemon/internal/agent/codex/model_verbosity_test.go @@ -28,22 +28,21 @@ func TestCatalogVerbositySupport(t *testing.T) { } } -func TestPrepareModelVerbosity(t *testing.T) { +func TestViewModelVerbosity(t *testing.T) { if !SupportsTextVerbosity { t.Skip("catalog probe requires Unix") } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) binary := filepath.Join(t.TempDir(), "codex") catalog := `{"models":[{"slug":"known-model","support_verbosity":true,"native_extra":{"keep":true}}]}` if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+catalog+"'\n"), 0700); err != nil { t.Fatal(err) } - plan, err := BuildSessionPlan(prepared(t, "state", proto.PromptRequestPayload{ModelProvider: fixtureProvider(), Model: "known-model", ExecutionControls: &proto.ExecutionControls{TextVerbosity: "high"}})) + plan, err := testPlan(t, prepared(t, "state", proto.PromptRequestPayload{ModelProvider: fixtureProvider(), Model: "known-model", ExecutionControls: &proto.ExecutionControls{TextVerbosity: "high"}})) if err != nil { t.Fatal(err) } defer plan.Cleanup() - if err := prepareModelVerbosity(context.Background(), binary, &plan); err != nil { + if err := viewModelVerbosity(context.Background(), binary, &plan, *fixtureProvider()); err != nil { t.Fatal(err) } kv := plan.ExtraConfig[len(plan.ExtraConfig)-1] @@ -60,19 +59,18 @@ func TestPrepareModelVerbosity(t *testing.T) { t.Fatalf("catalog was not removed: %v", err) } plan.Model = "custom-provider-model" - if err := prepareModelVerbosity(context.Background(), binary, &plan); err == nil { + if err := viewModelVerbosity(context.Background(), binary, &plan, *fixtureProvider()); err == nil { t.Fatal("accepted model that would ignore verbosity") } - if err := prepareModelVerbosity(context.Background(), "/missing-codex", &plan); err == nil { + if err := viewModelVerbosity(context.Background(), "/missing-codex", &plan, *fixtureProvider()); err == nil { t.Fatal("accepted unreadable catalog") } } -func TestPrepareDefaultModelVerbosity(t *testing.T) { +func TestViewDefaultModelVerbosity(t *testing.T) { if !SupportsTextVerbosity { t.Skip("catalog probe requires Unix") } - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) binary := filepath.Join(t.TempDir(), "codex") catalog := `{"models":[{"slug":"supported","support_verbosity":true,"default_verbosity":"low"},{"slug":"unsupported","support_verbosity":false}]}` if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' '"+catalog+"'\n"), 0700); err != nil { @@ -81,12 +79,12 @@ func TestPrepareDefaultModelVerbosity(t *testing.T) { for _, model := range []string{"supported", "unsupported", "unknown-provider-model"} { for _, level := range []string{"low", "medium", "high"} { t.Run(model+"/"+level, func(t *testing.T) { - plan, err := BuildSessionPlan(prepared(t, "state", proto.PromptRequestPayload{ModelProvider: fixtureProvider(), Model: model, ExecutionControls: &proto.ExecutionControls{TextVerbosity: level}})) + plan, err := testPlan(t, prepared(t, "state", proto.PromptRequestPayload{ModelProvider: fixtureProvider(), Model: model, ExecutionControls: &proto.ExecutionControls{TextVerbosity: level}})) if err != nil { t.Fatal(err) } defer func() { plan.Cleanup() }() - err = prepareModelVerbosity(context.Background(), binary, &plan) + err = viewModelVerbosity(context.Background(), binary, &plan, *fixtureProvider()) if model != "supported" && level != "medium" { if err == nil { t.Fatal("accepted unsupported non-default verbosity") diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 48e1cec2a..ad7cf0fe4 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -6,25 +6,21 @@ import ( "fmt" "os" "path/filepath" - "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) // SessionPlan holds the resolved per-prompt launch plan derived from // the daemon's PromptRequestPayload. type SessionPlan struct { - // Cwd is the working directory passed to codex and the spawned - // app-server: the bound workspace root for an Environment request and, - // for environment:none, the Session's private CODEX_HOME or a view's work - // directory. + // Cwd is the working directory of the app-server in the view: the + // Environment's workspace root, or the view's work directory with + // environment none. Cwd string - // Env is the environment slice (KEY=value) the plan adds. A local - // codex layers it onto os.Environ(); in an agent-host view it is the - // complete environment. Includes CODEX_HOME. + // Env is the app-server's complete environment (KEY=value), including + // CODEX_HOME. Env []string // ExtraConfig is a list of `-c key=value` overrides applied at the @@ -64,17 +60,9 @@ type SessionPlan struct { Cleanup func() } -// BuildSessionPlan derives a SessionPlan from the request's prepared model -// configuration and ExecutionControls. The codex binary is resolved via PATH. -func BuildSessionPlan(req agent.PrepareRequest) (SessionPlan, error) { - return buildSessionPlan(req, func() (agent.ViewDir, error) { - home, err := allocCodexHome(req.StateKey) - return agent.ViewDir{Host: home, View: home}, err - }) -} - -// buildSessionPlan derives the plan with CODEX_HOME from allocHome, which runs -// only after the request validates. +// buildSessionPlan derives the plan from the request's prepared model +// configuration and ExecutionControls, with CODEX_HOME from allocHome, which +// runs only after the request validates. func buildSessionPlan(req agent.PrepareRequest, allocHome func() (agent.ViewDir, error)) (SessionPlan, error) { plan := SessionPlan{ // Harnesses run unattended: Codex never offers its ask-the-user tool. @@ -125,32 +113,6 @@ func buildSessionPlan(req agent.PrepareRequest, allocHome func() (agent.ViewDir, // helpers // --------------------------------------------------------------------------- -func allocCodexHome(agentStateKey string) (string, error) { - if strings.TrimSpace(agentStateKey) == "" { - return "", fmt.Errorf("codex: agentStateKey required for CODEX_HOME allocation") - } - root, err := paths.Root() - if err != nil { - return "", err - } - parts := strings.Split(agentStateKey, "/") - safeParts := make([]string, 0, len(parts)) - for _, part := range parts { - if safe := safePathPartCodex(part); safe != "" { - safeParts = append(safeParts, safe) - } - } - if len(safeParts) == 0 { - return "", fmt.Errorf("codex: invalid agentStateKey %q", agentStateKey) - } - dirParts := append([]string{root, "daemon", "agent-sessions"}, safeParts...) - dir := filepath.Join(dirParts...) - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Errorf("codex: create CODEX_HOME %s: %w", dir, err) - } - return dir, nil -} - // openNativeHome opens CODEX_HOME from its parent. Every read and write in the // home goes through this Root: the Session user owns a view home, and a link // it leaves there resolves only inside the parent, never outside it. @@ -189,22 +151,6 @@ func nativeProvider(provider modelprovider.Provider) providerConfig { return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses"} } -func safePathPartCodex(runID string) string { - var b strings.Builder - for _, r := range runID { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - out := b.String() - if out == "" { - return "run" - } - return out -} - // strconv quotes a value as a TOML string. Done by reusing the JSON // encoder for escape rules — TOML strings accept the same standard // escape set so this is wire-safe. diff --git a/apps/daemon/internal/agent/codex/options_test.go b/apps/daemon/internal/agent/codex/options_test.go index 15bbe1656..2cc246632 100644 --- a/apps/daemon/internal/agent/codex/options_test.go +++ b/apps/daemon/internal/agent/codex/options_test.go @@ -8,10 +8,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestBuildSessionPlan_DefaultsToBypass(t *testing.T) { - plan, err := BuildSessionPlan(prepared(t, "conv-1/agent-1/codex", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider()})) +func TestSessionPlan_DefaultsToBypass(t *testing.T) { + plan, err := testPlan(t, prepared(t, "conv-1/agent-1/codex", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider()})) if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) + t.Fatal(err) } if !slices.Contains(plan.ExtraConfig, [2]string{"tools.experimental_request_user_input.enabled", "false"}) { t.Fatalf("default plan must disable the native ask-the-user tool, got %+v", plan.ExtraConfig) @@ -22,10 +22,10 @@ func TestBuildSessionPlan_DefaultsToBypass(t *testing.T) { plan.Cleanup() } -func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { - plan, err := BuildSessionPlan(prepared(t, "conv-1/agent-1/codex", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider()})) +func TestSessionPlan_SetsCodexHomeAndEnv(t *testing.T) { + plan, err := testPlan(t, prepared(t, "conv-1/agent-1/codex", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider()})) if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) + t.Fatal(err) } defer plan.Cleanup() hasCodexHome := false @@ -46,33 +46,8 @@ func TestBuildSessionPlan_AllocsCodexHomeAndEnv(t *testing.T) { } } -func TestBuildSessionPlan_StableCodexHomeByStateKey(t *testing.T) { - stateKey := "conv-stable/agent-stable/codex" - planA, err := BuildSessionPlan(prepared(t, stateKey, proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider()})) - if err != nil { - t.Fatalf("BuildSessionPlan A: %v", err) - } - planB, err := BuildSessionPlan(prepared(t, stateKey, proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider()})) - if err != nil { - t.Fatalf("BuildSessionPlan B: %v", err) - } - if codexHomeFromEnv(planA.Env) == "" || codexHomeFromEnv(planA.Env) != codexHomeFromEnv(planB.Env) { - t.Fatalf("CODEX_HOME must be stable by state key: A=%q B=%q", codexHomeFromEnv(planA.Env), codexHomeFromEnv(planB.Env)) - } -} - -func codexHomeFromEnv(env []string) string { - for _, kv := range env { - if strings.HasPrefix(kv, "CODEX_HOME=") { - return strings.TrimPrefix(kv, "CODEX_HOME=") - } - } - return "" -} - -func TestBuildSessionPlan_CarriesModelAndSystemPrompt(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, err := BuildSessionPlan(prepared(t, "conv/agent/codex", proto.PromptRequestPayload{ModelProvider: fixtureProvider(), Model: "MiniMax-M3", SystemPrompt: "current reference"})) +func TestSessionPlan_CarriesModelAndSystemPrompt(t *testing.T) { + plan, err := testPlan(t, prepared(t, "conv/agent/codex", proto.PromptRequestPayload{ModelProvider: fixtureProvider(), Model: "MiniMax-M3", SystemPrompt: "current reference"})) 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 75d73c474..49d7b4265 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "sync" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" @@ -28,12 +27,7 @@ func newExecutor(parent context.Context, req agent.PrepareRequest, cfg sessionCo if err != nil { return nil, err } - var plan SessionPlan - if cfg.view != nil { - plan, err = prepareViewPlan(parent, req, cfg) - } else { - plan, err = prepareSessionPlan(parent, req, cfg) - } + plan, err := prepareViewPlan(parent, req, cfg) if err != nil { return nil, err } @@ -41,17 +35,15 @@ func newExecutor(parent context.Context, req agent.PrepareRequest, cfg sessionCo cancelCtx, cancelFn := context.WithCancel(parent) rpcCfg := JSONRPCConfig{ - Binary: cfg.codexBinary, + Binary: cfg.view.binary, EnableFeatures: plan.EnableFeatures, DisableFeatures: plan.DisableFeatures, Cwd: plan.Cwd, - Env: append(os.Environ(), plan.Env...), + Env: plan.Env, + Launch: cfg.view.Launch, LogTag: "codex-preparation", Logger: cfg.logger, } - if cfg.view != nil { - rpcCfg.Binary, rpcCfg.Env, rpcCfg.Launch = cfg.view.binary, plan.Env, cfg.view.Launch - } for _, kv := range plan.ExtraConfig { rpcCfg.ExtraArgs = append(rpcCfg.ExtraArgs, "-c", kv[0]+"="+kv[1]) } diff --git a/apps/daemon/internal/agent/codex/preparation_close_test.go b/apps/daemon/internal/agent/codex/preparation_close_test.go index f4263dbc2..cb2abae2e 100644 --- a/apps/daemon/internal/agent/codex/preparation_close_test.go +++ b/apps/daemon/internal/agent/codex/preparation_close_test.go @@ -2,6 +2,8 @@ package codex import ( "context" + "os" + "path/filepath" "testing" "time" ) @@ -34,3 +36,17 @@ func TestPreparationCancellationDuringReadiness(t *testing.T) { } assertPreparationOnly(t, root) } + +func TestReadOnlyPreparationRejectedBeforeNativeSetup(t *testing.T) { + req, cfg, root := preparationFixture(t) + req.WorkspaceReadOnly = true + if e, err := newExecutor(t.Context(), req, cfg); err == nil || e != nil { + t.Fatal("read-only request admitted", err) + } + if len(preparationFrames(t, root)) != 0 { + t.Fatal("read-only request started native child") + } + if _, err := os.Stat(filepath.Join(root, "home", viewCodexHome)); !os.IsNotExist(err) { + t.Fatal("read-only request created native state", err) + } +} diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index 54102b229..f0214989a 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -22,25 +23,27 @@ type preparationFrame struct { Params json.RawMessage `json:"params"` } +// preparationFixture prepares a fake codex, which records each frame under +// root, in a test view whose home is root/home. func preparationFixture(t *testing.T) (agent.PrepareRequest, sessionConfig, string) { t.Helper() root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv("OAC_TEST_PREPARATION_FAKE", "1") t.Setenv("OAC_TEST_PREPARATION_FRAMES", filepath.Join(root, "frames.jsonl")) t.Setenv("OAC_TEST_PREPARATION_STATUS", filepath.Join(root, "environment-status")) t.Setenv("OAC_TEST_PREPARATION_BLOCK", "") - for _, key := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { - t.Setenv(key, "") - } binary := filepath.Join(root, "fake-codex") executable := "'" + strings.ReplaceAll(os.Args[0], "'", "'\\''") + "'" - body := "#!/bin/sh\nexec " + executable + " -test.run=^TestPreparationFakeCodexProcess$ -- \"$@\"\n" + body := "#!/bin/sh\nOAC_TEST_PREPARATION_FAKE=1 exec " + executable + " -test.run=^TestPreparationFakeCodexProcess$ -- \"$@\"\n" if err := os.WriteFile(binary, []byte(body), 0o700); err != nil { t.Fatal(err) } - cfg := defaultSessionConfig() - cfg.codexBinary = binary + cfg := testView(t, binary, filepath.Join(root, "home")) + // A view always owns the native MCP configuration, empty without MCP. + config := filepath.Join(root, "mcp-config.json") + if err := os.WriteFile(config, []byte(`{"config":{"mcp_servers":{},"features":{"plugins":false,"apps":false},"mcp_oauth_credentials_store":"file"}}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_TEST_PREPARATION_MCP_CONFIG", config) req := prepared(t, "prepared-session", proto.PromptRequestPayload{ AgentKind: "codex", Model: "fixture-model", @@ -52,6 +55,34 @@ func preparationFixture(t *testing.T) (agent.PrepareRequest, sessionConfig, stri return req, cfg, root } +// testView runs binary as codex in a view whose home is home, at the same +// path on the host and in the view. Launch runs binary on this host, with the +// plan's environment over the test's. +func testView(t testing.TB, binary, home string) sessionConfig { + t.Helper() + if err := os.MkdirAll(filepath.Join(home, agent.ViewWorkName), 0o700); err != nil { + t.Fatal(err) + } + cfg := defaultSessionConfig() + cfg.codexBinary = binary + cfg.view = &viewLaunch{binary: binary, ViewSession: agent.ViewSession{ + Home: agent.ViewDir{Host: home, View: home}, + Proxy: "http://127.0.0.1:9", + Launch: func(opts clirunner.StartOptions) (*clirunner.Process, error) { + opts.Env = append(os.Environ(), opts.Env...) + return clirunner.Start(opts) + }, + }} + return cfg +} + +// testPlan builds req's session plan with CODEX_HOME in a new directory. +func testPlan(t testing.TB, req agent.PrepareRequest) (SessionPlan, error) { + t.Helper() + home := t.TempDir() + return buildSessionPlan(req, func() (agent.ViewDir, error) { return agent.ViewDir{Host: home, View: home}, nil }) +} + // prepared is req as the registry and dispatch hand it to a Codex factory. func prepared(t testing.TB, stateKey string, req proto.PromptRequestPayload) agent.PrepareRequest { t.Helper() @@ -116,7 +147,7 @@ func assertPreparationOnly(t *testing.T, root string) { func preparedCatalogs(t *testing.T, root string) []string { t.Helper() - files, err := filepath.Glob(filepath.Join(root, "daemon", "agent-sessions", "*", "model-catalog-*.json")) + files, err := filepath.Glob(filepath.Join(root, "home", viewCodexHome, "model-catalog-*.json")) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/preparation_router_test.go b/apps/daemon/internal/agent/codex/preparation_router_test.go index 0fe775666..b22c16192 100644 --- a/apps/daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/daemon/internal/agent/codex/preparation_router_test.go @@ -2,15 +2,11 @@ package codex import ( "context" - "os" - "path/filepath" "testing" "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "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/agentdaemon/proto/prototest" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" @@ -32,31 +28,10 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { for _, start := range []bool{false, true} { t.Run(map[bool]string{false: "disconnect-before-start", true: "transfer-and-cancel"}[start], func(t *testing.T) { req, cfg, root := preparationFixture(t) - if err := os.Chmod(root, 0700); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", filepath.Join(t.TempDir(), "capabilities")) t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") - environment, session, workspace := uuid.NewString(), uuid.NewString(), filepath.Join(root, "harness") - if err := os.MkdirAll(workspace, 0700); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - "OAC_RUNTIME_ENVIRONMENT_ID": environment, - "OAC_RUNTIME_SESSION_ID": session, - "OAC_RUNTIME_WORKSPACE": workspace, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", - } { - t.Setenv(key, value) - } - binding, err := localworkspace.Load() - if err != nil { - t.Fatal(err) - } - req.DisableExecutionEnvironment = false - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}} + session := uuid.NewString() registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, harnessconfiguration.Configuration()) + registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, harnessconfiguration.Configuration()) prepared := make(chan *Executor, 1) registry.RegisterExecutor("codex", func(ctx context.Context, req agent.PrepareRequest) (agent.Executor, error) { e, err := newExecutor(ctx, req, cfg) @@ -67,8 +42,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { return e, nil }) sender := make(preparationWireSender, 64) - environments := func(proto.AssignmentRef, proto.AssignmentBindPayload) dispatch.Environment { return binding } - r, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, Environments: environments}) + r, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender}) if err != nil { t.Fatal(err) } @@ -90,7 +64,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { t.Fatal(err) } } - send(proto.TypeAssignmentBind, "bind", proto.AssignmentBindPayload{EnvironmentID: environment}) + send(proto.TypeAssignmentBind, "bind", proto.AssignmentBindPayload{}) await := func(state string) proto.PreparationStatusPayload { t.Helper() timer := time.NewTimer(4 * time.Second) diff --git a/apps/daemon/internal/agent/codex/provider_config_test.go b/apps/daemon/internal/agent/codex/provider_config_test.go index 4a1bff726..01182faf5 100644 --- a/apps/daemon/internal/agent/codex/provider_config_test.go +++ b/apps/daemon/internal/agent/codex/provider_config_test.go @@ -117,13 +117,13 @@ func TestWriteCodexProviderConfig_AppendsAlongsideMCP(t *testing.T) { } } -func TestBuildSessionPlan_PinsModelProviderWhenProviderSet(t *testing.T) { - plan, err := BuildSessionPlan(prepared(t, "conv-1/agent-1/codex", proto.PromptRequestPayload{ +func TestSessionPlan_PinsModelProviderWhenProviderSet(t *testing.T) { + plan, err := testPlan(t, prepared(t, "conv-1/agent-1/codex", proto.PromptRequestPayload{ Model: "fixture-model", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: "https://x/v1", APIKey: "sk-x"}, })) if err != nil { - t.Fatalf("BuildSessionPlan: %v", err) + t.Fatal(err) } defer plan.Cleanup() if plan.ModelProvider != "oac" { diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index f3a4912e2..adf984d62 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -143,16 +143,13 @@ func TestRequiredHistoryResolution(t *testing.T) { } func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { - // Recovery searches history for the Session's working directory: the private - // home for environment:none and the bound workspace root otherwise. + // Recovery searches history for the Session's working directory: the view's + // work directory for environment:none and the workspace root otherwise. for _, environment := range []string{"none", "local"} { t.Run(environment, func(t *testing.T) { req, cfg, root := preparationFixture(t) req.RequireExistingNativeSession = true - cwd, err := allocCodexHome(req.StateKey) - if err != nil { - t.Fatal(err) - } + cwd := filepath.Join(root, "home", agent.ViewWorkName) if environment == "local" { cwd = filepath.Join(root, "workspace") if err := os.Mkdir(cwd, 0o700); err != nil { @@ -191,25 +188,3 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { }) } } - -func TestRecoveryRequiresWritableAgentState(t *testing.T) { - for _, mode := range []string{"no-state", "read-only"} { - t.Run(mode, func(t *testing.T) { - req, cfg, root := preparationFixture(t) - req.RequireExistingNativeSession = true - switch mode { - case "no-state": - req.StateKey = "" - case "read-only": - req.WorkspaceReadOnly = true - } - if e, err := newExecutor(t.Context(), req, cfg); err == nil { - _ = e.Close(t.Context()) - t.Fatal("invalid recovery admitted") - } - if len(preparationFrames(t, root)) != 0 { - t.Fatal("invalid recovery launched native process") - } - }) - } -} diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index 90f7f509a..d13e62cf8 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -27,7 +27,7 @@ type sessionConfig struct { codexBinary string logger *slog.Logger killTimeout time.Duration - // view runs codex in an agent-host Session view instead of on this host. + // view is the agent-host Session view codex runs in. view *viewLaunch } diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go deleted file mode 100644 index 5fe5499f0..000000000 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ /dev/null @@ -1,70 +0,0 @@ -package codex - -import ( - "context" - "fmt" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" -) - -func prepareSessionPlan(ctx context.Context, req agent.PrepareRequest, cfg sessionConfig) (SessionPlan, error) { - if err := validateNativeTransportEnvironment(); err != nil { - return SessionPlan{}, err - } - mcpServers, mcpEnv, err := runtimeMCPServers(req) - if err != nil { - return SessionPlan{}, err - } - plan, err := BuildSessionPlan(req) - if err != nil { - return SessionPlan{}, fmt.Errorf("codex: build session plan: %w", err) - } - if err := configureSubagentObservations(&plan, req.PromptRequestPayload); err != nil { - plan.Cleanup() - return SessionPlan{}, err - } - disableProgrammaticTools(&plan, req.ExecutionControls) - if req.LocalEnvironment != nil { - plan.Cwd = req.WorkspaceRoot - } else { - // environment:none has no workspace; the Session's private home is its cwd. - plan.Cwd = plan.home.View - } - - if req.LocalEnvironment != nil { - values, err := localworkspace.ReadOptionalToolEnvironment() - if err != nil { - plan.Cleanup() - return SessionPlan{}, err - } - for key, value := range values { - if strings.EqualFold(key, "CODEX_HOME") || strings.EqualFold(key, "HOME") || strings.EqualFold(key, "USERPROFILE") { - continue - } - plan.Env = append(plan.Env, key+"="+value) - } - } - - if req.DisableSubagents { - disableSubagents(&plan) - } - if mcpServers != nil { - if err := configureMCP(&plan, mcpServers); err != nil { - plan.Cleanup() - return SessionPlan{}, err - } - } - - if req.ExecutionControls != nil { - if err := prepareModelVerbosity(ctx, cfg.codexBinary, &plan); err != nil { - plan.Cleanup() - return SessionPlan{}, err - } - } - - plan.Env = append(plan.Env, mcpEnv...) - - return plan, nil -} diff --git a/apps/daemon/internal/agent/codex/session_plan_test.go b/apps/daemon/internal/agent/codex/session_plan_test.go deleted file mode 100644 index 3152fa6fa..000000000 --- a/apps/daemon/internal/agent/codex/session_plan_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package codex - -import ( - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestRuntimeUsesHostPermissions(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - req := prepared(t, "session", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{}}) - req.WorkspaceRoot = t.TempDir() - plan, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - if plan.Cwd != req.WorkspaceRoot { - t.Fatal("native cwd is not the bound workspace root", plan.Cwd) - } - for _, kv := range plan.ExtraConfig { - if kv[0] == "default_permissions" { - t.Fatal("obsolete permission wrapper", kv) - } - } -} - -func TestSelfHostedToolEnvironmentCannotRedirectNativeHistory(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - workspace := filepath.Join(root, "workspace") - if err := os.Mkdir(workspace, 0700); err != nil { - t.Fatal(err) - } - config := filepath.Join(root, "tool-env.json") - if err := os.WriteFile(config, []byte(`{"CODEX_HOME":"wrong","HOME":"wrong","USERPROFILE":"wrong","USER_VALUE":"ready"}`), 0600); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": "b3d154b8-543b-4248-97b1-665f9f418d52", "OAC_RUNTIME_SESSION_ID": "33e02e0d-6fc8-4904-9d7a-4b61b9094ae0", "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_CAPABILITY_DIRECTORY": filepath.Join(root, "capabilities"), "OAC_RUNTIME_NETWORK_ACCESS": "enabled", "OAC_RUNTIME_TOOL_ENV_FILE": config} { - t.Setenv(key, value) - } - req := prepared(t, "session", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{}}) - plan, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - values := map[string]string{} - for _, entry := range plan.Env { - key, value, _ := strings.Cut(entry, "=") - values[key] = value - } - if values["CODEX_HOME"] == "" || values["CODEX_HOME"] == "wrong" || values["HOME"] == "wrong" || values["USERPROFILE"] == "wrong" || values["USER_VALUE"] != "ready" { - t.Fatal("initialization changed native state identity") - } -} diff --git a/apps/daemon/internal/agent/codex/session_policy_test.go b/apps/daemon/internal/agent/codex/session_policy_test.go index 4aca86090..f60b592a4 100644 --- a/apps/daemon/internal/agent/codex/session_policy_test.go +++ b/apps/daemon/internal/agent/codex/session_policy_test.go @@ -15,8 +15,7 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { t.Run("profile="+profile, func(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(), prepared(t, "conv/agent/codex", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), DisableSubagents: true, DisableExecutionEnvironment: true}), sessionConfig{}) + plan, err := prepareViewPlan(context.Background(), prepared(t, "conv/agent/codex", proto.PromptRequestPayload{Model: "fixture", ModelProvider: fixtureProvider(), DisableSubagents: true, DisableExecutionEnvironment: true}), testView(t, "", t.TempDir())) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/version.go b/apps/daemon/internal/agent/codex/version.go index 6a93cf979..e305ec09e 100644 --- a/apps/daemon/internal/agent/codex/version.go +++ b/apps/daemon/internal/agent/codex/version.go @@ -27,9 +27,8 @@ func defaultBinary() string { return binpath.Codex() } var ErrCLINotFound = errors.New("codex CLI not found") // CheckCLIAvailable runs ` --version` and returns the trimmed -// first line. The empty binary name defaults to defaultBinary(). Matches -// the CLI availability check signature -// so connect.go's preflight loop treats every engine uniformly. +// first line. The empty binary name defaults to defaultBinary(). Discovery +// passes it as the version check. func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { if strings.TrimSpace(binary) == "" { binary = defaultBinary() diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go index 725d6da2b..f620dc49a 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -135,12 +135,7 @@ func prepareViewPlan(ctx context.Context, req agent.PrepareRequest, cfg sessionC if (req.LocalEnvironment == nil) != req.DisableExecutionEnvironment || !path.IsAbs(cwd) { return SessionPlan{}, fmt.Errorf("%w: codex: a view runs in an Environment workspace or with environment none", agent.ErrUnsupportedOperation) } - // The Harness runs each stdio alias without arguments, which the native - // configuration reports as an empty list. - servers, _, err := mcpServersFromBindings(view.MCP, func(stdio agent.EnvironmentMCP) (string, []string) { return stdio.Server.Command, []string{} }) - if err != nil { - return SessionPlan{}, err - } + servers := mcpServersFromBindings(view.MCP) plan, err := buildSessionPlan(req, func() (agent.ViewDir, error) { return viewHome(view.Home) }) if err != nil { return SessionPlan{}, fmt.Errorf("codex: build session plan: %w", err) diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index e53e77bc5..43551ae44 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -10,20 +10,21 @@ // images, structured output and Subagent observations use protocol messages // rather than additional Go interfaces; qualify and advertise them separately. // -// Registration: each adapter exports one Declaration. The Runtime discovers the -// static declaration list and installs each resulting Runtime through Register. -// Availability and factory selection belong to the adapter. RegisterKind resets -// the factories, so Register installs it first. The Runtime's Environment -// owner, not the adapter, serves and declares the Environments a kind runs -// in: Register composes its EnvironmentSupport with the Harness's own -// declaration once. +// Registration: each adapter exports one Declaration. The agent host discovers +// the static declaration list, registers each kind whose Runtime declares a +// View, and installs its own Executor factory for it, which prepares the +// view's Executor in the Session's view. Availability belongs to the adapter. +// RegisterKind resets the factory and the view, so it comes first. The +// Runtime's Environment owner, not the adapter, serves and declares the +// Environments a kind runs in: Register composes its EnvironmentSupport with +// the Harness's own declaration once. // // The Harness's support is its harnessconfig Declaration, which Core reads // too. Discovery and the Environment owner only narrow its Capabilities, and // the registered Executor factory runs only for a request whose selection that // narrowed declaration admits. Requests, events and capability descriptors // use the existing internal/agentdaemon/proto types. An Environment execution -// request carries the Runtime's bound workspace directory in +// request carries the Environment's workspace directory in // PrepareRequest.WorkspaceRoot; the native Harness runs there. package agent @@ -51,7 +52,7 @@ import ( // Declaration is the complete startup contract for a Harness implementation. // Discover returns nil when the adapter is not configured. An unavailable -// configured adapter returns a Runtime with Available=false and no factories. +// configured adapter returns a Runtime with Available=false and no View. // Discovery owns runtime-specific configuration, readiness and feature gates. type Declaration struct { Info proto.SupportedAgentKind @@ -61,14 +62,12 @@ type Declaration struct { // DiscoveryOptions provides process context without naming an implementation. type DiscoveryOptions struct { - Profile string Stdout, Stderr io.Writer } -// Runtime binds one discovered descriptor to its native factories. +// Runtime is one discovered descriptor and how its Harness runs. type Runtime struct { - Info proto.SupportedAgentKind - Executor ExecutorFactory + Info proto.SupportedAgentKind // View declares how the Harness runs in an agent-host Session view. // A nil View means the agent host rejects the kind with ErrUnsupportedOperation. View *View @@ -99,14 +98,11 @@ func (r *Registry) Register(declaration Declaration, runtime Runtime, environmen if runtime.Info.Kind != declaration.Info.Kind { panic("agent.Registry.Register: discovery kind differs from declaration") } - if !runtime.Info.Available && runtime.Executor != nil { - panic("agent.Registry.Register: unavailable runtime has factories") + if !runtime.Info.Available && runtime.View != nil { + panic("agent.Registry.Register: unavailable runtime has a view") } runtime.Info.Capabilities = environments.Compose(runtime.Info.Capabilities) r.RegisterKind(runtime.Info, declaration.Configuration) - if runtime.Executor != nil { - r.RegisterExecutor(runtime.Info.Kind, runtime.Executor) - } if runtime.View != nil { r.RegisterView(runtime.Info.Kind, *runtime.View) } diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index 236fca8d5..e7e9082bd 100644 --- a/apps/daemon/internal/agent/mcode/declaration.go +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -10,9 +10,9 @@ import ( configuration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" ) -// Declaration owns MiniMax Code discovery, configuration and execution -// factories. Native preparation verifies the applied admission and tool -// profile before input. +// Declaration owns MiniMax Code discovery, configuration and agent-host view. +// Native preparation verifies the applied admission and tool profile before +// input. var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "mcode", Capabilities: configuration.Configuration().Declaration.Capabilities}, Configuration: configuration.Configuration(), Discover: discover} @@ -30,11 +30,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, i return runtime } runtime.Info.Available, runtime.Info.Version = true, version - workspace := discoverWorkspace(parent, options, runtime) - if runtime.Info.Available { - runtime.Executor = NewExecutorFactory(workspace) - runtime.View = discoverView(options) - } + runtime.View = discoverView(options) fmt.Fprintf(options.Stdout, "mcode preflight ok (%s)\n", version) return runtime } diff --git a/apps/daemon/internal/agent/mcode/declaration_test.go b/apps/daemon/internal/agent/mcode/declaration_test.go index 5c80e7393..ddeb04323 100644 --- a/apps/daemon/internal/agent/mcode/declaration_test.go +++ b/apps/daemon/internal/agent/mcode/declaration_test.go @@ -9,8 +9,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" ) -// An available runtime is execution-capable; a rejected native version is unavailable. -func TestMCodeExecutionFollowsAvailability(t *testing.T) { +// A rejected native version is unavailable and has no view. +func TestMCodeDiscoveryFollowsAvailability(t *testing.T) { for _, tc := range []struct { version string check error @@ -19,8 +19,8 @@ func TestMCodeExecutionFollowsAvailability(t *testing.T) { rc := agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard} runtime := discoverWithCheck(t.Context(), rc, Declaration.Info, func(context.Context, string) (string, error) { return tc.version, tc.check }) info, available := runtime.Info, tc.check == nil - if (runtime.Executor != nil) != available { - t.Fatalf("factories: %+v", runtime) + if !available && runtime.View != nil { + t.Fatalf("unavailable runtime has a view: %+v", runtime) } if info.Available != available { t.Fatalf("available=%v", info.Available) diff --git a/apps/daemon/internal/agent/mcode/discovery_workspace.go b/apps/daemon/internal/agent/mcode/discovery_workspace.go deleted file mode 100644 index 62de3696b..000000000 --- a/apps/daemon/internal/agent/mcode/discovery_workspace.go +++ /dev/null @@ -1,74 +0,0 @@ -package mcode - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" -) - -func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, runtime *agent.Runtime) *WorkspaceConfig { - fail := func(err error) { - runtime.Info.Available = false - fmt.Fprintf(options.Stderr, "oac-daemon: mcode workspace unavailable: %v\n", err) - } - binding, err := localworkspace.Load() - if err != nil { - fail(err) - return nil - } - if binding == nil { - return nil - } - root, err := paths.Root() - if err != nil { - fail(err) - return nil - } - programs, err := findPrograms() - if err != nil { - fail(err) - return nil - } - c, err := ConfigureLocal(programs.binary, programs.node, programs.bridge, root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) - if err == nil { - err = CheckWorkspace(parent, c) - } - if err != nil { - fail(err) - return nil - } - return &c -} - -// programs are the installed node, CLI entry and workspace bridge, as absolute -// host paths. -type programs struct{ node, binary, bridge string } - -func findPrograms() (programs, error) { - node := os.Getenv("OAC_RUNTIME_MCODE_NODE") - if node == "" { - node = "node" - } - node, err := exec.LookPath(node) - if err != nil { - return programs{}, err - } - if node, err = filepath.Abs(node); err != nil { - return programs{}, err - } - binary, err := exec.LookPath(binpath.MCode()) - if err != nil { - return programs{}, err - } - if binary, err = filepath.Abs(binary); err != nil { - return programs{}, err - } - return programs{node: node, binary: binary, bridge: os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE")}, nil -} diff --git a/apps/daemon/internal/agent/mcode/environment_mcp.go b/apps/daemon/internal/agent/mcode/environment_mcp.go index 3f391adc5..e7c9fe437 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp.go @@ -5,27 +5,15 @@ import ( "net/url" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" ) -func runtimeMCP(req agent.PrepareRequest) ([]map[string]any, []agent.MCPBinding, error) { - bindings, err := agent.ResolveMCPBindings(req) - if err != nil { - return nil, nil, err - } - servers, err := workspaceMCP(bindings, localworkspace.MCPStdioCommand) - return servers, bindings, err -} - -// workspaceMCP renders the Session's MCP bindings as ACP servers, each stdio -// binding with the command and arguments stdio gives it. -func workspaceMCP(bindings []agent.MCPBinding, stdio func(agent.EnvironmentMCP) (string, []string)) ([]map[string]any, error) { +// workspaceMCP renders the Session's MCP bindings as ACP servers. The Harness +// runs each stdio binding's alias without arguments. +func workspaceMCP(bindings []agent.MCPBinding) ([]map[string]any, error) { var servers []map[string]any for _, binding := range bindings { if binding.Transport != "http" { - command, args := stdio(*binding.Stdio) - servers = append(servers, map[string]any{"name": binding.ServerLabel, "command": command, "args": args, "env": []map[string]string{}}) + servers = append(servers, map[string]any{"name": binding.ServerLabel, "command": binding.Stdio.Server.Command, "args": []string{}, "env": []map[string]string{}}) continue } server, err := environmentHTTPMCP(binding) @@ -37,20 +25,14 @@ func workspaceMCP(bindings []agent.MCPBinding, stdio func(agent.EnvironmentMCP) return servers, nil } -// ACP session servers remain in native memory; credentials never enter argv or -// persisted native configuration. Custom headers are not qualified because the -// pinned native HTTP client can forward them across redirect origins. +// environmentHTTPMCP renders an HTTP binding, the Session gateway's endpoint +// for the server, as an ACP Session server, which remains in native memory. +// The gateway holds the server's credentials, so the binding carries none. func environmentHTTPMCP(item agent.MCPBinding) (map[string]any, error) { endpoint, err := url.Parse(item.ServerURL) - if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.Fragment != "" || endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Opaque != "" || len(item.HTTPHeaders) != 0 { + if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.Fragment != "" || endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Opaque != "" || + len(item.HTTPHeaders) != 0 || item.BearerToken != nil { return nil, fmt.Errorf("mcode: unsupported environment HTTP MCP declaration") } - headers := []map[string]string{} - if item.BearerToken != nil { - if endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*item.BearerToken) { - return nil, fmt.Errorf("mcode: unsupported environment MCP bearer credential") - } - headers = append(headers, map[string]string{"name": "Authorization", "value": "Bearer " + *item.BearerToken}) - } - return map[string]any{"name": item.ServerLabel, "type": "http", "url": item.ServerURL, "headers": headers}, nil + return map[string]any{"name": item.ServerLabel, "type": "http", "url": item.ServerURL, "headers": []map[string]string{}}, nil } diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index c3925dc47..045728234 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -15,6 +15,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) +// environmentMCPFixture is an installed stdio MCP server. func environmentMCPFixture() agent.EnvironmentMCP { return agent.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/fixture", Server: agentplugin.MCPServer{ Name: "proof.server", Type: "stdio", Command: "never-exec-before-sandbox", Args: []string{"private-argument"}, @@ -22,97 +23,87 @@ func environmentMCPFixture() agent.EnvironmentMCP { }} } -func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { +// stdioBinding and httpBinding are MCP bindings as the agent host hands them +// to the view: a stdio server under its alias and an HTTP server at the +// Session gateway's endpoint. +func stdioBinding() agent.MCPBinding { + return agent.MCPBinding{ServerLabel: "proof.server", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "stdio", + Stdio: &agent.EnvironmentMCP{Server: agentplugin.MCPServer{Name: "proof.server", Type: "stdio", Command: agent.ViewAlias(0)}}} +} + +func httpBinding() agent.MCPBinding { + return agent.MCPBinding{ServerLabel: "remote", ConnectionOrigin: "environment", CredentialAuthority: "none", Transport: "http", ServerURL: "http://127.0.0.1:4102/mcp/remote"} +} + +// sessionParams returns the MCP servers and working directory that the ACP +// Session of the CLI recording into record received. +func sessionParams(t *testing.T, record string) (string, []map[string]any) { + t.Helper() + raw, err := os.ReadFile(record + ".session") + if err != nil { + t.Fatal(err) + } + var params struct { + SessionID string `json:"sessionId"` + Cwd string `json:"cwd"` + MCP []map[string]any `json:"mcpServers"` + } + if err := json.Unmarshal(raw, ¶ms); err != nil { + t.Fatal(err) + } + return params.Cwd, params.MCP +} + +func TestEnvironmentMCPRunsAliasInWorkspaceForNewAndLoadedSessions(t *testing.T) { for _, resume := range []bool{false, true} { t.Run(map[bool]string{false: "new", true: "load"}[resume], func(t *testing.T) { - c, req, record := workspaceFixture(t) - req.MCP = []agent.EnvironmentMCP{environmentMCPFixture()} + req := workspaceRequest(t) if resume { req.AgentSessionID = "native-1" } - t.Setenv("USER_SELECTED", "must-not-resolve-from-daemon") - t.Setenv("MODEL_SECRET", "must-not-forward") - resource, err := NewExecutorFactory(&c)(t.Context(), req) - if err != nil { + record := filepath.Join(t.TempDir(), "calls") + if _, err := hostExecutor(t, t.Context(), helperInstall(t, "prepared", record), req, hostSession(t, stdioBinding())); err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.Close(context.Background()) }) - raw, err := os.ReadFile(record + ".session") - if err != nil { - t.Fatal(err) - } - var params struct { - SessionID string `json:"sessionId"` - Cwd string `json:"cwd"` - MCP []struct { - Name, Command string - Args []string - Env []map[string]string - } `json:"mcpServers"` - } - if json.Unmarshal(raw, ¶ms) != nil || len(params.MCP) != 2 || params.MCP[0].Name != "oac_workspace" { - t.Fatal("environment MCP displaced workspace tools") - } - cwd, err := os.ReadFile(record + ".cwd") + cwd, servers := sessionParams(t, record) + process, err := os.ReadFile(record + ".cwd") workspace, pathErr := filepath.EvalSymlinks(req.WorkspaceRoot) - if err != nil || pathErr != nil || string(cwd) != workspace || params.Cwd != req.WorkspaceRoot { - t.Fatalf("native process and ACP Session must use the declared workspace: process=%q ACP=%q", cwd, params.Cwd) + if err != nil || pathErr != nil || string(process) != workspace || cwd != req.WorkspaceRoot { + t.Fatalf("native process and ACP Session must use the declared workspace: process=%q ACP=%q", process, cwd) + } + if len(servers) != 2 || servers[0]["name"] != "oac_workspace" { + t.Fatalf("environment MCP displaced workspace tools: %v", servers) } - server := params.MCP[1] - executable, _ := os.Executable() - if server.Name != "proof.server" || server.Command != executable || server.Env == nil || len(server.Env) != 0 || - !reflect.DeepEqual(server.Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/fixture", "proof.server"}) { - t.Fatal("ACP declaration bypassed the shared Runtime launcher") + if server := servers[1]; server["name"] != "proof.server" || server["command"] != agent.ViewAlias(0) || !reflect.DeepEqual(server["args"], []any{}) || !reflect.DeepEqual(server["env"], []any{}) { + t.Fatalf("stdio MCP = %v", server) } - if (params.SessionID != "") != resume || strings.Contains(string(raw), "must-not") || strings.Contains(string(raw), "private-argument") { - t.Fatal("native attachment changed identity or exposed private inputs") + calls, _ := os.ReadFile(record) + if strings.Contains(string(calls), "session/load") != resume { + t.Fatalf("native attachment changed identity: %s", calls) } }) } } -func TestEnvironmentMCPRejectsUnqualifiedAuthorityBeforePreparation(t *testing.T) { - for _, name := range []string{"http-headers", "http-bearer-insecure", "http-bearer-missing", "restricted", "disabled", "duplicate"} { +func TestEnvironmentHTTPMCPRejectsCredentialsBeforePreparation(t *testing.T) { + for _, name := range []string{"headers", "bearer"} { t.Run(name, func(t *testing.T) { - c, req, _ := workspaceFixture(t) - req.MCP = []agent.EnvironmentMCP{environmentMCPFixture()} - switch name { - case "http-headers", "http-bearer-insecure", "http-bearer-missing": - req.MCP[0].Server = agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"} - if name == "http-headers" { - req.MCP[0].Server.HTTPHeaders = map[string]string{"X-Private": "secret"} - } - if name == "http-bearer-missing" { - req.MCP[0].Server.BearerTokenEnvVar = "SELECTED_TOKEN" - } - if name == "http-bearer-insecure" { - req.MCP[0].Server.URL = "http://example.invalid/mcp" - token := "confidential-http-token" - req.MCP[0].BearerToken = &token - } - case "restricted", "disabled": - c.Network = name - case "duplicate": - req.MCP = append(req.MCP, environmentMCPFixture()) + binding := httpBinding() + if name == "headers" { + binding.HTTPHeaders = map[string]string{"X-Private": "secret"} + } else { + token := "confidential-http-token" + binding.BearerToken = &token } - if _, err := prepareWorkspaceOptions(c, req); err == nil || strings.Contains(err.Error(), "confidential-http-token") { - t.Fatal("unqualified declaration accepted or credential exposed") + if _, err := fakeInstall("node").prepare(workspaceRequest(t), hostSession(t, binding)); err == nil || strings.Contains(err.Error(), "confidential-http-token") { + t.Fatal("credential accepted or exposed") } }) } } func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { - c, req, _ := workspaceFixture(t) - req.MCP = []agent.EnvironmentMCP{environmentMCPFixture()} - script, err := os.ReadFile(c.Binary) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(c.Binary, []byte(strings.Replace(string(script), "HELPER=prepared", "HELPER=prepared-mcp-cancel", 1)), 0700); err != nil { - t.Fatal(err) - } - resource, err := NewExecutorFactory(&c)(t.Context(), req) + resource, err := hostExecutor(t, t.Context(), helperInstall(t, "prepared-mcp-cancel", ""), workspaceRequest(t), hostSession(t, stdioBinding())) if err != nil { t.Fatal(err) } @@ -123,7 +114,6 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.Close(context.Background()) }) select { case event := <-out: var call proto.ToolCallPayload @@ -176,86 +166,25 @@ func mcpRegistryEntry(server, segment, tool, toolSegment string) map[string]any } func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { - for _, authenticated := range []bool{false, true} { - c, req, record := workspaceFixture(t) - item := agent.EnvironmentMCP{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}} - const token = "private-mcp-canary" - if authenticated { - value := token - item.BearerToken = &value - } - req.MCP = []agent.EnvironmentMCP{item} - resource, err := NewExecutorFactory(&c)(t.Context(), req) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = resource.Close(context.Background()) }) - raw, err := os.ReadFile(record + ".session") - if err != nil { - t.Fatal(err) - } - var params struct { - MCP []struct { - Name, Type, URL string - Headers []map[string]string - } `json:"mcpServers"` - } - if json.Unmarshal(raw, ¶ms) != nil || len(params.MCP) != 2 { - t.Fatal("HTTP MCP displaced workspace tools") - } - server := params.MCP[1] - if server.Name != "remote" || server.Type != "http" || server.URL != item.Server.URL || server.Headers == nil { - t.Fatal("invalid native HTTP projection") - } - if authenticated { - if !reflect.DeepEqual(server.Headers, []map[string]string{{"name": "Authorization", "value": "Bearer " + token}}) { - t.Fatal("credential missing from ACP transport") - } - } else if len(server.Headers) != 0 { - t.Fatal("anonymous MCP inherited credentials") - } - dataDir := resource.(*executor).opts.DataDir - for _, name := range []string{"config.yaml", "mcp.json", "workspace-profile.json"} { - body, err := os.ReadFile(filepath.Join(dataDir, name)) - if err != nil && !os.IsNotExist(err) { - t.Fatal(err) - } - if strings.Contains(string(body), token) { - t.Fatal("MCP credential persisted in native configuration") - } - } - } -} - -func TestPublicEnvironmentHTTPMCPKeepsCredentialTransient(t *testing.T) { - c, req, _ := workspaceFixture(t) - token := "selected-public-vault-canary" - req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", BearerToken: &token}} - opts, err := prepareWorkspaceOptions(c, req) + record := filepath.Join(t.TempDir(), "calls") + e, err := hostExecutor(t, t.Context(), helperInstall(t, "prepared", record), workspaceRequest(t), hostSession(t, httpBinding())) if err != nil { t.Fatal(err) } - raw, err := json.Marshal(opts.MCP) - if err != nil || !strings.Contains(string(raw), "Bearer "+token) { - t.Fatal("selected token not supplied to native ACP") + _, servers := sessionParams(t, record) + if len(servers) != 2 || servers[0]["name"] != "oac_workspace" { + t.Fatalf("HTTP MCP displaced workspace tools: %v", servers) } - err = filepath.WalkDir(opts.DataDir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - value, err := os.ReadFile(path) - if err != nil { - return err + if server := servers[1]; server["name"] != "remote" || server["type"] != "http" || server["url"] != httpBinding().ServerURL || !reflect.DeepEqual(server["headers"], []any{}) { + t.Fatalf("invalid native HTTP projection: %v", server) + } + for _, name := range []string{"config.yaml", "mcp.json", "workspace-profile.json"} { + body, err := os.ReadFile(filepath.Join(e.opts.DataDir, name)) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) } - if strings.Contains(string(value), token) { - t.Fatal("public credential persisted in native state") + if strings.Contains(string(body), httpBinding().ServerURL) { + t.Fatalf("%s persists the Session's HTTP MCP", name) } - return nil - }) - if err != nil { - t.Fatal(err) } } diff --git a/apps/daemon/internal/agent/mcode/execution.go b/apps/daemon/internal/agent/mcode/execution.go index e50440256..6f308eff1 100644 --- a/apps/daemon/internal/agent/mcode/execution.go +++ b/apps/daemon/internal/agent/mcode/execution.go @@ -2,7 +2,6 @@ package mcode import ( "fmt" - "os" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -26,9 +25,6 @@ func configureTextExecution(config map[string]any) { config["beta"] = map[string]bool{"browserUseTooling": false, "mcodeTools": false, "threadGoal": false} } -// Harness children use the daemon user's ordinary environment. -func executionEnvironment() []string { return os.Environ() } - // ACP commands are only recognized for a single text block. A second, empty // block keeps public input as user text. func promptContent(text string) []map[string]string { diff --git a/apps/daemon/internal/agent/mcode/execution_test.go b/apps/daemon/internal/agent/mcode/execution_test.go index 76fa40998..302dd9593 100644 --- a/apps/daemon/internal/agent/mcode/execution_test.go +++ b/apps/daemon/internal/agent/mcode/execution_test.go @@ -4,24 +4,16 @@ import ( "encoding/json" "os" "path/filepath" - "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestExecutionOptionsInheritUserEnvironment(t *testing.T) { - r := testRequest(t) - t.Setenv("OAC_TEST_SECRET_CANARY", "secret") - t.Setenv("NODE_OPTIONS", "--import=untrusted") - opts, err := prepareOptions(prepared(t, r)) +func TestExecutionDisablesNativeFeatures(t *testing.T) { + opts, err := fakeInstall("node").prepare(prepared(t, testRequest(t)), hostSession(t)) if err != nil { t.Fatal(err) } - if !strings.Contains(strings.Join(opts.Env, "\n"), "OAC_TEST_SECRET_CANARY=secret") { - t.Fatal("user environment lost") - } - data, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) if err != nil { t.Fatal(err) @@ -46,7 +38,7 @@ func TestExecutionRejectsUnqualifiedAuthority(t *testing.T) { } { r := testRequest(t) change(&r) - if _, err := prepareOptions(prepared(t, r)); err == nil { + if _, err := fakeInstall("node").prepare(prepared(t, r), hostSession(t)); err == nil { t.Fatal("unsupported execution accepted") } } diff --git a/apps/daemon/internal/agent/mcode/executor.go b/apps/daemon/internal/agent/mcode/executor.go index 104d38d1f..83d57e7ef 100644 --- a/apps/daemon/internal/agent/mcode/executor.go +++ b/apps/daemon/internal/agent/mcode/executor.go @@ -24,28 +24,6 @@ type executor struct { starting, closed, invalid bool } -// NewExecutorFactory fixes the deployment workspace once; nil selects none. -func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { - var frozen *WorkspaceConfig - if config != nil { - value := *config - value.AllowedDomains = append([]string(nil), config.AllowedDomains...) - frozen = &value - } - return func(ctx context.Context, req agent.PrepareRequest) (agent.Executor, error) { - binary := defaultBinary() - if frozen != nil { - binary = frozen.Binary - } - return startExecutor(ctx, req.PromptRequestPayload, binary, func() (launchOptions, error) { - if frozen == nil { - return prepareOptions(req) - } - return prepareWorkspaceOptions(*frozen, req) - }) - } -} - // startExecutor prepares the native owner for req and starts binary. func startExecutor(ctx context.Context, req proto.PromptRequestPayload, binary string, prepare func() (launchOptions, error)) (agent.Executor, error) { if ctx == nil { diff --git a/apps/daemon/internal/agent/mcode/executor_native_test.go b/apps/daemon/internal/agent/mcode/executor_native_test.go index c950c3688..e2a1b8ad6 100644 --- a/apps/daemon/internal/agent/mcode/executor_native_test.go +++ b/apps/daemon/internal/agent/mcode/executor_native_test.go @@ -15,13 +15,14 @@ import ( // This opt-in test makes real model calls and uses an isolated native home. // Provider options are read from a private file and never included in failures. func TestNativeMCodeExecutorReuse(t *testing.T) { - binary, options := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") - if binary == "" || options == "" { - t.Skip("native executable and private provider options required") + options := os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") + if options == "" { + t.Skip("private provider options required") } + install, session := installedView(t), hostSession(t) ctx, cancel := context.WithTimeout(t.Context(), 8*time.Minute) defer cancel() - if version, err := CheckCLIAvailable(ctx, binary); err != nil || version != SupportedVersion { + if version, err := CheckCLIAvailable(ctx, install.cli); err != nil || version != SupportedVersion { t.Fatal("pinned native version verification failed") } raw, err := os.ReadFile(options) @@ -32,7 +33,7 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { if json.Unmarshal(raw, &req) != nil { t.Fatal("invalid private provider options") } - value, err := NewExecutorFactory(nil)(ctx, prepared(t, req)) + value, err := install.executor(ctx, prepared(t, req), session) if err != nil { t.Fatal("native Executor preparation failed") } @@ -155,7 +156,7 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { } cleanupStop() req.AgentSessionID = nativeID - recovered, recoverErr := NewExecutorFactory(nil)(ctx, prepared(t, req)) + recovered, recoverErr := install.executor(ctx, prepared(t, req), session) if recoverErr != nil || recovered == nil { t.Fatal("exact native history recovery failed") } diff --git a/apps/daemon/internal/agent/mcode/executor_test.go b/apps/daemon/internal/agent/mcode/executor_test.go index 160ae43b6..a6ed1180c 100644 --- a/apps/daemon/internal/agent/mcode/executor_test.go +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -10,60 +10,35 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func workspaceFixture(t *testing.T) (WorkspaceConfig, agent.PrepareRequest, string) { +// workspaceRequest is a request to run in a workspace on this host. +func workspaceRequest(t *testing.T) agent.PrepareRequest { t.Helper() r := testRequest(t) r.DisableExecutionEnvironment = false r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment"} req := prepared(t, r) req.WorkspaceRoot = t.TempDir() - record := filepath.Join(t.TempDir(), "calls") - exe, err := os.Executable() - if err != nil { - t.Fatal(err) - } - binary := filepath.Join(t.TempDir(), "native") - quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } - script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=prepared\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { - t.Fatal(err) - } - return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: req.WorkspaceRoot, Network: "enabled", Scratch: t.TempDir()}, req, record + return req } +// executorFixture prepares an Executor of the scenario's native CLI, in a +// workspace or with environment none. It returns the Executor and the file +// in which the CLI records the requests it receives. func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, string) { t.Helper() - config, req, record := workspaceFixture(t) - script, err := os.ReadFile(config.Binary) - if err != nil { - t.Fatal(err) - } - if err = os.WriteFile(config.Binary, []byte(strings.Replace(string(script), "HELPER=prepared", "HELPER="+scenario, 1)), 0700); err != nil { - t.Fatal(err) - } - var factory agent.ExecutorFactory + req := prepared(t, testRequest(t)) if workspace { - factory = NewExecutorFactory(&config) - } else { - req = prepared(t, testRequest(t)) - t.Setenv("OAC_RUNTIME_MCODE_BIN", config.Binary) - factory = NewExecutorFactory(nil) + req = workspaceRequest(t) } - value, err := factory(t.Context(), req) + record := filepath.Join(t.TempDir(), "calls") + e, err := hostExecutor(t, t.Context(), helperInstall(t, scenario, record), req, hostSession(t)) if err != nil { t.Fatal(err) } - e := value.(*executor) - t.Cleanup(func() { - cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := e.Close(cleanup); err != nil { - t.Error(err) - } - }) return e, record } @@ -142,16 +117,15 @@ func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing. e.req.DisableSubagents = disabled if !disabled { // A terminal native history record still cannot prove Bash cleanup. - dir := t.TempDir() - node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") + reader := filepath.Join(t.TempDir(), "reader") body := "#!/bin/sh\nprintf '%s\\n' '{\"version\":1,\"complete\":true,\"rootSessionId\":\"native-1\",\"sessions\":[{\"id\":\"native-1\",\"turns\":[{\"id\":\"root-turn\",\"status\":\"aborted\"}]}]}'\n" - for name, data := range map[string]string{node: body, bridge: "", filepath.Join(dir, "subagent-snapshot.mjs"): ""} { - if err := os.WriteFile(name, []byte(data), 0700); err != nil { - t.Fatal(err) - } + if err := os.WriteFile(reader, []byte(body), 0700); err != nil { + t.Fatal(err) + } + e.opts.spawn = func(options clirunner.StartOptions) (*clirunner.Process, error) { + options.Binary = reader + return clirunner.Start(options) } - t.Setenv("OAC_RUNTIME_MCODE_NODE", node) - t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) } out := make(chan proto.Envelope, 32) second, err := e.StartTurn(t.Context(), "second", proto.TextInput("wait"), out) @@ -288,12 +262,12 @@ func TestExecutorCloseRetainsOwnerAfterDeadline(t *testing.T) { } } -func TestExecutorFactoryPreparationFailureHasNoTypedNilOwner(t *testing.T) { - config, req, _ := workspaceFixture(t) - if err := os.WriteFile(config.Binary, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { +func TestExecutorPreparationFailureHasNoTypedNilOwner(t *testing.T) { + cli := filepath.Join(t.TempDir(), "native") + if err := os.WriteFile(cli, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { t.Fatal(err) } - value, err := NewExecutorFactory(&config)(t.Context(), req) + value, err := fakeInstall(cli).executor(t.Context(), workspaceRequest(t), hostSession(t)) if err == nil || value != nil { t.Fatalf("settled preparation failure returned owner: nil=%t error=%v", value == nil, err) } diff --git a/apps/daemon/internal/agent/mcode/mcp_observations_test.go b/apps/daemon/internal/agent/mcode/mcp_observations_test.go index cad5df984..17da013ac 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations_test.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations_test.go @@ -15,7 +15,8 @@ import ( func mcpObservationSession(t *testing.T) (*Session, chan proto.Envelope) { t.Helper() out := make(chan proto.Envelope, 16) - s := &Session{ctx: context.Background(), outputContext: context.Background(), opts: launchOptions{DataDir: t.TempDir()}, + dir := t.TempDir() + s := &Session{ctx: context.Background(), outputContext: context.Background(), opts: launchOptions{DataDir: dir, home: dir}, req: proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}}, runID: "run", out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}, completedMessages: map[string]bool{}, active: true, sessionID: "native-session"} s.opts.bindings, _ = agent.ResolveMCPBindings(agent.PrepareRequest{PromptRequestPayload: s.req, MCP: []agent.EnvironmentMCP{environmentMCPFixture()}}) diff --git a/apps/daemon/internal/agent/mcode/model_provider_test.go b/apps/daemon/internal/agent/mcode/model_provider_test.go index f14b6c55c..5620f53a8 100644 --- a/apps/daemon/internal/agent/mcode/model_provider_test.go +++ b/apps/daemon/internal/agent/mcode/model_provider_test.go @@ -18,7 +18,7 @@ func TestOptionsModelProviderProtocols(t *testing.T) { req := testRequest(t) req.ModelProvider.Protocol = modelprovider.Protocol(tc.protocol) req.Model = "chosen-model" - opts, err := prepareOptions(prepared(t, req)) + opts, err := fakeInstall("node").prepare(prepared(t, req), hostSession(t)) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/mcode/native_history_test.go b/apps/daemon/internal/agent/mcode/native_history_test.go index e438e2ff7..8d7d20319 100644 --- a/apps/daemon/internal/agent/mcode/native_history_test.go +++ b/apps/daemon/internal/agent/mcode/native_history_test.go @@ -13,10 +13,11 @@ import ( // Neither that history nor a missing ID may prepare an Executor, so no Turn can // run against it or silently replace it with a new session. func TestNativeMCodeHistoryIsolation(t *testing.T) { - binary, options, foreign := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS"), os.Getenv("OAC_TEST_MCODE_FOREIGN_NATIVE_ID") - if binary == "" || options == "" || foreign == "" { - t.Skip("native executable, private provider options and foreign history ID required") + options, foreign := os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS"), os.Getenv("OAC_TEST_MCODE_FOREIGN_NATIVE_ID") + if options == "" || foreign == "" { + t.Skip("private provider options and foreign history ID required") } + install := installedView(t) raw, err := os.ReadFile(options) if err != nil { t.Fatal(err) @@ -30,7 +31,7 @@ func TestNativeMCodeHistoryIsolation(t *testing.T) { req.AgentSessionID = id ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) defer cancel() - e, err := prepareExecutor(t, ctx, req) + e, err := prepareExecutor(t, ctx, install, req) if e != nil || err == nil || !strings.Contains(err.Error(), "session/load:") || strings.Contains(err.Error(), "deadline exceeded") { t.Fatal("native history was not explicitly rejected") } diff --git a/apps/daemon/internal/agent/mcode/native_test.go b/apps/daemon/internal/agent/mcode/native_test.go index ba69e75b7..92412f9dd 100644 --- a/apps/daemon/internal/agent/mcode/native_test.go +++ b/apps/daemon/internal/agent/mcode/native_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "sync" "testing" @@ -16,14 +17,24 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) -// Opt in with the installed native CLI; the default test gate uses protocol fixtures. -func TestNativeMCodeACP(t *testing.T) { - binary := os.Getenv("OAC_TEST_MCODE_INTEGRATION_BIN") - if binary == "" { - t.Skip("set OAC_TEST_MCODE_INTEGRATION_BIN to run native ACP smoke test") +// installedView is the installed MiniMax Code that the installation +// environment names, as a view install on this host. Without one the test is +// skipped; the default test gate uses protocol fixtures. +func installedView(t *testing.T) viewInstall { + t.Helper() + if os.Getenv("OAC_RUNTIME_MCODE_BIN") == "" || os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE") == "" { + t.Skip("set OAC_RUNTIME_MCODE_BIN, OAC_RUNTIME_MCODE_NODE and OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE to run the installed native CLI") + } + programs, err := findPrograms() + if err != nil { + t.Fatal(err) } + return viewInstall{node: programs.node, cli: programs.binary, bridge: programs.bridge, assets: filepath.Join(filepath.Dir(programs.binary), "assets")} +} + +func TestNativeMCodeACP(t *testing.T) { + install, session := installedView(t), hostSession(t) req := testRequest(t) - t.Setenv("OAC_RUNTIME_MCODE_BIN", binary) var mu sync.Mutex var requests []string model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -46,7 +57,11 @@ func TestNativeMCodeACP(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) t.Cleanup(cancel) out := make(chan proto.Envelope, 64) - if _, err := startTurn(t, ctx, req, runID, input, out); err != nil { + e, err := hostExecutor(t, ctx, install, prepared(t, req), session) + if err != nil { + t.Fatal(err) + } + if _, err := e.StartTurn(ctx, runID, input, out); err != nil { t.Fatal(err) } var done proto.DonePayload diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index ad2f72ca2..2bec53b28 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -8,11 +8,9 @@ import ( "os" "path/filepath" "strconv" - "strings" "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/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -23,63 +21,18 @@ type launchOptions struct { // bindings are the effective MCP bindings rendered into MCP; native MCP // tool calls are observed against them. bindings []agent.MCPBinding - // start runs the native process; nil selects clirunner.Start. script is - // the CLI entry when the binary is node rather than the CLI itself. + // start runs node with script, the CLI entry, as the native process. start func(clirunner.StartOptions) (*clirunner.Process, error) script string - // spawn runs the Subagent history reader beside the native process in an - // agent-host view, with reader's node, script and data directory as the - // view presents them. nil runs the installed reader on the host over - // DataDir. + // spawn runs the Subagent history reader beside the native process, with + // reader's node, script and data directory as the view presents them. spawn func(clirunner.StartOptions) (*clirunner.Process, error) reader clirunner.StartOptions - // home is an agent-host view's Session home on the host. It contains - // DataDir and belongs to the Session user, so the daemon reads DataDir - // only within it. + // home is the Session home on the host. It contains DataDir and belongs + // to the Session user, so the daemon reads DataDir only within it. home string } -func prepareOptions(req agent.PrepareRequest) (launchOptions, error) { - return prepareOptionsWithTools(req, nil) -} - -// prepareOptionsWithTools prepares the Session's native data directory. With -// tools, the workspace bridge presents the Environment's workspace and Skills. -func prepareOptionsWithTools(req agent.PrepareRequest, tools *workspaceTools) (launchOptions, error) { - var result launchOptions - err := validateExecutionRequest(req.PromptRequestPayload) - if err != nil { - return result, err - } - if !req.DisableSubagents { - if _, _, err := subagentReader(); err != nil { - return result, err - } - } - if result.DataDir, err = dataDirectory(req.StateKey); err != nil { - return result, err - } - result.Dir = filepath.Join(result.DataDir, "workspace") - if err := os.MkdirAll(result.DataDir, 0o700); err != nil { - return result, err - } - if err := os.MkdirAll(result.Dir, 0o700); err != nil { - return result, err - } - data, err := os.OpenRoot(result.DataDir) - if err != nil { - return result, err - } - defer data.Close() - if err := writeNativeConfig(req, data, result.DataDir, tools); err != nil { - return result, err - } - result.Model = req.Prepared.Model - result.Env = append(executionEnvironment(), nativeEnvironment(req.PromptRequestPayload, result.DataDir)...) - result.MCP = []map[string]any{} - return result, nil -} - // writeNativeConfig writes the instructions and native configuration into the // data directory, which the native process sees at dataDir. With tools, the // workspace bridge replaces native permissions and sandbox, and Subagents use @@ -175,55 +128,16 @@ func nativeEnvironment(req proto.PromptRequestPayload, dataDir string) []string } // readData reads a file the native process wrote in its data directory, -// without leaving the Session home in a view. +// without leaving the Session home. func (o launchOptions) readData(name string) ([]byte, error) { - trusted := o.home - if trusted == "" { - trusted = o.DataDir - } - rel, err := filepath.Rel(trusted, filepath.Join(o.DataDir, name)) + rel, err := filepath.Rel(o.home, filepath.Join(o.DataDir, name)) if err != nil { return nil, err } - root, err := os.OpenRoot(trusted) + root, err := os.OpenRoot(o.home) if err != nil { return nil, err } defer root.Close() return root.ReadFile(rel) } - -// dataDirectory returns the native data directory of the Session's state -// key. It never derives runtime state from the subprocess cwd. -func dataDirectory(stateKey string) (string, error) { - root, err := paths.Root() - if err != nil { - return "", fmt.Errorf("mcode: resolve data directory: %w", err) - } - parts := []string{root, "runtime", "mcode", "state"} - for _, part := range strings.Split(stateKey, "/") { - if safe := safePathPart(part); safe != "" { - parts = append(parts, safe) - } - } - if len(parts) == 4 { - return "", fmt.Errorf("mcode: invalid agent state key %q", stateKey) - } - return filepath.Join(parts...), nil -} - -func safePathPart(value string) string { - var b strings.Builder - for _, r := range strings.TrimSpace(value) { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - value = b.String() - if value == "." || value == ".." { - return "" - } - return value -} diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go index a6396ac82..f758e5a70 100644 --- a/apps/daemon/internal/agent/mcode/options_test.go +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -12,26 +12,24 @@ import ( func TestOptionsRefreshManagedState(t *testing.T) { t.Setenv("MINIMAX_DATA_DIR", "/wrong") + install, session := fakeInstall("node"), hostSession(t) req := testRequest(t) - opts, err := prepareOptions(prepared(t, req)) + opts, err := install.prepare(prepared(t, req), session) if err != nil { t.Fatal(err) } - if !strings.HasPrefix(opts.Dir, os.Getenv("OAC_RUNTIME_HOME")+string(os.PathSeparator)) { - t.Fatalf("workdir escaped managed state: %s", opts.Dir) - } - dataDir := "" + var dataDirs []string for _, entry := range opts.Env { if value, ok := strings.CutPrefix(entry, "MINIMAX_DATA_DIR="); ok { - dataDir = value + dataDirs = append(dataDirs, value) } } - if dataDir != opts.DataDir { + if len(dataDirs) != 1 || dataDirs[0] != opts.DataDir { t.Fatal("native data directory is not the adapter's") } req.SystemPrompt = "" req.AgentSessionID = "native-1" - refreshed, err := prepareOptions(prepared(t, req)) + refreshed, err := install.prepare(prepared(t, req), session) if err != nil { t.Fatal(err) } @@ -73,23 +71,9 @@ func TestOptionsRejectDroppedContext(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req := testRequest(t) tt.edit(&req) - if _, err := prepareOptions(prepared(t, req)); err == nil { + if _, err := fakeInstall("node").prepare(prepared(t, req), hostSession(t)); err == nil { t.Fatal("expected validation failure") } }) } } - -func TestDataDirectoryRequiresAgentState(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) - for _, key := range []string{"", " ", "../.."} { - if _, err := dataDirectory(key); err == nil { - t.Fatalf("state key %q accepted", key) - } - } - got, err := dataDirectory("../session-1/a b") - if want := filepath.Join(home, "runtime", "mcode", "state", "session-1", "a_b"); err != nil || got != want { - t.Fatalf("data directory = %q, %v; want %q", got, err, want) - } -} diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index ea574ef16..26d5bafb4 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -54,14 +54,7 @@ type Session struct { } func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string) (*Session, error) { - start, args := opts.start, []string{"acp"} - if start == nil { - start = clirunner.Start - } - if opts.script != "" { - args = []string{opts.script, "acp"} - } - process, err := start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Dir: opts.Dir, Env: opts.Env, NeedStdin: true}) + process, err := opts.start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{opts.script, "acp"}, Dir: opts.Dir, Env: opts.Env, NeedStdin: true}) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index fcd746ee5..37a3c5c21 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -12,60 +12,72 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) func testRequest(t *testing.T) proto.PromptRequestPayload { t.Helper() - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) return proto.PromptRequestPayload{Model: "fixture", SystemPrompt: "Current instructions", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "https://provider.example", APIKey: "fixture-key", ContextWindow: 64000, MaxOutputTokens: 4096}, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}} } -// helperRequest selects a protocol fixture scenario as the native CLI. -func helperRequest(t *testing.T, scenario string, resume bool) proto.PromptRequestPayload { +func prepared(t testing.TB, req proto.PromptRequestPayload) agent.PrepareRequest { t.Helper() - req := testRequest(t) - if resume { - req.AgentSessionID = "native-1" + configuration, err := Declaration.Configuration.Prepare(req) + if err != nil { + t.Fatal(err) } - t.Setenv("OAC_TEST_MCODE_HELPER", scenario) + return agent.PrepareRequest{PromptRequestPayload: req, Prepared: configuration, StateKey: "session-state"} +} + +// fakeInstall is a view install whose node is cli, a fake native CLI that +// ignores the CLI entry it is given. +func fakeInstall(cli string) viewInstall { + return viewInstall{node: cli, cli: "/opt/mcode-harness/native/cli.js", bridge: "/opt/mcode-harness/bridge.mjs", assets: "/opt/mcode-harness/native/assets"} +} + +// helperInstall is the fake install whose CLI runs scenario of +// TestMCodeProcess. With record, the CLI records each request method there. +func helperInstall(t *testing.T, scenario, record string) viewInstall { + t.Helper() exe, err := os.Executable() if err != nil { t.Fatal(err) } - binary := filepath.Join(t.TempDir(), "mcode") - script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=" + scenario + "\nexec '" + strings.ReplaceAll(exe, "'", "'\\''") + "' -test.run=^TestMCodeProcess$ -- \"$@\"\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } + cli := filepath.Join(t.TempDir(), "mcode") + script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=" + scenario + "\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" + if err := os.WriteFile(cli, []byte(script), 0700); err != nil { t.Fatal(err) } - t.Setenv("OAC_RUNTIME_MCODE_BIN", binary) - return req + return fakeInstall(cli) } -// prepared is req as the registry hands it to the factory, with the state key -// of one Session. -func prepared(t testing.TB, req proto.PromptRequestPayload) agent.PrepareRequest { +// hostSession is a Session whose home has the same path on this host and in +// the view, so the view Executor runs on this host as it runs in a view. +func hostSession(t *testing.T, mcp ...agent.MCPBinding) agent.ViewSession { t.Helper() - configuration, err := Declaration.Configuration.Prepare(req) - if err != nil { + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, agent.ViewWorkName), 0o700); err != nil { t.Fatal(err) } - return agent.PrepareRequest{PromptRequestPayload: req, Prepared: configuration, StateKey: "session-state"} + return agent.ViewSession{Home: agent.ViewDir{Host: home, View: home}, MCP: mcp, Launch: clirunner.Start, Spawn: clirunner.Start} } -// prepareExecutor prepares req; cleanup closes the Executor and reaps its CLI. -func prepareExecutor(t *testing.T, ctx context.Context, req proto.PromptRequestPayload) (*executor, error) { +// hostExecutor prepares req through install's view Executor in session; +// cleanup closes the Executor and reaps its CLI. +func hostExecutor(t *testing.T, ctx context.Context, install viewInstall, req agent.PrepareRequest, session agent.ViewSession) (*executor, error) { t.Helper() - value, err := NewExecutorFactory(nil)(ctx, prepared(t, req)) + value, err := install.executor(ctx, req, session) if value == nil { return nil, err } e := value.(*executor) t.Cleanup(func() { - cleanup, cancel := context.WithTimeout(context.Background(), 3*time.Second) + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := e.Close(cleanup); err != nil { t.Error("CLI was not reaped:", err) @@ -74,10 +86,16 @@ func prepareExecutor(t *testing.T, ctx context.Context, req proto.PromptRequestP return e, err } +// prepareExecutor prepares req through install with environment none. +func prepareExecutor(t *testing.T, ctx context.Context, install viewInstall, req proto.PromptRequestPayload) (*executor, error) { + t.Helper() + return hostExecutor(t, ctx, install, prepared(t, req), hostSession(t)) +} + // startTurn prepares an Executor for req and starts input as its Turn run. -func startTurn(t *testing.T, ctx context.Context, req proto.PromptRequestPayload, run string, input proto.MessageInput, out chan<- proto.Envelope) (*Session, error) { +func startTurn(t *testing.T, ctx context.Context, install viewInstall, req proto.PromptRequestPayload, run string, input proto.MessageInput, out chan<- proto.Envelope) (*Session, error) { t.Helper() - e, err := prepareExecutor(t, ctx, req) + e, err := prepareExecutor(t, ctx, install, req) if err != nil { return nil, err } @@ -88,13 +106,18 @@ func startTurn(t *testing.T, ctx context.Context, req proto.PromptRequestPayload return turn.(*Session), nil } +// helperSession starts a Turn of the scenario's native CLI, resuming its +// native session when resume is set. func helperSession(t *testing.T, scenario string, resume bool) (*Session, <-chan proto.Envelope) { t.Helper() - req := helperRequest(t, scenario, resume) + req := testRequest(t) + if resume { + req.AgentSessionID = "native-1" + } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) t.Cleanup(cancel) out := make(chan proto.Envelope, 32) - session, err := startTurn(t, ctx, req, "run-1", proto.TextInput("Hello"), out) + session, err := startTurn(t, ctx, helperInstall(t, scenario, ""), req, "run-1", proto.TextInput("Hello"), out) if err != nil { t.Fatal(err) } @@ -188,7 +211,7 @@ func TestSessionFailuresAreReported(t *testing.T) { t.Run(scenario, func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() - if e, err := prepareExecutor(t, ctx, helperRequest(t, scenario, false)); err == nil || e != nil { + if e, err := prepareExecutor(t, ctx, helperInstall(t, scenario, ""), testRequest(t)); err == nil || e != nil { t.Fatal("preparation failure was not reported", err) } }) @@ -208,12 +231,11 @@ func TestSessionFailuresAreReported(t *testing.T) { } func TestPreparationCancellationStopsWaitingCLI(t *testing.T) { - req := helperRequest(t, "hang", false) ctx, cancel := context.WithCancel(t.Context()) defer cancel() time.AfterFunc(100*time.Millisecond, cancel) started := time.Now() - if e, err := prepareExecutor(t, ctx, req); err == nil || e != nil { + if e, err := prepareExecutor(t, ctx, helperInstall(t, "hang", ""), testRequest(t)); err == nil || e != nil { t.Fatal("cancelled preparation retained the CLI", err) } if time.Since(started) > 3*time.Second { diff --git a/apps/daemon/internal/agent/mcode/subagents.go b/apps/daemon/internal/agent/mcode/subagents.go index 9905cbf5b..68bd6d0b2 100644 --- a/apps/daemon/internal/agent/mcode/subagents.go +++ b/apps/daemon/internal/agent/mcode/subagents.go @@ -5,12 +5,9 @@ import ( "encoding/json" "fmt" "io" - "os" - "path/filepath" "slices" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -53,32 +50,13 @@ type nativeSubagentTask struct { Metadata struct{ ChildSessionID, ParentSessionID, ParentTurnID, SubTurnID, ExecutionMode string } } -func subagentReader() (string, string, error) { - node, bridge := os.Getenv("OAC_RUNTIME_MCODE_NODE"), os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE") - reader := filepath.Join(filepath.Dir(bridge), "subagent-snapshot.mjs") - for _, path := range []string{node, bridge, reader} { - resolved, err := filepath.EvalSymlinks(path) - if err != nil || !filepath.IsAbs(path) || resolved != path { - return "", "", fmt.Errorf("mcode: protected Subagent reader is unavailable") - } - } - return node, reader, nil -} - func (s *Session) readSubagents(ctx context.Context) (nativeSubagentSnapshot, error) { var snapshot nativeSubagentSnapshot - reader, start := s.opts.reader, s.opts.spawn - if start == nil { - node, script, err := subagentReader() - if err != nil { - return snapshot, err - } - reader, start = clirunner.StartOptions{Binary: node, Args: []string{script, s.opts.DataDir}, Env: executionEnvironment()}, clirunner.Start - } + reader := s.opts.reader ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() reader.Parent, reader.Args = ctx, slices.Concat([]string{"--disable-warning=ExperimentalWarning"}, reader.Args, []string{s.sessionID}) - process, err := start(reader) + process, err := s.opts.spawn(reader) if err != nil { return snapshot, fmt.Errorf("mcode: child history reader is unavailable") } diff --git a/apps/daemon/internal/agent/mcode/subagents_test.go b/apps/daemon/internal/agent/mcode/subagents_test.go index 25a1773af..998be46dc 100644 --- a/apps/daemon/internal/agent/mcode/subagents_test.go +++ b/apps/daemon/internal/agent/mcode/subagents_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -72,18 +73,11 @@ func TestSubagentSnapshotRejectsMissingParentProvenance(t *testing.T) { // The ACP cancelled response may precede the root native Turn becoming terminal. func TestSubagentSettlementIncludesActiveRootTurn(t *testing.T) { dir := t.TempDir() - node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") - for name, data := range map[string]string{ - node: "#!/bin/sh\ncat \"$3/snapshot.json\"\n", - bridge: "", - filepath.Join(dir, "subagent-snapshot.mjs"): "", - } { - if err := os.WriteFile(name, []byte(data), 0700); err != nil { - t.Fatal(err) - } + node := filepath.Join(dir, "node") + if err := os.WriteFile(node, []byte("#!/bin/sh\ncat \"$3/snapshot.json\"\n"), 0700); err != nil { + t.Fatal(err) } - t.Setenv("OAC_RUNTIME_MCODE_NODE", node) - t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) + reader := clirunner.StartOptions{Binary: node, Args: []string{filepath.Join(dir, "subagent-snapshot.mjs"), dir}} for _, status := range []string{"accepted", "aborted"} { t.Run(status, func(t *testing.T) { raw := []byte(`{"version":1,"complete":true,"rootSessionId":"root","sessions":[{"id":"root","turns":[{"id":"current","status":"` + status + `"}]}]}`) @@ -92,7 +86,7 @@ func TestSubagentSettlementIncludesActiveRootTurn(t *testing.T) { } ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) defer cancel() - s := &Session{ctx: ctx, sessionID: "root", opts: launchOptions{DataDir: dir}, frames: make(chan rpcFrame)} + s := &Session{ctx: ctx, sessionID: "root", opts: launchOptions{DataDir: dir, spawn: clirunner.Start, reader: reader}, frames: make(chan rpcFrame)} err := s.settleSubagents() if (err != nil) != (status == "accepted") { t.Fatalf("root %s settlement error = %v", status, err) diff --git a/apps/daemon/internal/agent/mcode/tool_environment_test.go b/apps/daemon/internal/agent/mcode/tool_environment_test.go deleted file mode 100644 index f7a995ce6..000000000 --- a/apps/daemon/internal/agent/mcode/tool_environment_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package mcode - -import ( - "bytes" - "encoding/json" - "io/fs" - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/google/uuid" -) - -func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T) { - config, req, _ := workspaceFixture(t) - source := filepath.Join(t.TempDir(), "operator.json") - write := func(path, body string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(body), 0600); err != nil { - t.Fatal(err) - } - } - write(source, `{"MCP_TEST_TOKEN":"fixture-bearer-canary","TOOL_SECRET":"fixture-tool-canary"}`) - initialization := t.TempDir() - t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", initialization) - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", source) - plugin := t.TempDir() - write(filepath.Join(plugin, ".codex-plugin", "plugin.json"), `{"name":"remote","description":"Credential fixture","mcpServers":"./.mcp.json"}`) - write(filepath.Join(plugin, ".mcp.json"), `{"mcpServers":{"remote":{"type":"http","url":"https://example.invalid/mcp","bearer_token_env_var":"MCP_TEST_TOKEN"}}}`) - environment, session := uuid.NewString(), uuid.NewString() - t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", environment) - t.Setenv("OAC_RUNTIME_SESSION_ID", session) - t.Setenv("OAC_RUNTIME_WORKSPACE", config.Directory) - t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", t.TempDir()) - t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", "enabled") - t.Setenv("OAC_RUNTIME_ALLOWED_DOMAINS", "") - req.StateKey = "agents-api-" + session - req.LocalEnvironment.ID, req.LocalEnvironment.WorkspaceDirectory = environment, config.Directory - req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{plugin}} - for _, resume := range []bool{false, true} { - if resume { - req.AgentSessionID = "native-1" - write(source, `{"MCP_TEST_TOKEN":"changed","TOOL_SECRET":"changed"}`) - } - binding, err := localworkspace.Load() - if err != nil { - t.Fatal(err) - } - if err := binding.Configure(req.PromptRequestPayload); err != nil { - t.Fatal(err) - } - bound, err := binding.Prepare(t.Context(), req) - if err != nil { - t.Fatal(err) - } - opts, err := prepareWorkspaceOptions(config, bound) - if err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(opts.DataDir, "workspace-profile.json")) - if err != nil { - t.Fatal(err) - } - var profile struct { - ToolEnvFile string `json:"toolEnvFile"` - } - if err := json.Unmarshal(raw, &profile); err != nil { - t.Fatal(err) - } - if profile.ToolEnvFile != filepath.Join(initialization, "tool-env.json") { - t.Fatal("native profile did not retain the Runtime snapshot reference") - } - // Assert the actual env-selected HTTP credential, not a manually injected token. - headers, ok := opts.MCP[1]["headers"].([]map[string]string) - if !ok || len(headers) != 1 || headers[0]["name"] != "Authorization" || headers[0]["value"] != "Bearer fixture-bearer-canary" { - t.Fatal("frozen MCP credential was lost or replaced") - } - if err := filepath.WalkDir(opts.DataDir, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - return nil - } - body, err := os.ReadFile(path) - if err != nil { - return err - } - if bytes.Contains(body, []byte("fixture-bearer-canary")) || bytes.Contains(body, []byte("fixture-tool-canary")) { - t.Fatal("tool credential copied into native persisted state") - } - return nil - }); err != nil { - t.Fatal(err) - } - } - if err := os.Remove(filepath.Join(initialization, "tool-env.json")); err != nil { - t.Fatal(err) - } - binding, err := localworkspace.Load() - if err != nil { - t.Fatal(err) - } - if err := binding.Configure(req.PromptRequestPayload); err != nil { - t.Fatal(err) - } - if _, err := binding.Prepare(t.Context(), req); err == nil { - t.Fatal("missing frozen credential source was recreated or fell back to anonymous") - } -} - -func TestWorkspaceRejectsInvalidToolEnvironment(t *testing.T) { - config, req, _ := workspaceFixture(t) - file := filepath.Join(t.TempDir(), "tool-env.json") - if err := os.WriteFile(file, []byte(`[]`), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) - t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) - if _, err := prepareWorkspaceOptions(config, req); err == nil { - t.Fatal("invalid explicit tool configuration was ignored") - } -} diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index 35c13416a..858bd9acb 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -5,11 +5,13 @@ import ( "errors" "fmt" "os" + "os/exec" "path" "path/filepath" "slices" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/viewloader" ) @@ -70,6 +72,32 @@ func findView() (*agent.View, error) { return &view, nil } +// programs are the installed node, CLI entry and workspace bridge, as absolute +// host paths. +type programs struct{ node, binary, bridge string } + +func findPrograms() (programs, error) { + node := os.Getenv("OAC_RUNTIME_MCODE_NODE") + if node == "" { + node = "node" + } + node, err := exec.LookPath(node) + if err != nil { + return programs{}, err + } + if node, err = filepath.Abs(node); err != nil { + return programs{}, err + } + binary, err := exec.LookPath(binpath.MCode()) + if err != nil { + return programs{}, err + } + if binary, err = filepath.Abs(binary); err != nil { + return programs{}, err + } + return programs{node: node, binary: binary, bridge: os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE")}, nil +} + // newViewInstall presents node's directory and the harness directory holding // the bridge and the CLI as the closure, with loader for a dynamic node. node // is a resolved host path. @@ -158,8 +186,7 @@ func (i viewInstall) prepare(req agent.PrepareRequest, session agent.ViewSession if !path.IsAbs(dir) || path.Clean(dir) != dir || dir == "/" { return launchOptions{}, errors.New("mcode: the workspace is not a canonical absolute path") } - // The Harness runs each stdio alias without arguments. - servers, err := workspaceMCP(session.MCP, func(stdio agent.EnvironmentMCP) (string, []string) { return stdio.Server.Command, []string{} }) + servers, err := workspaceMCP(session.MCP) if err != nil { return launchOptions{}, err } @@ -191,7 +218,7 @@ func (i viewInstall) prepare(req agent.PrepareRequest, session agent.ViewSession 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"}, skills: req.Skills} + tools = &workspaceTools{node: i.node, bridge: i.bridge, profile: map[string]any{"workspace": dir, "scratch": tempDir}, skills: req.Skills} opts.MCP = append(opts.MCP, tools.server(dataDir)) } if err := writeNativeConfig(private, data, dataDir, tools); err != nil { diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go index a3aec4f26..26919986d 100644 --- a/apps/daemon/internal/agent/mcode/workspace.go +++ b/apps/daemon/internal/agent/mcode/workspace.go @@ -1,89 +1,11 @@ package mcode import ( - "fmt" - "os" "path/filepath" - "runtime" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" ) -// WorkspaceConfig is frozen deployment input, separate from the Session request. -type WorkspaceConfig struct { - Binary, Node, Bridge, Directory, Network, Scratch string - AllowedDomains []string -} - -func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy) (WorkspaceConfig, error) { - c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), - Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch")} - if runtime.GOOS == "windows" || network.Access != "enabled" || len(network.AllowedDomains) != 0 { - return c, fmt.Errorf("mcode: native execution requires Linux or macOS and unrestricted host access") - } - if network.Validate() != nil { - return c, fmt.Errorf("mcode: explicit workspace network policy is required") - } - for _, path := range []string{binary, node, bridge, root, workspace} { - if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { - return c, fmt.Errorf("mcode: canonical absolute deployment paths are required") - } - } - for _, path := range []string{binary, node, bridge} { - info, err := os.Stat(path) - if err != nil || !info.Mode().IsRegular() { - return c, fmt.Errorf("mcode: runtime program unavailable") - } - } - bound, err := os.Stat(workspace) - if err != nil || !bound.IsDir() { - return c, fmt.Errorf("mcode: workspace directory unavailable") - } - - if err := os.MkdirAll(c.Scratch, 0700); err != nil { - return c, err - } - return c, nil -} - -func prepareWorkspaceOptions(c WorkspaceConfig, req agent.PrepareRequest) (launchOptions, error) { - if c.Network != "enabled" || len(c.AllowedDomains) != 0 { - return launchOptions{}, fmt.Errorf("mcode: Runtime does not implement network isolation") - } - if req.LocalEnvironment == nil || req.WorkspaceRoot != c.Directory || req.DisableExecutionEnvironment || req.WorkspaceReadOnly { - return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") - } - servers, bindings, err := runtimeMCP(req) - if err != nil { - return launchOptions{}, err - } - tools := workspaceTools{node: c.Node, bridge: c.Bridge, profile: map[string]any{"capabilityRoot": req.CapabilityRoot, "workspace": c.Directory, "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.Skills) > 0}, - skills: req.Skills} - file, err := localworkspace.ToolEnvironmentFile() - if err != nil { - return launchOptions{}, err - } - if file != "" { - tools.profile["toolEnvFile"] = file - } - // Reuse public option validation and private Session state provisioning. - // The native process, ACP Session and workspace tools share the declared cwd. - private := req - private.LocalEnvironment, private.DisableExecutionEnvironment = nil, true - // Public declarations have already been resolved into the transient ACP map. - private.MCPHTTPServers = nil - opts, err := prepareOptionsWithTools(private, &tools) - if err != nil { - return opts, err - } - opts.Dir, opts.bindings = c.Directory, bindings - opts.MCP = append([]map[string]any{tools.server(opts.DataDir)}, servers...) - return opts, nil -} - // workspaceTools is the workspace bridge as the native process runs it, the // bridge's profile and the installed Skills it presents. type workspaceTools struct { diff --git a/apps/daemon/internal/agent/mcode/workspace_network_test.go b/apps/daemon/internal/agent/mcode/workspace_network_test.go deleted file mode 100644 index eb158cb52..000000000 --- a/apps/daemon/internal/agent/mcode/workspace_network_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package mcode - -import "testing" - -func TestWorkspaceRejectsInnerNetworkIsolation(t *testing.T) { - for _, access := range []string{"disabled", "restricted"} { - c, req, _ := workspaceFixture(t) - c.Network = access - if _, err := prepareWorkspaceOptions(c, req); err == nil { - t.Fatal("unsupported network isolation accepted") - } - } -} diff --git a/apps/daemon/internal/agent/mcode/workspace_readiness.go b/apps/daemon/internal/agent/mcode/workspace_readiness.go index 7f44d672a..4c496943b 100644 --- a/apps/daemon/internal/agent/mcode/workspace_readiness.go +++ b/apps/daemon/internal/agent/mcode/workspace_readiness.go @@ -1,38 +1,10 @@ package mcode import ( - "context" "encoding/json" "fmt" - "io" - "path/filepath" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" ) -// CheckWorkspace verifies the installed companion. Public qualification remains -// an operator deployment requirement, not an implication of this probe. -func CheckWorkspace(ctx context.Context, c WorkspaceConfig) error { - ctx, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() - p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: c.Node, Args: []string{filepath.Join(filepath.Dir(c.Bridge), "check.mjs")}, Env: executionEnvironment()}) - if err != nil { - return err - } - defer p.Cancel() - go func() { _, _ = io.Copy(io.Discard, p.Stderr) }() - raw, err := io.ReadAll(io.LimitReader(p.Stdout, 4097)) - if err != nil || len(raw) > 4096 { - p.Cancel() - } - waitErr := p.Wait() - if err != nil || waitErr != nil { - return fmt.Errorf("mcode: workspace companion check failed") - } - return ValidateWorkspaceReadiness(raw) -} - // ValidateWorkspaceReadiness checks the installed companion's private contract. // Neither upstream version alone nor a successful CLI --version proves cleanup. func ValidateWorkspaceReadiness(raw []byte) error { diff --git a/apps/daemon/internal/agent/mcode/workspace_readiness_test.go b/apps/daemon/internal/agent/mcode/workspace_readiness_test.go index 6fca32fe8..319a4aefc 100644 --- a/apps/daemon/internal/agent/mcode/workspace_readiness_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_readiness_test.go @@ -1,36 +1,14 @@ package mcode import ( - "context" - "fmt" - "os" - "path/filepath" - "runtime" "strings" "testing" ) -func TestWorkspaceReadinessRejectsOldCleanupContract(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("MiniMax adapter supports Linux and macOS") - } - for _, protocol := range []int{1, 2} { - t.Run(fmt.Sprint(protocol), func(t *testing.T) { - dir := t.TempDir() - node := filepath.Join(dir, "node") - body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' '{\"protocol\":%d,\"native\":\"0.4.12\",\"source\":\"33b259bbbeb1c16433390869938191d09bdb0680\"}'\n", protocol) - if err := os.WriteFile(node, []byte(body), 0700); err != nil { - t.Fatal(err) - } - err := CheckWorkspace(context.Background(), WorkspaceConfig{Node: node, Bridge: filepath.Join(dir, "bridge.mjs")}) - if (err == nil) != (protocol == 2) { - t.Fatalf("protocol %d readiness: %v", protocol, err) - } - }) - } -} - func TestValidateWorkspaceReadinessRejectsInvalidDescriptors(t *testing.T) { + if err := ValidateWorkspaceReadiness([]byte(`{"protocol":2,"native":"0.4.12","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`)); err != nil { + t.Fatal(err) + } for _, raw := range []string{"", "null", "{", strings.Repeat(" ", 4097), `{"protocol":1,"native":"0.4.12","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, `{"protocol":2,"native":"0.4.11","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, diff --git a/apps/daemon/internal/agent/mcode/workspace_skills_test.go b/apps/daemon/internal/agent/mcode/workspace_skills_test.go index 1a847d515..2e9163a70 100644 --- a/apps/daemon/internal/agent/mcode/workspace_skills_test.go +++ b/apps/daemon/internal/agent/mcode/workspace_skills_test.go @@ -12,7 +12,7 @@ import ( ) func TestWorkspaceSkillsUseSelectedSnapshotAndNativeLoader(t *testing.T) { - c, req, _ := workspaceFixture(t) + install, session, req := fakeInstall("node"), hostSession(t), workspaceRequest(t) root := t.TempDir() path := filepath.Join(root, "plugin", "skills", "proof") if err := os.MkdirAll(path, 0700); err != nil { @@ -28,7 +28,7 @@ func TestWorkspaceSkillsUseSelectedSnapshotAndNativeLoader(t *testing.T) { Metadata: agentskill.Metadata{Name: "proof", Description: "Read a marker"}, }} for range 2 { - opts, err := prepareWorkspaceOptions(c, req) + opts, err := install.prepare(req, session) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/mcp_binding_test.go b/apps/daemon/internal/agent/mcp_binding_test.go index 05ba5a330..d65cf19cb 100644 --- a/apps/daemon/internal/agent/mcp_binding_test.go +++ b/apps/daemon/internal/agent/mcp_binding_test.go @@ -1,6 +1,7 @@ package agent import ( + "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -106,3 +107,28 @@ func TestPublicEnvironmentMCPRetainsVaultAuthority(t *testing.T) { t.Fatal("unprepared environment accepted") } } + +func TestPublicMCPHTTPRejectsUnsafeEndpointsAndBearers(t *testing.T) { + resolve := func(server proto.MCPHTTPServer) error { + servers := []proto.MCPHTTPServer{server} + _, err := ResolveMCPBindings(PrepareRequest{PromptRequestPayload: proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}}) + return err + } + for _, endpoint := range []string{"https://user:synthetic-secret@docs.example/mcp", "https://docs.example/mcp?token=synthetic-secret", "file:///tmp/mcp"} { + if err := resolve(proto.MCPHTTPServer{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: endpoint}); err == nil || strings.Contains(err.Error(), "synthetic-secret") { + t.Fatal("unsupported endpoint was accepted or exposed", err) + } + } + token := "synthetic-private-token" + if err := resolve(proto.MCPHTTPServer{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "http://tools.example/mcp", BearerToken: &token}); err == nil || strings.Contains(err.Error(), token) { + t.Fatal("plaintext bearer accepted or exposed") + } + if err := resolve(proto.MCPHTTPServer{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}); err != nil { + t.Fatal("HTTPS bearer declaration rejected", err) + } + for _, invalid := range []string{"", "=", " has-space", "has-space ", "has space", "line\r\ninjection", "nul\x00byte", "opaque中文", "middle=padding", "punctuation:invalid"} { + if err := resolve(proto.MCPHTTPServer{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &invalid}); err == nil || err.Error() != "invalid HTTPS MCP bearer credential" { + t.Fatal("invalid bearer value accepted or unsafe error returned") + } + } +} diff --git a/apps/daemon/internal/agent/registry_test.go b/apps/daemon/internal/agent/registry_test.go index 60eaf62c4..cd0ae756d 100644 --- a/apps/daemon/internal/agent/registry_test.go +++ b/apps/daemon/internal/agent/registry_test.go @@ -58,19 +58,21 @@ func TestRegistryRegisterPanicsOnEmptyKind(t *testing.T) { agent.NewRegistry().RegisterKind(proto.SupportedAgentKind{Kind: "", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, prototest.ModelConfiguration()) } -func TestRegistryRegisterRejectsFactoriesForUnavailableRuntime(t *testing.T) { +func TestRegistryRegisterRejectsViewForUnavailableRuntime(t *testing.T) { info := proto.SupportedAgentKind{Kind: "k", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})} - executor := func(context.Context, agent.PrepareRequest) (agent.Executor, error) { return nil, nil } + view := &agent.View{Proxy: agent.ViewProxyNone, Executor: func(context.Context, agent.PrepareRequest, agent.ViewSession) (agent.Executor, error) { + return nil, nil + }} registry := agent.NewRegistry() defer func() { if recover() == nil { - t.Fatal("unavailable runtime registered factories") + t.Fatal("unavailable runtime registered a view") } if len(registry.SupportedAgentKinds()) != 0 { t.Fatal("rejected runtime changed registry") } }() - registry.Register(agent.Declaration{Info: info, Configuration: prototest.ModelConfiguration()}, agent.Runtime{Info: info, Executor: executor}, agent.EnvironmentSupport{Local: true}) + registry.Register(agent.Declaration{Info: info, Configuration: prototest.ModelConfiguration()}, agent.Runtime{Info: info, View: view}, agent.EnvironmentSupport{Local: true}) } func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index 98d9f9ca2..3b1dc4a19 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -272,6 +272,19 @@ func (dm *daemon) assign(t *testing.T, b Binding) { } } +// suspend sends Core's quiesce or resume of b's Environment and returns its +// result. +func (dm *daemon) suspend(t *testing.T, b Binding, typ string, request proto.EnvironmentSuspendPayload) proto.EnvironmentSuspendResultPayload { + t.Helper() + id := sandboxwire.NewID().String() + dm.handle(t, ref(b), typ, id, request) + var result proto.EnvironmentSuspendResultPayload + if err := dm.next(t, id).DecodePayload(&result); err != nil { + t.Fatal(err) + } + return result +} + // status returns the assignment status sent with id. func (dm *daemon) status(t *testing.T, id string) proto.AssignmentStatusPayload { t.Helper() diff --git a/apps/daemon/internal/agenthost/environment_linux_test.go b/apps/daemon/internal/agenthost/environment_linux_test.go index fb12f1689..94d5e0bcc 100644 --- a/apps/daemon/internal/agenthost/environment_linux_test.go +++ b/apps/daemon/internal/agenthost/environment_linux_test.go @@ -141,20 +141,18 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { } checkSetup(t) - // Quiesce drains the owner; after Resume it serves a read on a new + // A quiesce drains the owner; after the resume it serves a read on a new // attachment. second.release(t, b, id, status.Handle) suspension := proto.EnvironmentSuspendPayload{EnvironmentID: environmentID(b), SuspendID: "suspend-" + uuid.NewString()} - ctx, cancel := context.WithTimeout(context.Background(), wait) - defer cancel() - if err := second.router.Quiesce(ctx, ref(b), suspension); err != nil { - t.Fatalf("Quiesce: %v", err) + if r := second.suspend(t, b, proto.TypeEnvironmentQuiesce, suspension); !r.Accepted { + t.Fatalf("the quiesce is %+v", r) } if !h.drained(t, b) { t.Fatal("the quiesced owner kept its attachment") } - if err := second.router.Resume(ref(b), suspension, second); err != nil { - t.Fatalf("Resume: %v", err) + if r := second.suspend(t, b, proto.TypeEnvironmentResume, suspension); !r.Accepted { + t.Fatalf("the resume is %+v", r) } id, status = second.prepare(t, b, req) if status.State != "ready" { diff --git a/apps/daemon/internal/agenthost/executor_linux.go b/apps/daemon/internal/agenthost/executor_linux.go index 768344ecb..f5acae3d4 100644 --- a/apps/daemon/internal/agenthost/executor_linux.go +++ b/apps/daemon/internal/agenthost/executor_linux.go @@ -50,7 +50,8 @@ func registry(harnesses *agent.Registry, factory agent.ExecutorFactory) *agent.R if _, viewErr := harnesses.ResolveView(info.Kind); err != nil || viewErr != nil { continue } - reg.Register(agent.Declaration{Info: info, Configuration: configuration}, agent.Runtime{Info: info, Executor: factory}, agent.EnvironmentSupport{Local: true, None: true}) + reg.Register(agent.Declaration{Info: info, Configuration: configuration}, agent.Runtime{Info: info}, agent.EnvironmentSupport{Local: true, None: true}) + reg.RegisterExecutor(info.Kind, factory) } return reg } diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index 2f3f2546c..02e1d3540 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -112,7 +112,7 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { if raw == "" { t.Skipf("set OAC_QUALIFY_%s to the Harness's model and model_provider", strings.ToUpper(kind)) } - runtime := declaration.Discover(context.Background(), agent.DiscoveryOptions{Profile: "default", Stdout: io.Discard, Stderr: os.Stderr}, declaration.Info) + runtime := declaration.Discover(context.Background(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: os.Stderr}, declaration.Info) if runtime == nil || runtime.View == nil { t.Fatalf("%s declares no agent-host view; discovery reported why above", kind) } diff --git a/apps/daemon/internal/auth/store.go b/apps/daemon/internal/auth/store.go deleted file mode 100644 index ec636f67e..000000000 --- a/apps/daemon/internal/auth/store.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package auth reads the daemon credential profile written by -// oac-core-device: server URL, runtime row id (= device_id), and the -// long-lived runner_credential. Stored as JSON per-profile at -// ~/.oac/daemon//auth.json (0o600). -package auth - -import ( - "encoding/json" - "errors" - "fmt" - "os" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" -) - -// Profile is the on-disk representation of one daemon credential. -type Profile struct { - // ServerURL is the absolute base URL the daemon dials (no - // trailing slash). The daemon joins this with paths like - // /agent-daemon/bootstrap. - ServerURL string `json:"server_url"` - - // RuntimeID is the runtimes row id. The gateway uses it verbatim - // as device_id on WS upgrade. - RuntimeID string `json:"runtime_id"` - - // RunnerCredential is the bearer presented on every - // /agent-daemon/* call. Stored plaintext in a 0o600 file; the - // server holds only the hash, so this is the only proof of - // identity and MUST NOT be checked into VCS. - RunnerCredential string `json:"runner_credential"` - - DeviceName string `json:"device_name,omitempty"` -} - -// ErrNotPaired is returned by Load when no auth.json exists for the -// requested profile. -var ErrNotPaired = errors.New("auth: no daemon credential profile — create one with oac-core-device") - -// Load reads the profile's auth.json. Returns ErrNotPaired wrapping -// fs.ErrNotExist when the file is missing. -func Load(profile string) (Profile, error) { - authPath, err := paths.AuthFile(profile) - if err != nil { - return Profile{}, err - } - raw, err := os.ReadFile(authPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - // Multi-%w so errors.Is matches both ErrNotPaired AND - // fs.ErrNotExist. - return Profile{}, fmt.Errorf("%w (looked at %s): %w", ErrNotPaired, authPath, err) - } - return Profile{}, fmt.Errorf("auth: read: %w", err) - } - var p Profile - if err := json.Unmarshal(raw, &p); err != nil { - return Profile{}, fmt.Errorf("auth: parse %s: %w", authPath, err) - } - return p, nil -} - -// Delete removes the auth.json for a profile. Idempotent — missing -// file is not an error. -func Delete(profile string) error { - authPath, err := paths.AuthFile(profile) - if err != nil { - return err - } - if err := os.Remove(authPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("auth: delete: %w", err) - } - return nil -} diff --git a/apps/daemon/internal/auth/store_test.go b/apps/daemon/internal/auth/store_test.go deleted file mode 100644 index 1d3bfffcc..000000000 --- a/apps/daemon/internal/auth/store_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package auth_test - -import ( - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" -) - -func withTempHome(t *testing.T) string { - t.Helper() - dir := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", dir) - return dir -} - -func profileDir(t *testing.T, profile string) string { - t.Helper() - dir, err := paths.ProfileDir(profile) - if err != nil { - t.Fatalf("ProfileDir: %v", err) - } - if err := runtimefs.EnsurePrivateDir(dir); err != nil { - t.Fatalf("EnsurePrivateDir: %v", err) - } - return dir -} - -func writeProfile(t *testing.T, profile string, p auth.Profile) { - t.Helper() - dir := profileDir(t, profile) - raw, err := json.Marshal(p) - if err != nil { - t.Fatalf("marshal profile: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, "auth.json"), raw, 0o600); err != nil { - t.Fatalf("write auth.json: %v", err) - } -} - -func TestLoadReadsProfile(t *testing.T) { - _ = withTempHome(t) - want := auth.Profile{ - ServerURL: "https://core.example.com", - RuntimeID: "rt_abc123", - RunnerCredential: "secret-credential", - DeviceName: "alice-mac", - } - writeProfile(t, "test", want) - got, err := auth.Load("test") - if err != nil { - t.Fatalf("Load: %v", err) - } - if got != want { - t.Fatalf("Load mismatch:\n got=%+v\nwant=%+v", got, want) - } -} - -func TestLoadIgnoresLegacyProfileFields(t *testing.T) { - _ = withTempHome(t) - raw := `{"server_url":"https://core.example.com/api/v1","runtime_id":"rt","runner_credential":"c","device_name":"d",` + - `"hostname":"h","paired_at":"2026-06-04T12:00:00Z","runner_public_key":"pub","runner_private_key":"priv"}` - if err := os.WriteFile(filepath.Join(profileDir(t, "legacy"), "auth.json"), []byte(raw), 0o600); err != nil { - t.Fatalf("write auth.json: %v", err) - } - got, err := auth.Load("legacy") - if err != nil { - t.Fatalf("Load: %v", err) - } - want := auth.Profile{ServerURL: "https://core.example.com/api/v1", RuntimeID: "rt", RunnerCredential: "c", DeviceName: "d"} - if got != want { - t.Fatalf("Load = %+v, want %+v", got, want) - } -} - -func TestLoadMissingReturnsErrNotPaired(t *testing.T) { - _ = withTempHome(t) - _, err := auth.Load("default") - if !errors.Is(err, auth.ErrNotPaired) { - t.Fatalf("Load on missing profile returned %v, want ErrNotPaired", err) - } - // ErrNotPaired must also wrap fs.ErrNotExist for the canonical - // "missing file" check. - if !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("ErrNotPaired must wrap fs.ErrNotExist, got %v", err) - } -} - -func TestLoadCorruptJSONReturnsError(t *testing.T) { - _ = withTempHome(t) - dir := profileDir(t, "default") - if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte("{not valid json"), 0o600); err != nil { - t.Fatalf("seed corrupt file: %v", err) - } - _, err := auth.Load("default") - if err == nil { - t.Fatal("Load returned nil error on corrupt JSON") - } - if errors.Is(err, auth.ErrNotPaired) { - t.Fatalf("Load on corrupt JSON should not be ErrNotPaired: %v", err) - } -} - -func TestDeleteIsIdempotent(t *testing.T) { - _ = withTempHome(t) - if err := auth.Delete("default"); err != nil { - t.Fatalf("Delete on missing profile returned %v, want nil (idempotent)", err) - } - writeProfile(t, "default", auth.Profile{ServerURL: "https://x", RuntimeID: "rt", RunnerCredential: "c"}) - if err := auth.Delete("default"); err != nil { - t.Fatalf("Delete: %v", err) - } - if _, err := auth.Load("default"); !errors.Is(err, auth.ErrNotPaired) { - t.Fatalf("Load after Delete = %v, want ErrNotPaired", err) - } - // Second Delete must still succeed. - if err := auth.Delete("default"); err != nil { - t.Fatalf("Delete second call = %v, want nil", err) - } -} diff --git a/apps/daemon/internal/cli/agent_discovery.go b/apps/daemon/internal/cli/agent_discovery.go deleted file mode 100644 index d29b85acc..000000000 --- a/apps/daemon/internal/cli/agent_discovery.go +++ /dev/null @@ -1,44 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" -) - -var harnessDeclarations = []agent.Declaration{codex.Declaration, mcode.Declaration, claudesdk.Declaration} - -type discoveredHarness struct { - declaration agent.Declaration - runtime agent.Runtime -} -type agentCLIDiscovery []discoveredHarness - -func preflightAgentCLIs(parent context.Context, rc *runContext, profile string) (agentCLIDiscovery, error) { - return discoverAgentCLIs(parent, rc, profile, harnessDeclarations) -} -func discoverAgentCLIs(parent context.Context, rc *runContext, profile string, declarations []agent.Declaration) (agentCLIDiscovery, error) { - var out agentCLIDiscovery - available := false - for _, declaration := range declarations { - if rc.installedKinds != nil && !rc.installedKinds[declaration.Info.Kind] { - continue - } - runtime := declaration.Discover(parent, agent.DiscoveryOptions{Profile: profile, Stdout: rc.stdout, Stderr: rc.stderr}, declaration.Info) - if runtime == nil { - continue - } - out = append(out, discoveredHarness{declaration, *runtime}) - available = available || runtime.Info.Available - } - if err := parent.Err(); err != nil { - return out, err - } - if !available { - return out, fmt.Errorf("connect: no supported agent CLI available (install a supported Harness runtime)") - } - return out, nil -} diff --git a/apps/daemon/internal/cli/agent_host_linux.go b/apps/daemon/internal/cli/agent_host_linux.go index 05d6bfac3..7efe75922 100644 --- a/apps/daemon/internal/cli/agent_host_linux.go +++ b/apps/daemon/internal/cli/agent_host_linux.go @@ -25,7 +25,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agenthost" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" @@ -51,6 +50,8 @@ const ( // agentHostUIDs is the range the agent host runs its Executors as. var agentHostUIDs = agenthost.UIDRange{First: 70000, Count: 4096} +var harnessDeclarations = []agent.Declaration{codex.Declaration, mcode.Declaration, claudesdk.Declaration} + func runAgentHost(rc *runContext, args []string) error { return serveAgentHost(context.Background(), rc, args, harnessDeclarations) } @@ -109,7 +110,7 @@ func serveAgentHost(parent context.Context, rc *runContext, args []string, decla } harnesses := agent.NewRegistry() for _, declaration := range declarations { - runtime := declaration.Discover(ctx, agent.DiscoveryOptions{Profile: paths.DefaultProfile, Stdout: rc.stdout, Stderr: rc.stderr}, declaration.Info) + runtime := declaration.Discover(ctx, agent.DiscoveryOptions{Stdout: rc.stdout, Stderr: rc.stderr}, declaration.Info) if runtime == nil || runtime.View == nil { continue } @@ -155,3 +156,133 @@ func serveAgentHost(parent context.Context, rc *runContext, args []string, decla return pumpConn(ctx, conn, cfg, boot) }) } + +// serveConnections dials Core with dial and serves each connection until ctx +// ends or Core rejects the credential. With a positive unreachable bound it +// fails once Core has stayed unreachable that long. +func serveConnections(ctx context.Context, wsURL string, dial transport.DialFn, unreachable time.Duration, serve func(*transport.Conn) error) error { + for { + if err := ctx.Err(); err != nil { + return nil + } + + dialCtx, stop := ctx, context.CancelFunc(func() {}) + if unreachable > 0 { + dialCtx, stop = context.WithTimeout(ctx, unreachable) + } + conn, err := transport.Reconnect(dialCtx, dial, transport.DefaultBackoff, func(attempt int, lastDelay time.Duration, lastErr error) { + switch { + case attempt == 1: + obslog.Bg().Info("connecting", "ws_url", wsURL) + case lastErr != nil: + // Include lastErr so a stuck Reconnect tells the + // operator WHY ("ws upgrade rejected with 426") + // instead of just "retry attempt 3 after 4s". + obslog.Bg().Warn("dial retry", "attempt", attempt, "delay", lastDelay, "err", lastErr) + default: + obslog.Bg().Warn("dial retry", "attempt", attempt, "delay", lastDelay) + } + }) + stop() + if err != nil { + if ctx.Err() != nil { + return nil + } + if errors.Is(err, transport.ErrPermanent) { + return fmt.Errorf("connect: permanent error (reissue the daemon credential): %w", err) + } + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("connect: Core unreachable for %s: %w", unreachable, err) + } + return fmt.Errorf("connect: dial: %w", err) + } + obslog.Bg().Info("ws connected", "device_id", conn.DeviceID()) + + // serve returns on conn close (peer hangup, transport error, ctx + // cancel). Loop back into Reconnect unless ctx is cancelled. + pumpErr := serve(conn) + if pumpErr != nil { + obslog.Bg().Warn("ws session ended", "err", pumpErr) + } else { + obslog.Bg().Info("ws session ended cleanly") + } + _ = conn.Close() + + // Server-initiated clean close (e.g. shutdown) → exit; + // otherwise loop back and reconnect. + if ctx.Err() != nil { + return nil + } + // Permanent error (e.g. runtime deleted) → exit instead of + // reconnecting. + if pumpErr != nil && errors.Is(pumpErr, transport.ErrPermanent) { + return fmt.Errorf("connect: runtime deleted (reissue the daemon credential): %w", pumpErr) + } + // Small breather before redialing so a flapping server doesn't + // get a tight loop of upgrade requests. + _ = transport.Sleep(ctx, 1*time.Second) + } +} + +// pumpConn runs the per-connection workload: a dispatch.Router of cfg's +// Harness kinds and Environment owners fed by conn.Recv(), heartbeats every +// boot.HeartbeatInterval(), and a confirmed router.Shutdown before returning +// ownership to the reconnect loop. Failed cleanup keeps this exact Router +// alive, including after a shutdown signal. +func pumpConn(parentCtx context.Context, conn *transport.Conn, cfg dispatch.Config, boot *transport.BootstrapResponse) error { + cfg.Sender, cfg.Log = conn, obslog.Bg() + router, err := dispatch.New(cfg) + if err != nil { + return fmt.Errorf("router init: %w", err) + } + defer func() { + _ = conn.Close() + shutdownRouterUntilConfirmed(router.Shutdown, time.Second) + }() + + conn.StartHeartbeats(parentCtx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { + return proto.HeartbeatPayload{ + SupportedAgentKinds: cfg.Registry.SupportedAgentKinds(), + HomeRemoval: proto.CapabilityFromBool(cfg.RemoveHome != nil), + } + }, obslog.Bg().With("component", "heartbeat")) + + obslog.Bg().Info("pumpConn: entering recv loop") + for { + select { + case <-parentCtx.Done(): + obslog.Bg().Warn("pumpConn: parentCtx cancelled", "err", parentCtx.Err()) + return parentCtx.Err() + case <-conn.Done(): + obslog.Bg().Warn("pumpConn: conn.Done fired", "err", conn.Err()) + return conn.Err() + case env, ok := <-conn.Recv(): + if !ok { + obslog.Bg().Warn("pumpConn: recvCh closed", "err", conn.Err()) + return conn.Err() + } + obslog.Bg().Info("pumpConn: received envelope, calling router.Handle", "type", env.Type, "id", env.ID) + if err := router.Handle(parentCtx, env); err != nil { + obslog.Bg().Error("router.Handle failed", "type", env.Type, "id", env.ID, "err", err) + } else { + obslog.Bg().Info("pumpConn: router.Handle ok", "type", env.Type, "id", env.ID) + } + } + } +} + +// A wait deadline does not revoke native ownership. Keep retrying the same +// Router until it confirms cleanup; reconnect and process exit both wait here. +// Router.Shutdown serializes attempts and retains resources after a failure. +func shutdownRouterUntilConfirmed(shutdown func(context.Context) error, retryDelay time.Duration) { + for { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := shutdown(ctx) + cancel() + if err == nil { + return + } + obslog.Bg().Warn("router cleanup unconfirmed; reconnect remains blocked", "err", err) + time.Sleep(retryDelay) + } +} diff --git a/apps/daemon/internal/cli/agent_host_linux_test.go b/apps/daemon/internal/cli/agent_host_linux_test.go index c50470634..6060f163b 100644 --- a/apps/daemon/internal/cli/agent_host_linux_test.go +++ b/apps/daemon/internal/cli/agent_host_linux_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "slices" "strings" + "sync/atomic" "testing" "time" @@ -22,6 +23,7 @@ import ( "golang.org/x/sys/unix" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" @@ -148,3 +150,190 @@ func TestAgentHostExitsWhileCoreStaysUnreachable(t *testing.T) { t.Fatalf("serveConnections = %v, want the unreachable bound", err) } } + +func TestReconnectWaitsForConfirmedRouterCleanup(t *testing.T) { + for _, failure := range []error{errors.New("native close failed"), context.DeadlineExceeded} { + t.Run(failure.Error(), func(t *testing.T) { + var calls atomic.Int32 + retry := make(chan struct{}) + confirm := make(chan struct{}) + returned := make(chan struct{}) + go func() { + shutdownRouterUntilConfirmed(func(ctx context.Context) error { + if ctx.Err() != nil { + t.Error("cleanup inherited a cancelled connection context") + } + if _, ok := ctx.Deadline(); !ok { + t.Error("cleanup wait has no deadline") + } + if calls.Add(1) == 1 { + return failure + } + close(retry) + <-confirm + return nil + }, time.Millisecond) + close(returned) + }() + select { + case <-retry: + case <-time.After(time.Second): + t.Fatal("same cleanup was not retried") + } + select { + case <-returned: + t.Fatal("reconnect released unconfirmed ownership") + default: + } + close(confirm) + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("confirmed cleanup did not release reconnect") + } + if calls.Load() != 2 { + t.Fatalf("cleanup attempts=%d", calls.Load()) + } + }) + } +} + +type cleanupExecutor struct { + closes atomic.Int32 + retry chan struct{} + confirm chan struct{} +} + +func (e *cleanupExecutor) StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { + return nil, errors.New("unexpected Turn") +} +func (e *cleanupExecutor) Close(context.Context) error { + if e.closes.Add(1) == 1 { + return errors.New("native cleanup temporarily unavailable") + } + close(e.retry) + <-e.confirm + return nil +} + +func TestDisconnectedPumpRetainsExactExecutorUntilCleanup(t *testing.T) { + peers := make(chan *websocket.Conn, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err == nil { + peers <- peer + } + })) + defer server.Close() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + dial := func(ctx context.Context) (*transport.Conn, error) { + return transport.Dial(ctx, transport.DialOptions{ + WSURL: "ws" + strings.TrimPrefix(server.URL, "http"), DeviceID: "device", + Credential: "credential", DaemonVersion: proto.Version, + }) + } + owner := &cleanupExecutor{retry: make(chan struct{}), confirm: make(chan struct{})} + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, prototest.ModelConfiguration()) + var factories atomic.Int32 + registry.RegisterExecutor("cleanup", func(context.Context, agent.PrepareRequest) (agent.Executor, error) { + factories.Add(1) + return owner, nil + }) + finished := make(chan error, 1) + go func() { + boot := &transport.BootstrapResponse{HeartbeatSeconds: 60} + conn, err := dial(ctx) + if err != nil { + finished <- err + return + } + defer conn.Close() + finished <- pumpConn(ctx, conn, dispatch.Config{Registry: registry}, boot) + }() + var peer *websocket.Conn + select { + case peer = <-peers: + case <-time.After(3 * time.Second): + t.Fatal("initial connection missing") + } + defer peer.Close() + ref := proto.AssignmentRef{SessionID: "cleanup", AssignmentID: "assignment", Epoch: 1} + if got := sendAssignment(t, peer, proto.TypeAssignmentBind, ref, proto.AssignmentBindPayload{}); got.State != proto.AssignmentBound { + t.Fatalf("bind = %+v", got) + } + env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "cleanup", + Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "cleanup", DisableExecutionEnvironment: true})}) + if err != nil { + t.Fatal(err) + } + env.Assignment = ref + if err := peer.WriteJSON(env); err != nil { + t.Fatal(err) + } + _ = peer.SetReadDeadline(time.Now().Add(3 * time.Second)) + for { + var result proto.Envelope + if err := peer.ReadJSON(&result); err != nil { + t.Fatal(err) + } + if result.Type != proto.TypePreparationStatus { + continue + } + var status proto.PreparationStatusPayload + if err := result.DecodePayload(&status); err != nil { + t.Fatal(err) + } + if status.State == "ready" { + break + } + if status.State == "failed" { + t.Fatalf("preparation failed: %+v", status) + } + } + _ = peer.Close() + select { + case <-owner.retry: + case <-time.After(3 * time.Second): + t.Fatal("original executor cleanup was not retried") + } + select { + case err := <-finished: + t.Fatalf("pump discarded native ownership: %v", err) + default: + } + cancel() + close(owner.confirm) + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("pump did not return after confirmed cleanup") + } + if factories.Load() != 1 || owner.closes.Load() != 2 { + t.Fatalf("factory=%d close=%d", factories.Load(), owner.closes.Load()) + } +} + +func sendAssignment(t *testing.T, peer *websocket.Conn, kind string, ref proto.AssignmentRef, payload any) proto.AssignmentStatusPayload { + t.Helper() + env, err := proto.NewEnvelope(kind, kind, payload) + if err != nil { + t.Fatal(err) + } + env.Assignment = ref + if err := peer.WriteJSON(env); err != nil { + t.Fatal(err) + } + _ = peer.SetReadDeadline(time.Now().Add(3 * time.Second)) + for { + var reply proto.Envelope + if err := peer.ReadJSON(&reply); err != nil { + t.Fatal(err) + } + var status proto.AssignmentStatusPayload + if reply.Type == proto.TypeAssignmentStatus && reply.ID == kind && reply.Assignment == ref && reply.DecodePayload(&status) == nil { + return status + } + } +} diff --git a/apps/daemon/internal/cli/agent_registration.go b/apps/daemon/internal/cli/agent_registration.go deleted file mode 100644 index b1230fb98..000000000 --- a/apps/daemon/internal/cli/agent_registration.go +++ /dev/null @@ -1,9 +0,0 @@ -package cli - -import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - -func registerAgentKinds(registry *agent.Registry, discovery agentCLIDiscovery, environments agent.EnvironmentSupport) { - for _, discovered := range discovery { - registry.Register(discovered.declaration, discovered.runtime, environments) - } -} diff --git a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go deleted file mode 100644 index 737fd01ff..000000000 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ /dev/null @@ -1,215 +0,0 @@ -//go:build linux - -package cli - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" - "github.com/google/uuid" -) - -type registeredSDKSender chan proto.Envelope - -func (s registeredSDKSender) Send(ctx context.Context, env proto.Envelope) error { - select { - case s <- env: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func TestLiveRegisteredClaudeSDK(t *testing.T) { - entrypoint, keyFile := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE") - if entrypoint == "" || keyFile == "" { - t.Skip("requires explicit SDK runtime and real provider key file") - } - proofRoot := os.Getenv("OAC_TEST_CLAUDE_SDK_PROOF_DIR") - if !filepath.IsAbs(proofRoot) { - t.Fatal("real acceptance requires an absolute managed proof directory") - } - root, err := os.MkdirTemp(proofRoot, "claude-registered-") - if err != nil { - t.Fatal(err) - } - t.Logf("registered SDK evidence: %s", root) - t.Setenv("OAC_RUNTIME_HOME", root) - key, err := os.ReadFile(keyFile) - if err != nil { - t.Fatal(err) - } - provider := &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: "https://api.minimax.cn/anthropic", APIKey: strings.TrimSpace(string(key))} - for name, value := range map[string]string{ - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M3", - } { - t.Setenv(name, value) - } - stdout, stderr := &strings.Builder{}, &strings.Builder{} - discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: stdout, stderr: stderr, installedKinds: map[string]bool{"claude_sdk": true}}, "acceptance", harnessDeclarations) - if err != nil || len(discovery) != 1 || !discovery[0].runtime.Info.Available { - t.Fatal("SDK-only discovery failed", err) - } - type execution struct { - Outcome proto.DonePayload `json:"outcome"` - Text string `json:"text"` - Events []proto.Envelope `json:"events"` - FunctionCalls int `json:"function_calls"` - AppliedResults int `json:"applied_results"` - Cancelled bool `json:"cancelled"` - } - nonce := "registered-function-" + uuid.NewString() - ref := proto.AssignmentRef{SessionID: prototest.SessionID, AssignmentID: "registered-acceptance", Epoch: 1} - run := func(index int, prompt, resume string, callFunction, cancelOnText bool) execution { - t.Helper() - reg := agent.NewRegistry() - registerAgentKinds(reg, discovery, agent.EnvironmentSupport{None: true}) - sender := make(registeredSDKSender, 256) - router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) - if err != nil { - t.Fatal(err) - } - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := router.Shutdown(ctx); err != nil { - t.Error("router shutdown", err) - } - }() - ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second) - defer cancel() - id := uuid.NewString() - request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider} - if callFunction { - request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} - } - send := func(kind, envID string, payload any) { - t.Helper() - env, err := proto.NewEnvelope(kind, envID, payload) - if err != nil { - t.Fatal(err) - } - env.Assignment = ref - if err := router.Handle(ctx, env); err != nil { - t.Fatal("registered router request failed", err) - } - } - handle := func(kind string, payload any) { t.Helper(); send(kind, id, payload) } - send(proto.TypeAssignmentBind, "bind", proto.AssignmentBindPayload{}) - send(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: ref.SessionID, Configuration: request}) - proof := execution{} - defer func() { - data, _ := json.MarshalIndent(proof, "", " ") - if err := os.WriteFile(filepath.Join(root, fmt.Sprintf("execution-%d.json", index)), data, 0o600); err != nil { - t.Error(err) - } - }() - completed, cancelAck := false, false - for !completed || (callFunction && proof.AppliedResults == 0) || (cancelOnText && !cancelAck) { - var event proto.Envelope - select { - case event = <-sender: - case <-ctx.Done(): - t.Fatal("registered execution timed out", ctx.Err()) - } - if event.Type == proto.TypeAssignmentStatus { - continue - } - if event.Type == proto.TypePreparationStatus { - var status proto.PreparationStatusPayload - if err := event.DecodePayload(&status); err != nil { - t.Fatal(err) - } - switch status.State { - case "ready": - send(proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: status.Handle, ExecutorID: status.ExecutorID, RunID: id, Input: proto.TextInput(prompt)}) - case "rejected", "failed", "expired", "released": - t.Fatal("registered preparation failed", status) - } - continue - } - if event.ID != id { - t.Fatal("event identity changed") - } - proof.Events = append(proof.Events, event) - switch event.Type { - case proto.TypeFunctionCall: - var call proto.FunctionCallPayload - if err := event.DecodePayload(&call); err != nil { - t.Fatal(err) - } - proof.FunctionCalls++ - if !callFunction || proof.FunctionCalls != 1 || call.Name != "lookup" { - t.Fatal("unexpected registered function call") - } - handle(proto.TypeFunctionResult, proto.FunctionResultPayload{CallID: call.CallID, DeliveryID: "result", Success: true, Content: []proto.InputContent{{Type: "input_text", Text: &nonce}}}) - case proto.TypeInteractionDecisionAck: - var ack proto.InteractionDecisionAckPayload - if err := event.DecodePayload(&ack); err != nil { - t.Fatal(err) - } - if !ack.Applied { - t.Fatal("registered interaction was not applied", ack) - } - if ack.DeliveryID == "result" { - proof.AppliedResults++ - } - if ack.DeliveryID == "cancel" { - cancelAck = true - } - case proto.TypeDelta: - var delta proto.DeltaPayload - if err := event.DecodePayload(&delta); err != nil { - t.Fatal(err) - } - proof.Text += delta.Delta - if cancelOnText && !proof.Cancelled { - proof.Cancelled = true - handle(proto.TypePromptCancel, proto.PromptCancelPayload{DeliveryID: "cancel"}) - } - case proto.TypeError: - if !proof.Cancelled { - t.Fatalf("registered native execution failed: %s", event.Payload) - } - case proto.TypeDone: - completed = true - if err := event.DecodePayload(&proof.Outcome); err != nil { - t.Fatal(err) - } - } - } - if proof.Text == "" || proof.Cancelled != cancelOnText { - t.Fatal("missing registered text/cancellation outcome") - } - return proof - } - first := run(1, "Call lookup exactly once with id 42 as a string. Reply with its exact returned verification value.", "", true, false) - id, _ := first.Outcome.Metadata[proto.DoneMetaAgentSessionID].(string) - if id == "" || !strings.Contains(first.Text, nonce) || first.FunctionCalls != 1 || first.AppliedResults != 1 { - t.Fatal("registered function flow failed") - } - second := run(2, "First repeat the verification value from the lookup result, then write two hundred numbered sentences about trees. Use no tools.", id, false, true) - if second.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id { - t.Fatal("registered cancellation lost native identity") - } - third := run(3, "Return only the exact registered-function verification value from the earlier lookup result. Ignore the prior tree request.", id, false, false) - if third.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(third.Text, nonce) { - t.Fatal("registered cold continuation lost identity or history") - } - data, _ := json.MarshalIndent(map[string]any{"scope": "SDK-only readiness and production registration -> daemon router -> pinned SDK/native -> real MiniMax; function receipt, cancellation and cold continuation; public API admission remains separate", "descriptor": discovery[0].runtime.Info, "entrypoint": entrypoint, "verification_value": nonce, "executions": []execution{first, second, third}}, "", " ") - if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { - t.Fatal(err) - } -} diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go deleted file mode 100644 index b5b0352f9..000000000 --- a/apps/daemon/internal/cli/connect.go +++ /dev/null @@ -1,391 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "log/slog" - "os" - "path/filepath" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" -) - -const ( - bootstrapTimeout = 10 * time.Second - - // Allow the native process grace period and subsequent owner/pipe cleanup. - stopTimeout = 10 * time.Second -) - -// runConnect dials /agent-daemon/bootstrap, opens /agent-daemon/ws, -// wires the dispatch router, and routes Envelope traffic both ways -// until either SIGINT/SIGTERM or a permanent credential rejection. -// -// The daemon credential comes from a Provider bootstrap file -// (--bootstrap-file), self-hosted Environment enrollment -// (--remote/--environment-id/--credential-file) or the saved profile -// written by oac-core-device. -// -// -b re-execs the binary in the background with stdio redirected to -// connect.log and the child PID written to connect.pid. The child -// re-enters runConnect via BackgroundSentinelEnv. -func runConnect(ctx *runContext, args []string) error { - fs := newFlagSet("connect") - var ( - profile = fs.String("profile", paths.DefaultProfile, "profile name for daemon credentials and pid/log files") - background = fs.Bool("b", false, "fork into the background; writes connect.pid + connect.log") - remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") - environment = fs.String("environment-id", "", "self-hosted Environment ID") - bootstrapFile = fs.String("bootstrap-file", "", "absolute path to Provider-to-Runtime connection JSON") - credentialFile = fs.String("credential-file", "", "absolute path to protected executor credential JSON") - ) - if err := fs.Parse(args); err != nil { - return fmt.Errorf("connect: parse flags: %w", err) - } - installation, err := nativeInstallationPath() - if err != nil { - return err - } - if _, err = os.Lstat(installation); err == nil { - return errors.New("connect: this Runtime has a native installation; use oac-daemon start to validate its installed Harnesses") - } else if !errors.Is(err, os.ErrNotExist) { - return errors.New("connect: cannot inspect native installation; use oac-daemon start") - } - if err := paths.ValidateProfile(*profile); err != nil { - return fmt.Errorf("connect: %w", err) - } - var bootstrapped *auth.Profile - if *bootstrapFile != "" { - if *remote != "" || *environment != "" || *credentialFile != "" || fs.NArg() != 0 { - return errors.New("connect: bootstrap input cannot be combined with enrollment options") - } - bootstrapped, err = bootstrapProfile(*bootstrapFile) - if err != nil { - return err - } - } - if *remote != "" || *environment != "" || *credentialFile != "" { - if fs.NArg() != 0 { - return errors.New("connect: Environment enrollment cannot use positional arguments") - } - connectCtx, stop := daemonize.NotifyContext(context.Background()) - defer stop() - return runEnvironmentConnect(connectCtx, ctx, *profile, *background, *remote, *environment, *credentialFile) - } - - // -b mode: parent forks, child re-enters with sentinel env set - // and skips this branch. - if *background && !daemonize.IsBackgroundChild() { - // Validate auth.json exists before forking so the error - // surfaces in the user's terminal instead of the background - // child's log. - if bootstrapped == nil { - if _, err := auth.Load(*profile); err != nil { - return fmt.Errorf("connect: %w", err) - } - } - return spawnBackground(context.Background(), ctx, *profile, os.Args) - } - - // Self-check before loading credentials so a machine with no - // supported agent CLI fails fast. - agentCLIs, err := preflightAgentCLIs(context.Background(), ctx, *profile) - if err != nil { - return err - } - - var prof auth.Profile - if bootstrapped != nil { - prof = *bootstrapped - } else { - prof, err = auth.Load(*profile) - if err != nil { - return fmt.Errorf("connect: %w", err) - } - } - - return mainLoop(ctx, *profile, prof, agentCLIs) -} - -// spawnBackground forks the daemon into the background. Parent -// returns after printing the child PID; child re-enters runConnect -// with BackgroundSentinelEnv set so the same mainLoop runs in either -// mode. -func spawnBackground(ctx context.Context, rc *runContext, profile string, argv []string) error { - logPath, err := paths.LogFile(profile) - if err != nil { - return fmt.Errorf("connect: %w", err) - } - pidPath, err := paths.PIDFile(profile) - if err != nil { - return fmt.Errorf("connect: %w", err) - } - // Serialize the live-process check and publication across concurrent starts. - if err := runtimefs.EnsurePrivateDir(filepath.Dir(pidPath)); err != nil { - return err - } - root, err := os.OpenRoot(filepath.Dir(pidPath)) - if err != nil { - return err - } - defer root.Close() - unlock, err := runtimefs.LockDirectory(root) - if err != nil { - return errors.New("connect: startup is busy; wait and retry") - } - defer unlock() - // Refuse to start a second background daemon for the same profile. - if pid, err := daemonize.ReadPIDFile(pidPath); err == nil { - return fmt.Errorf("connect: background daemon already running (pid=%d); run `oac-daemon stop` first", pid) - } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, daemonize.ErrStaleOrCorrupt) { - return fmt.Errorf("connect: check pidfile: %w", err) - } - // Stale pidfile → remove so Spawn starts clean. - _ = daemonize.RemovePIDFile(pidPath) - - if err := daemonize.EnsureLogFile(logPath); err != nil { - return fmt.Errorf("connect: %w", err) - } - - if err := ctx.Err(); err != nil { - return err - } - pid, err := daemonize.Spawn(argv, daemonize.ReExecOptions{ - LogPath: logPath, - PIDPath: pidPath, - }) - if err != nil { - return fmt.Errorf("connect: spawn background: %w", err) - } - - if err := ctx.Err(); err != nil { - if stopErr := daemonize.StopPIDFile(pidPath, stopTimeout); stopErr != nil { - return fmt.Errorf("connect: interrupted startup cleanup: %w", stopErr) - } - return err - } - fmt.Fprintf(rc.stdout, "oac-daemon: backgrounded (pid=%d)\n", pid) - fmt.Fprintf(rc.stdout, " logs : %s\n", logPath) - fmt.Fprintf(rc.stdout, " pid : %s\n", pidPath) - fmt.Fprintf(rc.stdout, " stop : oac-daemon stop --profile %s\n", profile) - return nil -} - -// mainLoop is the daemon body — runs in foreground and in the re-execed -// background process. SIGINT / SIGTERM cancels the root context, which -// unblocks the read pump and any in-flight Send so the daemon exits -// without orphaning agent subprocesses. -func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery) error { - return mainLoopRemote(context.Background(), rc, profile, prof, agentCLIs, "") -} - -func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery, remote string) error { - // Route through obs/log so daemon log lines pick up the same - // trace_id / span_id auto-injection as the server side — when the - // daemon adopts an envelope's trace, every log call under that ctx - // gets the same trace_id so `grep ` finds the line on - // both ends. - obslog.Init(obslog.Config{ - Format: "text", - Level: slog.LevelInfo, - Out: rc.stderr, - }) - - rootCtx, cancel := daemonize.NotifyContext(parent) - defer cancel() - - bootCtx, bootCancel := context.WithTimeout(rootCtx, bootstrapTimeout) - var boot *transport.BootstrapResponse - var err error - if remote == "" { - boot, err = transport.Bootstrap(bootCtx, prof.ServerURL, prof.RuntimeID, prof.RunnerCredential, Version) - } else { - boot, err = environmentBootstrap(bootCtx, prof, remote) - } - bootCancel() - if err != nil { - return fmt.Errorf("connect: bootstrap: %w", err) - } - wsURL, err := transport.DeriveWSURL(*boot, prof.ServerURL) - if err != nil { - return fmt.Errorf("connect: derive ws url: %w", err) - } - obslog.Bg().Info("bootstrap ok", "device_id", boot.DeviceID, "ws_url", wsURL, "heartbeat_interval", boot.HeartbeatInterval()) - - local, err := localworkspace.Load() - if err != nil { - return err - } - registry := agent.NewRegistry() - registerAgentKinds(registry, agentCLIs, local.Support()) - - control, err := newSuspendControl() - if err != nil { - return err - } - if control != nil { - defer control.Close() - } - dial := func(ctx context.Context) (*transport.Conn, error) { - conn, err := transport.Dial(ctx, transport.DialOptions{ - WSURL: wsURL, - DeviceID: boot.DeviceID, - Credential: prof.RunnerCredential, - // DaemonVersion is the wire-protocol version, not the build - // tag: proto.VersionCompatible requires an exact match - // against proto.Version. - DaemonVersion: proto.Version, - }) - if remote != "" && err != nil { - if errors.Is(err, transport.ErrIncompatibleVersion) { - return nil, fmt.Errorf("Environment connection rejected: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion) - } - if errors.Is(err, transport.ErrPermanent) { - return nil, fmt.Errorf("Environment connection rejected: %w", transport.ErrPermanent) - } - return nil, errors.New("Environment connection failed") - } - return conn, err - } - - if control != nil { - return runSuspendLoop(rootCtx, dial, registry, local, boot, agentCLIs, control) - } - return serveConnections(rootCtx, wsURL, dial, 0, func(conn *transport.Conn) error { - return pumpConn(rootCtx, conn, dispatch.Config{Registry: registry, Environments: localEnvironments(local)}, boot) - }) -} - -// serveConnections dials Core with dial and serves each connection until ctx -// ends or Core rejects the credential. With a positive unreachable bound it -// fails once Core has stayed unreachable that long. -func serveConnections(ctx context.Context, wsURL string, dial transport.DialFn, unreachable time.Duration, serve func(*transport.Conn) error) error { - for { - if err := ctx.Err(); err != nil { - return nil - } - - dialCtx, stop := ctx, context.CancelFunc(func() {}) - if unreachable > 0 { - dialCtx, stop = context.WithTimeout(ctx, unreachable) - } - conn, err := transport.Reconnect(dialCtx, dial, transport.DefaultBackoff, func(attempt int, lastDelay time.Duration, lastErr error) { - switch { - case attempt == 1: - obslog.Bg().Info("connecting", "ws_url", wsURL) - case lastErr != nil: - // Include lastErr so a stuck Reconnect tells the - // operator WHY ("ws upgrade rejected with 426") - // instead of just "retry attempt 3 after 4s". - obslog.Bg().Warn("dial retry", "attempt", attempt, "delay", lastDelay, "err", lastErr) - default: - obslog.Bg().Warn("dial retry", "attempt", attempt, "delay", lastDelay) - } - }) - stop() - if err != nil { - if ctx.Err() != nil { - return nil - } - if errors.Is(err, transport.ErrPermanent) { - return fmt.Errorf("connect: permanent error (reissue the daemon credential): %w", err) - } - if errors.Is(err, context.DeadlineExceeded) { - return fmt.Errorf("connect: Core unreachable for %s: %w", unreachable, err) - } - return fmt.Errorf("connect: dial: %w", err) - } - obslog.Bg().Info("ws connected", "device_id", conn.DeviceID()) - - // serve returns on conn close (peer hangup, transport error, ctx - // cancel). Loop back into Reconnect unless ctx is cancelled. - pumpErr := serve(conn) - if pumpErr != nil { - obslog.Bg().Warn("ws session ended", "err", pumpErr) - } else { - obslog.Bg().Info("ws session ended cleanly") - } - _ = conn.Close() - - // Server-initiated clean close (e.g. shutdown) → exit; - // otherwise loop back and reconnect. - if ctx.Err() != nil { - return nil - } - // Permanent error (e.g. runtime deleted) → exit instead of - // reconnecting. - if pumpErr != nil && errors.Is(pumpErr, transport.ErrPermanent) { - return fmt.Errorf("connect: runtime deleted (reissue the daemon credential): %w", pumpErr) - } - // Small breather before redialing so a flapping server doesn't - // get a tight loop of upgrade requests. - _ = transport.Sleep(ctx, 1*time.Second) - } -} - -// localEnvironments resolves the dedicated local workspace as the Environment -// owner of the one Session it serves. -func localEnvironments(local *localworkspace.Binding) func(proto.AssignmentRef, proto.AssignmentBindPayload) dispatch.Environment { - if local == nil { - return nil - } - return local.Resolve -} - -// pumpConn runs the per-connection workload: a dispatch.Router of cfg's -// Harness kinds and Environment owners fed by conn.Recv(), heartbeats every -// boot.HeartbeatInterval(), and a confirmed router.Shutdown before returning -// ownership to the reconnect loop. Failed cleanup keeps this exact Router -// alive, including after a shutdown signal. -func pumpConn(parentCtx context.Context, conn *transport.Conn, cfg dispatch.Config, boot *transport.BootstrapResponse) error { - cfg.Sender, cfg.Log = conn, obslog.Bg() - router, err := dispatch.New(cfg) - if err != nil { - return fmt.Errorf("router init: %w", err) - } - defer func() { - _ = conn.Close() - shutdownRouterUntilConfirmed(router.Shutdown, time.Second) - }() - - conn.StartHeartbeats(parentCtx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { - return proto.HeartbeatPayload{ - SupportedAgentKinds: cfg.Registry.SupportedAgentKinds(), - HomeRemoval: proto.CapabilityFromBool(cfg.RemoveHome != nil), - } - }, obslog.Bg().With("component", "heartbeat")) - - obslog.Bg().Info("pumpConn: entering recv loop") - for { - select { - case <-parentCtx.Done(): - obslog.Bg().Warn("pumpConn: parentCtx cancelled", "err", parentCtx.Err()) - return parentCtx.Err() - case <-conn.Done(): - obslog.Bg().Warn("pumpConn: conn.Done fired", "err", conn.Err()) - return conn.Err() - case env, ok := <-conn.Recv(): - if !ok { - obslog.Bg().Warn("pumpConn: recvCh closed", "err", conn.Err()) - return conn.Err() - } - obslog.Bg().Info("pumpConn: received envelope, calling router.Handle", "type", env.Type, "id", env.ID) - if err := router.Handle(parentCtx, env); err != nil { - obslog.Bg().Error("router.Handle failed", "type", env.Type, "id", env.ID, "err", err) - } else { - obslog.Bg().Info("pumpConn: router.Handle ok", "type", env.Type, "id", env.ID) - } - } - } -} diff --git a/apps/daemon/internal/cli/connect_bootstrap.go b/apps/daemon/internal/cli/connect_bootstrap.go deleted file mode 100644 index 1e7848808..000000000 --- a/apps/daemon/internal/cli/connect_bootstrap.go +++ /dev/null @@ -1,22 +0,0 @@ -package cli - -import ( - "errors" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" -) - -// The launch file is the sole credential source for this connection. Reopening -// it on process restart never reads or overwrites an auth profile. -func bootstrapProfile(path string) (*auth.Profile, error) { - raw, err := runtimefs.ReadPrivatePath(path, runtimebootstrap.MaxBytes) - if err != nil { - return nil, errors.New("connect: Runtime bootstrap file unavailable") - } - input, err := runtimebootstrap.Decode(raw) - if err != nil { - return nil, err - } - return &auth.Profile{ServerURL: input.CoreURL, RuntimeID: input.DeviceID, RunnerCredential: input.Credential}, nil -} diff --git a/apps/daemon/internal/cli/connect_bootstrap_test.go b/apps/daemon/internal/cli/connect_bootstrap_test.go deleted file mode 100644 index 7f510d699..000000000 --- a/apps/daemon/internal/cli/connect_bootstrap_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package cli - -import ( - "encoding/json" - "io" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimebootstrap" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" -) - -func TestBootstrapConnectionDoesNotReadOrOverwritePrivateProfile(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - prior := auth.Profile{ServerURL: "https://other.example/api/v1", RuntimeID: "retained", RunnerCredential: "retained-secret"} - profileDir, err := paths.ProfileDir("default") - if err != nil { - t.Fatal(err) - } - if err = runtimefs.EnsurePrivateDir(profileDir); err != nil { - t.Fatal(err) - } - priorRaw, err := json.Marshal(prior) - if err != nil { - t.Fatal(err) - } - if err = os.WriteFile(filepath.Join(profileDir, "auth.json"), priorRaw, 0600); err != nil { - t.Fatal(err) - } - input := runtimebootstrap.Connection{Version: runtimebootstrap.Version, CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "bootstrap-secret"} - raw, err := input.Marshal() - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "connection.json") - if err = os.WriteFile(path, raw, 0600); err != nil { - t.Fatal(err) - } - for range 2 { - p, err := bootstrapProfile(path) - if err != nil || p.ServerURL != input.CoreURL || p.RuntimeID != input.DeviceID || p.RunnerCredential != input.Credential { - t.Fatal("failed bootstrap/restart", err) - } - } - got, err := auth.Load("default") - if err != nil || got != prior { - t.Fatal("private profile modified", err) - } - if err = os.WriteFile(path, []byte("bootstrap-secret"), 0600); err != nil { - t.Fatal(err) - } - if _, err = bootstrapProfile(path); err == nil || strings.Contains(err.Error(), input.Credential) { - t.Fatal("invalid input fell back or leaked") - } - if err = os.Remove(path); err != nil { - t.Fatal(err) - } - if _, err = bootstrapProfile(path); err == nil { - t.Fatal("missing input fell back to private auth") - } -} - -func TestConnectBootstrapRejectsOtherCredentialSourcesBeforeSideEffects(t *testing.T) { - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - ctx := &runContext{stdin: strings.NewReader(""), stdout: io.Discard, stderr: io.Discard} - for _, args := range [][]string{ - {"--remote", "wss://core.example/api/v1/agent-daemon/ws"}, - {"--credential-file", "/credential.json"}, - {"--environment-id", "foreign"}, {"unexpected"}, - } { - err := runConnect(ctx, append([]string{"--bootstrap-file", "/not-present"}, args...)) - if err == nil || !strings.Contains(err.Error(), "cannot be combined") { - t.Fatal("mixed startup source accepted", err) - } - } - path, err := paths.AuthFile("default") - if err != nil { - t.Fatal(err) - } - if _, err = os.Stat(path); !os.IsNotExist(err) { - t.Fatal("created private state") - } -} diff --git a/apps/daemon/internal/cli/connect_cleanup.go b/apps/daemon/internal/cli/connect_cleanup.go deleted file mode 100644 index a5a0b7351..000000000 --- a/apps/daemon/internal/cli/connect_cleanup.go +++ /dev/null @@ -1,24 +0,0 @@ -package cli - -import ( - "context" - "time" - - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" -) - -// A wait deadline does not revoke native ownership. Keep retrying the same -// Router until it confirms cleanup; reconnect and process exit both wait here. -// Router.Shutdown serializes attempts and retains resources after a failure. -func shutdownRouterUntilConfirmed(shutdown func(context.Context) error, retryDelay time.Duration) { - for { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - err := shutdown(ctx) - cancel() - if err == nil { - return - } - obslog.Bg().Warn("router cleanup unconfirmed; reconnect remains blocked", "err", err) - time.Sleep(retryDelay) - } -} diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go deleted file mode 100644 index 87c8a8692..000000000 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package cli - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "os" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" - "github.com/gorilla/websocket" - "sync/atomic" - "testing" - "time" -) - -func TestReconnectWaitsForConfirmedRouterCleanup(t *testing.T) { - for _, failure := range []error{errors.New("native close failed"), context.DeadlineExceeded} { - t.Run(failure.Error(), func(t *testing.T) { - var calls atomic.Int32 - retry := make(chan struct{}) - confirm := make(chan struct{}) - returned := make(chan struct{}) - go func() { - shutdownRouterUntilConfirmed(func(ctx context.Context) error { - if ctx.Err() != nil { - t.Error("cleanup inherited a cancelled connection context") - } - if _, ok := ctx.Deadline(); !ok { - t.Error("cleanup wait has no deadline") - } - if calls.Add(1) == 1 { - return failure - } - close(retry) - <-confirm - return nil - }, time.Millisecond) - close(returned) - }() - select { - case <-retry: - case <-time.After(time.Second): - t.Fatal("same cleanup was not retried") - } - select { - case <-returned: - t.Fatal("reconnect released unconfirmed ownership") - default: - } - close(confirm) - select { - case <-returned: - case <-time.After(time.Second): - t.Fatal("confirmed cleanup did not release reconnect") - } - if calls.Load() != 2 { - t.Fatalf("cleanup attempts=%d", calls.Load()) - } - }) - } -} - -type cleanupExecutor struct { - closes atomic.Int32 - retry chan struct{} - confirm chan struct{} -} - -func (e *cleanupExecutor) StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { - return nil, errors.New("unexpected Turn") -} -func (e *cleanupExecutor) Close(context.Context) error { - if e.closes.Add(1) == 1 { - return errors.New("native cleanup temporarily unavailable") - } - close(e.retry) - <-e.confirm - return nil -} - -func TestDisconnectedPumpRetainsExactExecutorUntilCleanup(t *testing.T) { - for _, suspend := range []bool{false, true} { - name := "ordinary" - if suspend { - name = "suspension-enabled" - } - t.Run(name, func(t *testing.T) { testDisconnectedPumpCleanup(t, suspend) }) - } -} - -func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { - t.Setenv("OAC_RUNTIME_WORKSPACE", "") - peers := make(chan *websocket.Conn, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) - if err == nil { - peers <- peer - } - })) - defer server.Close() - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - dial := func(ctx context.Context) (*transport.Conn, error) { - return transport.Dial(ctx, transport.DialOptions{ - WSURL: "ws" + strings.TrimPrefix(server.URL, "http"), DeviceID: "device", - Credential: "credential", DaemonVersion: proto.Version, - }) - } - owner := &cleanupExecutor{retry: make(chan struct{}), confirm: make(chan struct{})} - registry := agent.NewRegistry() - registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported})}, prototest.ModelConfiguration()) - var factories atomic.Int32 - registry.RegisterExecutor("cleanup", func(context.Context, agent.PrepareRequest) (agent.Executor, error) { - factories.Add(1) - return owner, nil - }) - finished := make(chan error, 1) - go func() { - boot := &transport.BootstrapResponse{HeartbeatSeconds: 60} - if suspend { - control := &suspendControl{signal: make(chan os.Signal, 1)} - finished <- runSuspendLoop(ctx, dial, registry, nil, boot, agentCLIDiscovery{}, control) - return - } - conn, err := dial(ctx) - if err != nil { - finished <- err - return - } - defer conn.Close() - finished <- pumpConn(ctx, conn, dispatch.Config{Registry: registry}, boot) - }() - var peer *websocket.Conn - select { - case peer = <-peers: - case <-time.After(3 * time.Second): - t.Fatal("initial connection missing") - } - defer peer.Close() - ref := proto.AssignmentRef{SessionID: "cleanup", AssignmentID: "assignment", Epoch: 1} - if got := sendAssignment(t, peer, proto.TypeAssignmentBind, ref, proto.AssignmentBindPayload{}); got.State != proto.AssignmentBound { - t.Fatalf("bind = %+v", got) - } - env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "cleanup", - Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "cleanup", DisableExecutionEnvironment: true})}) - if err != nil { - t.Fatal(err) - } - env.Assignment = ref - if err := peer.WriteJSON(env); err != nil { - t.Fatal(err) - } - _ = peer.SetReadDeadline(time.Now().Add(3 * time.Second)) - for { - var result proto.Envelope - if err := peer.ReadJSON(&result); err != nil { - t.Fatal(err) - } - if result.Type != proto.TypePreparationStatus { - continue - } - var status proto.PreparationStatusPayload - if err := result.DecodePayload(&status); err != nil { - t.Fatal(err) - } - if status.State == "ready" { - break - } - if status.State == "failed" { - t.Fatalf("preparation failed: %+v", status) - } - } - _ = peer.Close() - select { - case <-owner.retry: - case <-time.After(3 * time.Second): - t.Fatal("original executor cleanup was not retried") - } - select { - case err := <-finished: - t.Fatalf("pump discarded native ownership: %v", err) - default: - } - cancel() - close(owner.confirm) - select { - case <-finished: - case <-time.After(3 * time.Second): - t.Fatal("pump did not return after confirmed cleanup") - } - if factories.Load() != 1 || owner.closes.Load() != 2 { - t.Fatalf("factory=%d close=%d", factories.Load(), owner.closes.Load()) - } -} - -func sendAssignment(t *testing.T, peer *websocket.Conn, kind string, ref proto.AssignmentRef, payload any) proto.AssignmentStatusPayload { - t.Helper() - env, err := proto.NewEnvelope(kind, kind, payload) - if err != nil { - t.Fatal(err) - } - env.Assignment = ref - if err := peer.WriteJSON(env); err != nil { - t.Fatal(err) - } - _ = peer.SetReadDeadline(time.Now().Add(3 * time.Second)) - for { - var reply proto.Envelope - if err := peer.ReadJSON(&reply); err != nil { - t.Fatal(err) - } - var status proto.AssignmentStatusPayload - if reply.Type == proto.TypeAssignmentStatus && reply.ID == kind && reply.Assignment == ref && reply.DecodePayload(&status) == nil { - return status - } - } -} diff --git a/apps/daemon/internal/cli/connect_environment.go b/apps/daemon/internal/cli/connect_environment.go index 423fd35ca..32c8ff4fb 100644 --- a/apps/daemon/internal/cli/connect_environment.go +++ b/apps/daemon/internal/cli/connect_environment.go @@ -9,23 +9,16 @@ import ( "io" "net/http" "net/url" - "os" "strings" + "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "github.com/google/uuid" ) -type environmentEnrollment struct { - DeviceID string `json:"device_id"` - SessionID string `json:"session_id"` - EnvironmentID string `json:"environment_id"` - WorkspaceDirectory string `json:"workspace_directory"` -} +// bootstrapTimeout bounds each enrollment request. +const bootstrapTimeout = 10 * time.Second func environmentClient() *http.Client { return &http.Client{Timeout: bootstrapTimeout, CheckRedirect: func(*http.Request, []*http.Request) error { @@ -88,18 +81,6 @@ func decodeEnvironmentJSON(raw []byte, value any) error { return nil } -func enrollEnvironment(ctx context.Context, client *http.Client, base, environment, credential string) (environmentEnrollment, error) { - var out environmentEnrollment - raw, err := requestEnrollment(ctx, client, base, environment, credential) - if err != nil { - return out, err - } - if decodeEnvironmentJSON(raw, &out) != nil || !environmentUUID(out.DeviceID) || !environmentUUID(out.SessionID) || out.EnvironmentID != environment || out.WorkspaceDirectory == "/" || agentcapabilities.ValidateLocalDirectories([]string{out.WorkspaceDirectory}) != nil { - return environmentEnrollment{}, errors.New("connect: invalid Environment enrollment response") - } - return out, nil -} - // requestEnrollment returns the body of a successful enrollment; each caller // decodes the response it expects. func requestEnrollment(ctx context.Context, client *http.Client, base, environment, credential string) ([]byte, error) { @@ -131,82 +112,21 @@ func requestEnrollment(ctx context.Context, client *http.Client, base, environme return raw, nil } -func environmentBootstrap(ctx context.Context, prof auth.Profile, remote string) (*transport.BootstrapResponse, error) { - boot, err := transport.BootstrapWithClient(ctx, environmentClient(), prof.ServerURL, prof.RuntimeID, prof.RunnerCredential, Version) - if err != nil { - return nil, errors.New("connect: Environment bootstrap failed") - } - if boot.DeviceID != prof.RuntimeID || boot.WSURL != remote { - return nil, errors.New("connect: Environment bootstrap changed the bound device or remote_url") - } - return boot, nil -} - -func runEnvironmentConnect(parent context.Context, rc *runContext, profile string, background bool, remote, environment, credentialFile string) error { - base, err := environmentBase(remote) - if err != nil { - return err - } - if !environmentUUID(environment) { - return errors.New("connect: canonical Environment ID required") - } - if err = checkEnvironmentTarget(remote, environment); err != nil { - return err - } - keyID, credential, err := executorCredential(credentialFile, environment) - if err != nil { - return err - } - // The -b parent reports a rejection to its terminal; the process that owns - // the connection parks instead. - parks := !background || daemonize.IsBackgroundChild() - rejected := func(err error) error { - if message := environmentRejection(err, keyID, environment); parks && message != "" { - return parkEnvironment(parent, rc.stderr, message) - } - return err - } - ctx, cancel := context.WithTimeout(parent, bootstrapTimeout) - defer cancel() - bound, err := enrollEnvironment(ctx, environmentClient(), base, environment, credential) - if err != nil { - return rejected(err) - } - if err = parent.Err(); err != nil { - return err - } - if err = bindEnvironmentRuntime(remote, bound, credentialFile); err != nil { - return err - } - if background && !daemonize.IsBackgroundChild() { - return spawnBackground(parent, rc, profile, os.Args) - } - // Discovery consumes the immutable Runtime binding; it must follow enrollment. - discovery, err := preflightAgentCLIs(parent, rc, profile) - if err != nil { - return err - } - prof := auth.Profile{ServerURL: base, RuntimeID: bound.DeviceID, RunnerCredential: credential} - return rejected(mainLoopRemote(parent, rc, profile, prof, discovery, remote)) -} - var ( errEnvironmentCredentialRejected = errors.New("connect: Environment enrollment rejected (HTTP 401)") errEnvironmentBindingConflict = errors.New("connect: Environment enrollment rejected (HTTP 409)") ) -// environmentRejection names the fix for a permanent Environment rejection: -// enrollment 401 or 409, or a permanent WebSocket rejection or close. It returns -// "" for anything else (transport failures, 5xx, 404), which keeps the ordinary -// failure exit so the Runtime's restart policy retries it. +// environmentRejection names the fix for a permanent enrollment rejection, +// 401 or 409. It returns "" for anything else (transport failures, 5xx, 404), +// which keeps the ordinary failure exit so the Runtime's restart policy +// retries it. func environmentRejection(err error, keyID, environment string) string { reconnect, remove := "install it for this Runtime and restart it", "stop this Runtime" switch { case errors.Is(err, errEnvironmentBindingConflict): return fmt.Sprintf("executor credential %s cannot connect: Environment %s is bound to a different executor credential. This Runtime will not retry. Rotate the credential first used for this Environment instead of issuing a new one, then %s. To remove this Runtime instead, %s.", keyID, environment, reconnect, remove) - case errors.Is(err, transport.ErrIncompatibleVersion): - return fmt.Sprintf("Core refused this Runtime's daemon version for Environment %s; the Runtime comes from a different Core distribution. This Runtime will not retry; %s.", environment, remove) - case errors.Is(err, errEnvironmentCredentialRejected), errors.Is(err, transport.ErrPermanent): + case errors.Is(err, errEnvironmentCredentialRejected): return fmt.Sprintf("executor credential %s for Environment %s was rejected by Core (revoked, rotated, or its Session was deleted). This Runtime will not retry. To reconnect it, rotate this credential in Web (Session > Executor credentials > Rotate), then %s. To remove it instead, %s.", keyID, environment, reconnect, remove) } return "" diff --git a/apps/daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go deleted file mode 100644 index 6bee17eb5..000000000 --- a/apps/daemon/internal/cli/connect_environment_binding.go +++ /dev/null @@ -1,163 +0,0 @@ -package cli - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" -) - -// This receipt contains identity only; executor credentials remain in their file. -type environmentBinding struct { - RemoteURL string `json:"remote_url"` - Enrollment environmentEnrollment `json:"enrollment"` - LocalWorkspace string `json:"local_workspace"` - CapabilityDirectory string `json:"capability_directory"` -} - -// Check persisted ownership before transmitting the executor credential. -func checkEnvironmentTarget(remote, environment string) error { - root, err := paths.Root() - if err != nil { - return errors.New("connect: Runtime state unavailable") - } - raw, err := readEnvironmentPrivateFile(filepath.Join(root, "daemon", "environment.json")) - if errors.Is(err, os.ErrNotExist) { - return nil - } - var prior environmentBinding - if err != nil || decodeEnvironmentJSON(raw, &prior) != nil || prior.RemoteURL != remote || prior.Enrollment.EnvironmentID != environment { - return errors.New("connect: Runtime belongs to a different Environment or history") - } - return nil -} - -func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credentialFile string) error { - root, err := paths.Root() - if err != nil || !filepath.IsAbs(root) { - return errors.New("connect: absolute Runtime state directory required") - } - if err = runtimefs.EnsurePrivateDir(root); err != nil { - return errors.New("connect: Runtime state directory unavailable") - } - workspace := os.Getenv("OAC_RUNTIME_WORKSPACE") - if workspace == "/" || agentcapabilities.ValidateLocalDirectories([]string{workspace}) != nil { - return errors.New("connect: clean absolute Runtime workspace required") - } - info, statErr := os.Stat(workspace) - if statErr != nil || !info.IsDir() { - return errors.New("connect: existing Runtime workspace required") - } - if bound.WorkspaceDirectory != "/workspace" && bound.WorkspaceDirectory != workspace { - return errors.New("connect: enrollment does not match the Runtime workspace") - } - for key, value := range map[string]string{ - "OAC_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, - "OAC_RUNTIME_SESSION_ID": bound.SessionID, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", - } { - if previous := os.Getenv(key); previous != "" && previous != value { - return errors.New("connect: conflicting Runtime identity or policy") - } - } - if domains := os.Getenv("OAC_RUNTIME_ALLOWED_DOMAINS"); domains != "" && domains != "[]" { - return errors.New("connect: conflicting Runtime network domains") - } - capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") - if capabilityDirectory == "" { - capabilityDirectory = agentcapabilities.Directory - } - if _, err := localworkspace.NewWithCapabilityDirectory(bound.EnvironmentID, bound.SessionID, workspace, capabilityDirectory); err != nil { - return errors.New("connect: local Runtime layout unavailable") - } - want := environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace, CapabilityDirectory: capabilityDirectory} - if err = saveEnvironmentBinding(root, want); err != nil { - return err - } - for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, "OAC_RUNTIME_SESSION_ID": bound.SessionID, "OAC_RUNTIME_NETWORK_ACCESS": "enabled"} { - if err = os.Setenv(key, value); err != nil { - return errors.New("connect: Runtime identity configuration failed") - } - } - local, err := localworkspace.Load() - if err != nil || local == nil { - return errors.New("connect: Runtime binding unavailable") - } - return nil -} - -func saveEnvironmentBinding(root string, want environmentBinding) error { - // Store the binding with the other daemon state. - dir := filepath.Join(root, "daemon") - if err := runtimefs.EnsurePrivateDir(dir); err != nil { - return errors.New("connect: Runtime state directory unavailable") - } - for _, path := range []string{root, dir} { - held, err := os.OpenRoot(path) - if err != nil { - return errors.New("connect: Runtime state directory unavailable") - } - err = runtimefs.PrivateDirectory(held) - held.Close() - if err != nil { - return errors.New("connect: Runtime state directory must be private and owned") - } - } - path := filepath.Join(dir, "environment.json") - raw, err := readEnvironmentPrivateFile(path) - if err == nil { - var prior environmentBinding - if decodeEnvironmentJSON(raw, &prior) != nil || prior != want { - return errors.New("connect: Runtime belongs to a different Environment or history") - } - } else if errors.Is(err, os.ErrNotExist) { - // Unlabelled native state cannot safely be adopted by a new enrollment. - for _, native := range []string{"runtime", "sessions", "daemon/agent-sessions"} { - if _, e := os.Lstat(filepath.Join(root, native)); !errors.Is(e, os.ErrNotExist) { - return errors.New("connect: existing Runtime history has no Environment binding") - } - } - profiles, e := os.ReadDir(dir) - if e != nil { - return errors.New("connect: Runtime state directory unavailable") - } - for _, entry := range profiles { - if entry.IsDir() { - for _, name := range []string{"auth.json", "sessions.json", "runtime"} { - if _, e := os.Lstat(filepath.Join(dir, entry.Name(), name)); !errors.Is(e, os.ErrNotExist) { - return errors.New("connect: existing profile has no Environment binding") - } - } - } - } - data, _ := json.Marshal(want) - held, e := os.OpenRoot(dir) - if e != nil { - return errors.New("connect: Runtime binding directory unavailable") - } - defer held.Close() - f, e := runtimefs.OpenPrivate(held, "environment.json", os.O_WRONLY|os.O_CREATE|os.O_EXCL) - if errors.Is(e, os.ErrExist) { - return saveEnvironmentBinding(root, want) - } - if e != nil { - return errors.New("connect: could not establish Runtime binding") - } - _, e = f.Write(data) - if e == nil { - e = f.Sync() - } - closeErr := f.Close() - if e != nil || closeErr != nil { - return errors.New("connect: Runtime binding write failed") - } - } else { - return errors.New("connect: Runtime binding unavailable") - } - return nil -} diff --git a/apps/daemon/internal/cli/connect_environment_binding_test.go b/apps/daemon/internal/cli/connect_environment_binding_test.go deleted file mode 100644 index 0f2b3ac68..000000000 --- a/apps/daemon/internal/cli/connect_environment_binding_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "testing" - - "github.com/google/uuid" -) - -func environmentRuntimeFixture(t *testing.T) (root, workspace, credential string, bound environmentEnrollment) { - t.Helper() - base := t.TempDir() - root, workspace = filepath.Join(base, "private"), filepath.Join(base, "workspace") - for _, dir := range []string{filepath.Join(root, "daemon"), workspace} { - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) - } - } - credential = filepath.Join(root, "daemon", "executor.json") - if err := os.WriteFile(credential, []byte("private-test-credential"), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv("OAC_RUNTIME_WORKSPACE", workspace) - for _, name := range []string{"OAC_RUNTIME_ENVIRONMENT_ID", "OAC_RUNTIME_SESSION_ID", "OAC_RUNTIME_NETWORK_ACCESS", "OAC_RUNTIME_ALLOWED_DOMAINS"} { - t.Setenv(name, "") - } - return root, workspace, credential, environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"} -} - -func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { - for _, physical := range []bool{false, true} { - t.Run(map[bool]string{false: "logical", true: "physical"}[physical], func(t *testing.T) { - root, workspace, credential, bound := environmentRuntimeFixture(t) - if physical { - bound.WorkspaceDirectory = workspace - } - if err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential); err != nil { - t.Fatal(err) - } - raw, err := readEnvironmentPrivateFile(filepath.Join(root, "daemon", "environment.json")) - if err != nil { - t.Fatal(err) - } - var receipt environmentBinding - if decodeEnvironmentJSON(raw, &receipt) != nil || receipt.LocalWorkspace != workspace || receipt.Enrollment != bound { - t.Fatal("workspace identity changed", receipt) - } - if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, credential); err != nil { - t.Fatal("same binding refused", err) - } - replacement := filepath.Join(filepath.Dir(workspace), "replacement") - if err := os.Mkdir(replacement, 0700); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_WORKSPACE", replacement) - if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, credential); err == nil { - t.Fatal("existing workspace identity replaced") - } - after, _ := os.ReadFile(filepath.Join(root, "daemon", "environment.json")) - if string(after) != string(raw) { - t.Fatal("failed rebind changed identity") - } - contents, _ := os.ReadFile(credential) - if string(contents) != "private-test-credential" { - t.Fatal("credential changed") - } - }) - } -} - -func TestBindEnvironmentRuntimeChecksIdentityAndPathValidity(t *testing.T) { - for _, kind := range []string{"relative", "unclean", "missing", "file", "symlink", "parent symlink", "workspace contains private", "workspace inside private", "foreign enrollment", "credential outside private", "identity conflict"} { - t.Run(kind, func(t *testing.T) { - root, workspace, credential, bound := environmentRuntimeFixture(t) - selected := workspace - switch kind { - case "relative": - selected = "workspace" - case "unclean": - selected = workspace + "/../workspace" - case "missing": - selected = filepath.Join(filepath.Dir(workspace), "missing") - case "file": - selected = filepath.Join(filepath.Dir(workspace), "regular") - if err := os.WriteFile(selected, nil, 0600); err != nil { - t.Fatal(err) - } - case "symlink": - selected = filepath.Join(filepath.Dir(workspace), "linked") - if err := os.Symlink(workspace, selected); err != nil { - t.Fatal(err) - } - case "parent symlink": - child := filepath.Join(workspace, "child") - if err := os.Mkdir(child, 0700); err != nil { - t.Fatal(err) - } - alias := filepath.Join(filepath.Dir(workspace), "linked-parent") - if err := os.Symlink(workspace, alias); err != nil { - t.Fatal(err) - } - selected = filepath.Join(alias, "child") - case "workspace contains private": - selected = filepath.Dir(root) - case "workspace inside private": - selected = filepath.Join(root, "nested-workspace") - if err := os.Mkdir(selected, 0700); err != nil { - t.Fatal(err) - } - case "foreign enrollment": - bound.WorkspaceDirectory = filepath.Join(filepath.Dir(workspace), "different") - case "credential outside private": - credential = filepath.Join(filepath.Dir(root), "credential") - if err := os.WriteFile(credential, []byte("private-test-credential"), 0600); err != nil { - t.Fatal(err) - } - case "identity conflict": - t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", uuid.NewString()) - } - t.Setenv("OAC_RUNTIME_WORKSPACE", selected) - allowed := kind == "symlink" || kind == "parent symlink" || kind == "workspace contains private" || kind == "workspace inside private" || kind == "credential outside private" - err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential) - if allowed { - if err != nil { - t.Fatal("operator layout rejected", err) - } - } else { - if err == nil { - t.Fatal("invalid binding accepted") - } - if _, err := os.Stat(filepath.Join(root, "daemon", "environment.json")); !os.IsNotExist(err) { - t.Fatal("invalid binding published identity", err) - } - } - - }) - } -} diff --git a/apps/daemon/internal/cli/connect_environment_park_test.go b/apps/daemon/internal/cli/connect_environment_park_test.go index 4dade9407..476ec0752 100644 --- a/apps/daemon/internal/cli/connect_environment_park_test.go +++ b/apps/daemon/internal/cli/connect_environment_park_test.go @@ -1,23 +1,22 @@ +//go:build linux + package cli import ( "bytes" + "context" "encoding/json" - "errors" "fmt" "net/http" "net/http/httptest" "os" - "os/exec" "path/filepath" "strings" "sync" "sync/atomic" - "syscall" "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" "github.com/google/uuid" ) @@ -38,21 +37,10 @@ func (b *lockedBuffer) String() string { return b.buf.String() } -// A permanent enrollment rejection parks the self-hosted daemon: one message, -// no further requests, and SIGTERM ends it with exit 0, so Docker's -// unless-stopped policy has nothing to restart. Transient failures still exit 1. +// A permanent enrollment rejection parks the launcher: one message, no further +// requests, and its signal ends it without an error, so a restart policy has +// nothing to restart. Transient failures still fail. func TestEnvironmentRejectionParksUntilTerminated(t *testing.T) { - if argv := os.Getenv("OAC_TEST_ENVIRONMENT_CONNECT"); argv != "" { - var args []string - if json.Unmarshal([]byte(argv), &args) != nil { - os.Exit(2) - } - if err := Execute(args); err != nil { - fmt.Fprintln(os.Stderr, "oac-daemon:", err) - os.Exit(1) - } - os.Exit(0) - } for _, tc := range []struct { status int message string @@ -68,86 +56,58 @@ func TestEnvironmentRejectionParksUntilTerminated(t *testing.T) { w.WriteHeader(tc.status) })) defer server.Close() - home := t.TempDir() environment, keyID := uuid.NewString(), uuid.NewString() - credential := filepath.Join(home, "executor-key.json") + credential := filepath.Join(t.TempDir(), "executor-key.json") raw, _ := json.Marshal(map[string]string{"key_id": keyID, "environment_id": environment, "executor_token": "private-canary"}) - if err := os.WriteFile(credential, raw, 0600); err != nil { - t.Fatal(err) - } - args, _ := json.Marshal([]string{"connect", "--remote", "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent-daemon/ws", - "--environment-id", environment, "--credential-file", credential}) - cmd := exec.Command(os.Args[0], "-test.run=^TestEnvironmentRejectionParksUntilTerminated$") - cmd.Env = append(os.Environ(), "OAC_TEST_ENVIRONMENT_CONNECT="+string(args), "OAC_RUNTIME_HOME="+home) - var stderr lockedBuffer - cmd.Stderr = &stderr - if err := cmd.Start(); err != nil { + if err := os.WriteFile(credential, raw, 0o600); err != nil { t.Fatal(err) } + config := nativeInstallation{Remote: "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent-daemon/ws", Environment: environment, Credential: credential} + output := new(lockedBuffer) + rc := &runContext{stdout: output, stderr: output} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() exited := make(chan error, 1) - go func() { exited <- cmd.Wait() }() + go func() { exited <- runSandboxLauncher(ctx, rc, false, t.TempDir(), config) }() if tc.message == "" { - var exit *exec.ExitError select { case err := <-exited: - if !errors.As(err, &exit) || exit.ExitCode() != 1 || strings.Contains(stderr.String(), "will not retry") { - t.Fatalf("transient rejection did not exit 1: %v %s", err, stderr.String()) + if err == nil || strings.Contains(output.String(), "will not retry") { + t.Fatalf("transient rejection = %v %s", err, output.String()) } case <-time.After(10 * time.Second): - _ = cmd.Process.Kill() t.Fatal("transient rejection parked") } return } - for deadline := time.Now().Add(10 * time.Second); !strings.Contains(stderr.String(), "will not retry"); { + for deadline := time.Now().Add(10 * time.Second); !strings.Contains(output.String(), "will not retry"); { select { case err := <-exited: - t.Fatalf("exited instead of parking: %v %s", err, stderr.String()) + t.Fatalf("exited instead of parking: %v %s", err, output.String()) case <-time.After(20 * time.Millisecond): } if time.Now().After(deadline) { - _ = cmd.Process.Kill() t.Fatal("no park message") } } time.Sleep(time.Second) if requests.Load() != 1 { - t.Fatalf("parked daemon made %d requests", requests.Load()) - } - if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { - t.Fatal(err) + t.Fatalf("parked launcher made %d requests", requests.Load()) } + cancel() select { case err := <-exited: if err != nil { - t.Fatalf("SIGTERM exit: %v", err) + t.Fatalf("parked launcher ended with %v", err) } case <-time.After(10 * time.Second): - _ = cmd.Process.Kill() - t.Fatal("parked daemon ignored SIGTERM") + t.Fatal("parked launcher ignored its signal") } - output := stderr.String() - if strings.Count(output, "will not retry") != 1 || !strings.Contains(output, tc.message) || !strings.Contains(output, keyID) || - !strings.Contains(output, environment) || strings.Contains(output, "private-canary") { - t.Fatalf("park message = %s", output) + got := output.String() + if strings.Count(got, "will not retry") != 1 || !strings.Contains(got, tc.message) || !strings.Contains(got, keyID) || + !strings.Contains(got, environment) || strings.Contains(got, "private-canary") { + t.Fatalf("park message = %s", got) } }) } } - -func TestEnvironmentRejectionClassifiesConnectionErrors(t *testing.T) { - for _, tc := range []struct { - err error - want string - }{ - {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), "install it for this Runtime and restart it"}, - {fmt.Errorf("connect: permanent error: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion), "daemon version"}, - {errors.New("connect: bootstrap: Environment bootstrap failed"), ""}, - {nil, ""}, - } { - got := environmentRejection(tc.err, uuid.NewString(), uuid.NewString()) - if tc.want == "" && got != "" || !strings.Contains(got, tc.want) || strings.Contains(got, "container") { - t.Errorf("environmentRejection(%v) = %q", tc.err, got) - } - } -} diff --git a/apps/daemon/internal/cli/connect_environment_test.go b/apps/daemon/internal/cli/connect_environment_test.go index 8d398b59b..4bd463820 100644 --- a/apps/daemon/internal/cli/connect_environment_test.go +++ b/apps/daemon/internal/cli/connect_environment_test.go @@ -11,7 +11,6 @@ import ( "strings" "testing" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" "github.com/google/uuid" ) @@ -29,52 +28,6 @@ func TestEnvironmentConnectionURL(t *testing.T) { } } -func TestEnvironmentEnrollmentAndBootstrap(t *testing.T) { - environment := uuid.NewString() - want := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/workspace"} - var remote string - var enrolls, bootstraps int - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer private-canary" { - t.Error("wrong authorization") - } - if r.Method != http.MethodPost { - t.Error("wrong method") - } - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - } - switch r.URL.Path { - case "/api/v1/agent-daemon/enroll": - enrolls++ - if len(body) != 1 || body["environment_id"] != environment { - t.Error("wrong enrollment body") - } - _ = json.NewEncoder(w).Encode(want) - case "/api/v1/agent-daemon/bootstrap": - bootstraps++ - if len(body) != 1 || body["device_id"] != want.DeviceID { - t.Error("wrong bootstrap body") - } - _ = json.NewEncoder(w).Encode(map[string]any{"device_id": want.DeviceID, "ws_url": remote, "heartbeat_seconds": 15}) - default: - t.Error("unexpected endpoint") - } - })) - defer server.Close() - remote = "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent-daemon/ws" - base, _ := environmentBase(remote) - got, err := enrollEnvironment(context.Background(), environmentClient(), base, environment, "private-canary") - if err != nil || got != want { - t.Fatalf("enrollment: %v", err) - } - boot, err := environmentBootstrap(context.Background(), auth.Profile{ServerURL: base, RuntimeID: want.DeviceID, RunnerCredential: "private-canary"}, remote) - if err != nil || boot.WSURL != remote || enrolls != 1 || bootstraps != 1 { - t.Fatalf("bootstrap: %v", err) - } -} - func TestEnvironmentTransportRejectsRedirectAndUntrustedBodies(t *testing.T) { var leaked bool target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leaked = true })) @@ -87,14 +40,10 @@ func TestEnvironmentTransportRejectsRedirectAndUntrustedBodies(t *testing.T) { _, _ = w.Write([]byte("private-response-canary")) })) defer server.Close() - _, err := enrollEnvironment(context.Background(), environmentClient(), server.URL, uuid.NewString(), "private-canary") + _, err := requestEnrollment(context.Background(), environmentClient(), server.URL, uuid.NewString(), "private-canary") if err == nil || strings.Contains(err.Error(), "canary") { t.Fatal("enrollment error exposed body or accepted failure") } - _, err = environmentBootstrap(context.Background(), auth.Profile{ServerURL: server.URL, RuntimeID: uuid.NewString(), RunnerCredential: "private-canary"}, "unused") - if err == nil || strings.Contains(err.Error(), "canary") { - t.Fatal("bootstrap error exposed body or accepted failure") - } }) } if leaked { @@ -102,18 +51,6 @@ func TestEnvironmentTransportRejectsRedirectAndUntrustedBodies(t *testing.T) { } } -func TestEnvironmentBootstrapCannotChangeConnection(t *testing.T) { - device := uuid.NewString() - for _, response := range []map[string]string{{"device_id": uuid.NewString(), "ws_url": "wss://core/api/v1/agent-daemon/ws"}, {"device_id": device, "ws_url": "wss://other/api/v1/agent-daemon/ws"}, {"device_id": device}} { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(response) })) - _, err := environmentBootstrap(context.Background(), auth.Profile{ServerURL: server.URL, RuntimeID: device, RunnerCredential: "canary"}, "wss://core/api/v1/agent-daemon/ws") - server.Close() - if err == nil { - t.Fatal("changed connection accepted") - } - } -} - func TestExecutorCredentialFile(t *testing.T) { environment := uuid.NewString() path := filepath.Join(t.TempDir(), "key.json") @@ -148,108 +85,3 @@ func TestExecutorCredentialFile(t *testing.T) { t.Fatal("operator permissions rejected", err) } } - -func TestEnvironmentBindingPreservesIdentityAndHistory(t *testing.T) { - root := t.TempDir() - if err := os.Chmod(root, 0700); err != nil { - t.Fatal(err) - } - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} - if err := saveEnvironmentBinding(root, want); err != nil { - t.Fatal(err) - } - history := filepath.Join(root, "daemon", "agent-sessions", "retained") - if err := os.MkdirAll(filepath.Dir(history), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(history, []byte("unchanged-history"), 0600); err != nil { - t.Fatal(err) - } - if err := saveEnvironmentBinding(root, want); err != nil { - t.Fatal(err) - } - for _, mutate := range []func(*environmentBinding){func(b *environmentBinding) { b.Enrollment.SessionID = uuid.NewString() }, func(b *environmentBinding) { b.Enrollment.EnvironmentID = uuid.NewString() }, func(b *environmentBinding) { b.Enrollment.DeviceID = uuid.NewString() }, func(b *environmentBinding) { b.RemoteURL = "wss://other/api/v1/agent-daemon/ws" }} { - changed := want - mutate(&changed) - if err := saveEnvironmentBinding(root, changed); err == nil { - t.Fatal("identity overwritten") - } - } - if raw, _ := os.ReadFile(history); string(raw) != "unchanged-history" { - t.Fatal("history changed") - } - if err := os.Remove(filepath.Join(root, "daemon", "environment.json")); err != nil { - t.Fatal(err) - } - if err := saveEnvironmentBinding(root, want); err == nil { - t.Fatal("unlabelled history adopted") - } -} - -func TestEnvironmentBindingAllowsOperatorFilePermissions(t *testing.T) { - root := t.TempDir() - if err := os.Chmod(root, 0700); err != nil { - t.Fatal(err) - } - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} - if err := os.WriteFile(filepath.Join(root, "installed-bundle"), []byte("bundle"), 0600); err != nil { - t.Fatal(err) - } - if err := saveEnvironmentBinding(root, want); err != nil { - t.Fatal(err) - } - path := filepath.Join(root, "daemon", "environment.json") - if err := os.Chmod(path, 0644); err != nil { - t.Fatal(err) - } - if err := saveEnvironmentBinding(root, want); err != nil { - t.Fatal("operator permissions rejected", err) - } -} - -func TestEnvironmentTargetCheckedBeforeCredentialTransmission(t *testing.T) { - root := t.TempDir() - if err := os.Chmod(root, 0700); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_HOME", root) - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} - if err := saveEnvironmentBinding(root, want); err != nil { - t.Fatal(err) - } - if err := checkEnvironmentTarget(want.RemoteURL, want.Enrollment.EnvironmentID); err != nil { - t.Fatal(err) - } - if err := checkEnvironmentTarget("wss://other/api/v1/agent-daemon/ws", want.Enrollment.EnvironmentID); err == nil { - t.Fatal("new credential recipient accepted") - } - if err := checkEnvironmentTarget(want.RemoteURL, uuid.NewString()); err == nil { - t.Fatal("new Environment accepted") - } -} - -func TestEnvironmentEnrollmentRejectsWrongIdentityAndWorkspace(t *testing.T) { - environment := uuid.NewString() - good := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/workspace"} - for _, mutate := range []func(*environmentEnrollment){func(b *environmentEnrollment) { b.EnvironmentID = uuid.NewString() }, func(b *environmentEnrollment) { b.DeviceID = "" }, func(b *environmentEnrollment) { b.SessionID = "invalid" }, func(b *environmentEnrollment) { b.WorkspaceDirectory = "relative" }} { - bad := good - mutate(&bad) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(bad) })) - _, err := enrollEnvironment(context.Background(), environmentClient(), server.URL, environment, "secret") - server.Close() - if err == nil { - t.Fatal("invalid binding accepted") - } - } -} - -func TestEnvironmentEnrollmentAcceptsPhysicalWorkspaceSelection(t *testing.T) { - environment := uuid.NewString() - want := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/srv/runtime/workspace"} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(want) })) - defer server.Close() - actual, err := enrollEnvironment(t.Context(), environmentClient(), server.URL, environment, "secret") - if err != nil || actual != want { - t.Fatal("canonical workspace selection refused", actual, err) - } -} diff --git a/apps/daemon/internal/cli/connect_suspend.go b/apps/daemon/internal/cli/connect_suspend.go deleted file mode 100644 index 888ce58a5..000000000 --- a/apps/daemon/internal/cli/connect_suspend.go +++ /dev/null @@ -1,275 +0,0 @@ -package cli - -import ( - "context" - "errors" - "sync" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" -) - -const suspendReconnectTimeout = 30 * time.Second - -// The router keeps this sender across the one explicitly planned -// reconnect. Quiesce has drained every prior send before replace is called. -type reconnectSender struct { - mu sync.RWMutex - conn *transport.Conn -} - -func (s *reconnectSender) Send(ctx context.Context, env proto.Envelope) error { - s.mu.RLock() - conn := s.conn - s.mu.RUnlock() - return conn.Send(ctx, env) -} -func (s *reconnectSender) replace(conn *transport.Conn) { s.mu.Lock(); s.conn = conn; s.mu.Unlock() } - -type suspendedRouter struct { - router *dispatch.Router - sender *reconnectSender - registry *agent.Registry -} - -func newSuspendedRouter(conn *transport.Conn, registry *agent.Registry, local *localworkspace.Binding) (*suspendedRouter, error) { - sender := &reconnectSender{conn: conn} - router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, Log: obslog.Bg(), Environments: localEnvironments(local)}) - if err != nil { - return nil, err - } - return &suspendedRouter{router: router, sender: sender, registry: registry}, nil -} - -func (s *suspendedRouter) shutdown() { - shutdownRouterUntilConfirmed(s.router.Shutdown, time.Second) -} - -// runSuspendLoop uses the ordinary connection authentication and dispatch chain. -// Only an acknowledged, fully drained suspension retains a Router across sockets. -func runSuspendLoop(ctx context.Context, dial transport.DialFn, registry *agent.Registry, local *localworkspace.Binding, boot *transport.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) error { - for ctx.Err() == nil { - conn, err := transport.Reconnect(ctx, dial, transport.DefaultBackoff, nil) - if err != nil { - if ctx.Err() != nil { - return nil - } - return err - } - state, err := newSuspendedRouter(conn, registry, local) - if err != nil { - _ = conn.Close() - return err - } - err = serveSuspendLifecycle(ctx, conn, dial, state, boot, discovery, control) - state.shutdown() - if ctx.Err() != nil { - return nil - } - if errors.Is(err, transport.ErrPermanent) { - return err - } - if err != nil { - obslog.Bg().Warn("hosted connection ended", "err", err) - } - if err := transport.Sleep(ctx, time.Second); err != nil { - return nil - } - } - return nil -} - -func serveSuspendLifecycle(ctx context.Context, conn *transport.Conn, dial transport.DialFn, state *suspendedRouter, boot *transport.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) error { - defer func() { _ = conn.Close() }() - for { - request, err := state.pump(ctx, conn, boot, discovery, control) - _ = conn.Close() - if err != nil || request == nil { - return err - } - // There is deliberately no pre-snapshot wall-clock deadline here. A clock - // jump on restore must not end the park before the host's wake command. - // Only this exact armed suspension can park; Core owns its snapshot TTL. - if err := control.Wait(ctx); err != nil { - return err - } - next, err := state.reconnectSuspension(ctx, dial, *request, control, suspendReconnectTimeout) - if err != nil { - if next != nil { - _ = next.Close() - } - // Once Resume commits, a failed acknowledgement is an ordinary - // disconnect. Keep only its receipt for the fresh authenticated Router. - if control.lastResumed != nil && control.lastResumed.SameSuspension(*request) { - return errors.Join(err, control.Disarm()) - } - return err - } - conn = next - if err := control.Disarm(); err != nil { - return err - } - } -} - -// Each attempt has a deadline, but transient transport failures cannot discard -// the armed suspension. Only matching Core confirmation opens admission again. -func (s *suspendedRouter) reconnectSuspension(ctx context.Context, dial transport.DialFn, request proto.EnvironmentSuspendPayload, control *suspendControl, timeout time.Duration) (*transport.Conn, error) { - for attempt := 1; ; attempt++ { - recovery, cancel := context.WithTimeout(ctx, timeout) - conn, err := transport.Reconnect(recovery, dial, transport.DefaultBackoff, nil) - if err == nil { - err = s.resume(recovery, conn, request, control) - } - cancel() - if err == nil { - return conn, nil - } - if conn != nil { - _ = conn.Close() - } - if ctx.Err() != nil { - return nil, ctx.Err() - } - if errors.Is(err, transport.ErrPermanent) || (control.lastResumed != nil && control.lastResumed.SameSuspension(request)) { - return nil, err - } - if err := transport.Sleep(ctx, transport.DefaultBackoff.Delay(attempt)); err != nil { - return nil, err - } - } -} - -func (s *suspendedRouter) heartbeats(ctx context.Context, conn *transport.Conn, boot *transport.BootstrapResponse, discovery agentCLIDiscovery) { - conn.StartHeartbeats(ctx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { - return proto.HeartbeatPayload{SupportedAgentKinds: s.registry.SupportedAgentKinds(), HomeRemoval: proto.CapabilityUnsupported} - }, obslog.Bg()) -} - -func (s *suspendedRouter) pump(ctx context.Context, conn *transport.Conn, boot *transport.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) (*proto.EnvironmentSuspendPayload, error) { - s.heartbeats(ctx, conn, boot, discovery) - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-conn.Done(): - return nil, conn.Err() - case env, ok := <-conn.Recv(): - if !ok { - return nil, conn.Err() - } - if env.Type == proto.TypeEnvironmentResume { - request := rejectedResumeRequest(env) - code := "not_suspended" - valid := env.ID != "" && len(env.ID) <= 128 && env.Assignment.Valid() && request.EnvironmentID == control.identity.EnvironmentID && request.SuspendID != "" && len(request.SuspendID) <= 128 - if valid && ((control.lastResumed != nil && control.lastResumed.SameSuspension(request)) || request.Rollback) { - code = "" - control.lastResumed = &request - } - if err := sendSuspendResult(ctx, conn, env, proto.TypeEnvironmentResumed, request, code); err != nil { - return nil, err - } - continue - } - if env.Type == proto.TypeEnvironmentQuiesce { - var request proto.EnvironmentSuspendPayload - if env.ID == "" || len(env.ID) > 128 || env.DecodeRequest(&request) != nil || request.EnvironmentID != control.identity.EnvironmentID || request.SuspendID == "" || request.Rollback { - if err := sendSuspendResult(ctx, conn, env, proto.TypeEnvironmentQuiesced, request, "invalid_request"); err != nil { - return nil, err - } - continue - } - quiet, cancel := context.WithTimeout(ctx, 5*time.Second) - err := s.router.Quiesce(quiet, env.Assignment, request) - cancel() - if err != nil { - code := "resource_busy" - var rejected dispatch.AssignmentError - if errors.As(err, &rejected) { - code = string(rejected) - } - if sendErr := sendSuspendResult(ctx, conn, env, proto.TypeEnvironmentQuiesced, request, code); sendErr != nil { - return nil, sendErr - } - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - return nil, err - } - continue - } - if err := control.Arm(request); err != nil { - return nil, err - } - if err := sendSuspendResult(ctx, conn, env, proto.TypeEnvironmentQuiesced, request, ""); err != nil { - return nil, err - } - return &request, nil - } - if err := s.router.Handle(ctx, env); err != nil { - obslog.Bg().Error("router.Handle failed", "type", env.Type, "err", err) - } - } - } -} - -// resume waits for Core to confirm the suspension on conn. The quiesced Router -// replies on conn from the start, because Core may release an assignment -// before it resumes. -func (s *suspendedRouter) resume(ctx context.Context, conn *transport.Conn, request proto.EnvironmentSuspendPayload, control *suspendControl) error { - s.sender.replace(conn) - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-conn.Done(): - if err := conn.Err(); err != nil { - return err - } - return transport.ErrConnClosed - case env, ok := <-conn.Recv(): - if !ok { - if err := conn.Err(); err != nil { - return err - } - return transport.ErrConnClosed - } - if env.Type == proto.TypeAssignmentRelease { - if err := s.router.Handle(ctx, env); err != nil { - obslog.Bg().Error("router.Handle failed", "type", env.Type, "err", err) - } - continue - } - var echoed proto.EnvironmentSuspendPayload - if env.Type != proto.TypeEnvironmentResume || env.ID == "" || env.DecodeRequest(&echoed) != nil || !echoed.SameSuspension(request) { - return errors.Join(transport.ErrPermanent, errors.New("connect: expected authenticated suspension resume")) - } - if err := s.router.Resume(env.Assignment, echoed, s.sender); err != nil { - return errors.Join(transport.ErrPermanent, err) - } - control.lastResumed = &request - return sendSuspendResult(ctx, conn, env, proto.TypeEnvironmentResumed, request, "") - } - } -} - -func sendSuspendResult(ctx context.Context, conn *transport.Conn, env proto.Envelope, kind string, request proto.EnvironmentSuspendPayload, code string) error { - result, err := env.Reply(kind, proto.EnvironmentSuspendResultPayload{EnvironmentID: request.EnvironmentID, SuspendID: request.SuspendID, Accepted: code == "", ErrorCode: code}) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - return conn.Send(ctx, result) -} - -func rejectedResumeRequest(env proto.Envelope) proto.EnvironmentSuspendPayload { - var request proto.EnvironmentSuspendPayload - if len(env.Payload) > 1024 || env.DecodeRequest(&request) != nil { - return proto.EnvironmentSuspendPayload{} - } - return request -} diff --git a/apps/daemon/internal/cli/connect_suspend_test.go b/apps/daemon/internal/cli/connect_suspend_test.go deleted file mode 100644 index 5b681ba90..000000000 --- a/apps/daemon/internal/cli/connect_suspend_test.go +++ /dev/null @@ -1,369 +0,0 @@ -//go:build linux - -package cli - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync/atomic" - "syscall" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/transport" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/gorilla/websocket" -) - -func TestPlannedReconnectRequiresAuthenticatedMatchingResume(t *testing.T) { - for _, scenario := range []string{"resume", "rollback", "reconnect", "revoked", "wrong_operation"} { - t.Run(scenario, func(t *testing.T) { - t.Setenv("OAC_RUNTIME_WORKSPACE", "") - var connections atomic.Int32 - peers := make(chan *websocket.Conn, 3) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer credential" { - http.Error(w, "unauthorized", 401) - return - } - attempt := connections.Add(1) - if scenario == "revoked" && attempt > 1 { - http.Error(w, "revoked", 401) - return - } - peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) - if err == nil { - peers <- peer - } - })) - defer server.Close() - dial := func(ctx context.Context) (*transport.Conn, error) { - return transport.Dial(ctx, transport.DialOptions{WSURL: "ws" + strings.TrimPrefix(server.URL, "http"), DeviceID: "device", Credential: "credential", DaemonVersion: proto.Version}) - } - registry := agent.NewRegistry() - control := &suspendControl{path: filepath.Join(t.TempDir(), "suspend.json"), identity: suspendIdentity{EnvironmentID: "env"}, signal: make(chan os.Signal, 1)} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan error, 1) - go func() { - done <- runSuspendLoop(ctx, dial, registry, nil, &transport.BootstrapResponse{HeartbeatSeconds: 1}, agentCLIDiscovery{}, control) - }() - var first *websocket.Conn - select { - case first = <-peers: - case <-time.After(3 * time.Second): - t.Fatal("initial connection missing") - } - defer first.Close() - if got := sendAssignment(t, first, proto.TypeAssignmentBind, lifecycleRef, proto.AssignmentBindPayload{EnvironmentID: "env"}); got.State != proto.AssignmentBound { - t.Fatalf("bind = %+v", got) - } - request := proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "planned"} - sendLifecycleFrame(t, first, proto.TypeEnvironmentQuiesce, request) - result := readLifecycleResult(t, first, proto.TypeEnvironmentQuiesced) - if !result.Accepted { - t.Fatalf("quiesce rejected: %+v", result) - } - // The daemon deliberately closes its old socket. Neither that error nor a - // heartbeat interval may exit the planned park while awaiting host wake. - _ = first.SetReadDeadline(time.Now().Add(time.Second)) - for { - var env proto.Envelope - if first.ReadJSON(&env) != nil { - break - } - } - select { - case err := <-done: - t.Fatalf("park exited without wake: %v", err) - case <-time.After(25 * time.Millisecond): - } - control.signal <- syscall.SIGUSR1 - if scenario == "revoked" { - select { - case err := <-done: - if !errors.Is(err, transport.ErrPermanent) { - t.Fatalf("revoke error=%v", err) - } - case <-time.After(3 * time.Second): - t.Fatal("revoked reconnect did not terminate") - } - } else { - var second *websocket.Conn - select { - case second = <-peers: - case <-time.After(3 * time.Second): - t.Fatal("resume connection missing") - } - defer second.Close() - if scenario == "rollback" { - request.Rollback = true - } - if scenario == "wrong_operation" { - request.SuspendID = "stale" - } - if scenario == "resume" { - // Core may release the Session before it resumes; the reply - // arrives on the new connection. - released := lifecycleRef - released.Epoch++ - if got := sendAssignment(t, second, proto.TypeAssignmentRelease, released, proto.AssignmentReleasePayload{}); got.State != proto.AssignmentReleased { - t.Fatalf("release during suspension = %+v", got) - } - } - sendLifecycleFrame(t, second, proto.TypeEnvironmentResume, request) - if scenario == "resume" || scenario == "rollback" || scenario == "reconnect" { - result = readLifecycleResult(t, second, proto.TypeEnvironmentResumed) - if !result.Accepted { - t.Fatalf("resume failed: %+v", result) - } - // A lost response may be retried on this same authenticated socket. - sendLifecycleFrame(t, second, proto.TypeEnvironmentResume, request) - if repeated := readLifecycleResult(t, second, proto.TypeEnvironmentResumed); !repeated.Accepted { - t.Fatal("resume receipt was not idempotent") - } - if scenario == "reconnect" { - // Core can lose the receipt before committing waking -> running. - // The next ordinary connection has a fresh Router but must confirm - // this consumed token without restoring or retaining old work. - _ = second.Close() - var third *websocket.Conn - select { - case third = <-peers: - case <-time.After(3 * time.Second): - t.Fatal("ordinary reconnect missing") - } - defer third.Close() - sendLifecycleFrame(t, third, proto.TypeEnvironmentResume, request) - if repeated := readLifecycleResult(t, third, proto.TypeEnvironmentResumed); !repeated.Accepted { - t.Fatal("receipt lost across ordinary reconnect") - } - request.SuspendID = "unrelated" - sendLifecycleFrame(t, third, proto.TypeEnvironmentResume, request) - if unrelated := readLifecycleResult(t, third, proto.TypeEnvironmentResumed); unrelated.Accepted { - t.Fatal("unrelated resume accepted by fresh Router") - } - } - cancel() - } - select { - case err := <-done: - if scenario == "wrong_operation" && err == nil { - t.Fatal("mismatched resume accepted") - } - case <-time.After(3 * time.Second): - t.Fatal("lifecycle failed to terminate") - } - } - }) - } -} - -// lifecycleRef is the assignment of the Session whose Environment suspends. -var lifecycleRef = proto.AssignmentRef{SessionID: "session", AssignmentID: "assignment", Epoch: 1} - -func sendLifecycleFrame(t *testing.T, peer *websocket.Conn, kind string, payload proto.EnvironmentSuspendPayload) { - t.Helper() - env, err := proto.NewEnvelope(kind, "operation", payload) - if err != nil { - t.Fatal(err) - } - env.Assignment = lifecycleRef - if err := peer.WriteJSON(env); err != nil { - t.Fatal(err) - } -} - -func readLifecycleResult(t *testing.T, peer *websocket.Conn, kind string) proto.EnvironmentSuspendResultPayload { - t.Helper() - _ = peer.SetReadDeadline(time.Now().Add(3 * time.Second)) - for { - var env proto.Envelope - if err := peer.ReadJSON(&env); err != nil { - t.Fatal(err) - } - if env.Type != kind { - continue - } - var result proto.EnvironmentSuspendResultPayload - if err := env.DecodePayload(&result); err != nil { - t.Fatal(err) - } - return result - } -} - -func TestRunningSourceOnlyAcceptsExplicitRollbackOrItsReceipt(t *testing.T) { - t.Setenv("OAC_RUNTIME_WORKSPACE", "") - peers := make(chan *websocket.Conn, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) - if err == nil { - peers <- peer - } - })) - defer server.Close() - dial := func(ctx context.Context) (*transport.Conn, error) { - return transport.Dial(ctx, transport.DialOptions{WSURL: "ws" + strings.TrimPrefix(server.URL, "http"), DeviceID: "device", Credential: "credential", DaemonVersion: proto.Version}) - } - control := &suspendControl{path: filepath.Join(t.TempDir(), "control.json"), identity: suspendIdentity{EnvironmentID: "env"}, signal: make(chan os.Signal, 1)} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan error, 1) - go func() { - done <- runSuspendLoop(ctx, dial, agent.NewRegistry(), nil, &transport.BootstrapResponse{HeartbeatSeconds: 60}, agentCLIDiscovery{}, control) - }() - var peer *websocket.Conn - select { - case peer = <-peers: - case <-time.After(time.Second): - t.Fatal("no connection") - } - defer peer.Close() - for _, test := range []struct { - request proto.EnvironmentSuspendPayload - accepted bool - }{ - {proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "cold_restore"}, false}, - {proto.EnvironmentSuspendPayload{EnvironmentID: "wrong", SuspendID: "rollback", Rollback: true}, false}, - {proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "rollback", Rollback: true}, true}, - {proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "rollback"}, true}, - {proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "cold_restore"}, false}, - } { - sendLifecycleFrame(t, peer, proto.TypeEnvironmentResume, test.request) - got := readLifecycleResult(t, peer, proto.TypeEnvironmentResumed) - if got.Accepted != test.accepted { - t.Fatalf("request=%+v result=%+v", test.request, got) - } - } - cancel() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("shutdown did not settle") - } -} - -func TestSuspensionReconnectBeforeConfirmation(t *testing.T) { - for _, scenario := range []string{"disconnect", "timeout", "revoked", "cancelled", "deleted"} { - t.Run(scenario, func(t *testing.T) { - t.Setenv("OAC_RUNTIME_WORKSPACE", "") - var attempts atomic.Int32 - peers := make(chan *websocket.Conn, 3) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attempt := attempts.Add(1) - // The first connection binds and quiesces the Router. - if scenario == "revoked" && attempt > 2 { - http.Error(w, "revoked", http.StatusUnauthorized) - return - } - peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) - if err == nil { - peers <- peer - } - })) - defer server.Close() - dial := func(ctx context.Context) (*transport.Conn, error) { - return transport.Dial(ctx, transport.DialOptions{WSURL: "ws" + strings.TrimPrefix(server.URL, "http"), DeviceID: "device", Credential: "credential", DaemonVersion: proto.Version}) - } - conn, err := dial(t.Context()) - if err != nil { - t.Fatal(err) - } - setup := <-peers - defer setup.Close() - state, err := newSuspendedRouter(conn, agent.NewRegistry(), nil) - if err != nil { - t.Fatal(err) - } - defer state.shutdown() - bind, err := proto.NewEnvelope(proto.TypeAssignmentBind, "bind", proto.AssignmentBindPayload{EnvironmentID: "env"}) - if err != nil { - t.Fatal(err) - } - bind.Assignment = lifecycleRef - if err := state.router.Handle(t.Context(), bind); err != nil { - t.Fatal(err) - } - request := proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "pending-confirmation"} - if err := state.router.Quiesce(t.Context(), lifecycleRef, request); err != nil { - t.Fatal(err) - } - _ = conn.Close() - control := &suspendControl{path: filepath.Join(t.TempDir(), "control.json"), identity: suspendIdentity{EnvironmentID: "env"}, signal: make(chan os.Signal, 1)} - if err := control.Arm(request); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - done := make(chan error, 1) - go func() { - conn, err := state.reconnectSuspension(ctx, dial, request, control, 100*time.Millisecond) - if conn != nil { - _ = conn.Close() - } - done <- err - }() - var first *websocket.Conn - select { - case first = <-peers: - case <-time.After(3 * time.Second): - t.Fatal("initial recovery connection missing") - } - defer first.Close() - switch scenario { - case "cancelled": - cancel() - case "deleted": - if err := first.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(4001, "runtime deleted"), time.Now().Add(time.Second)); err != nil { - t.Fatal(err) - } - case "timeout": - // Leave the connection open without confirmation. The short injected - // attempt deadline must cause a new authenticated connection. - default: - _ = first.Close() - } - if scenario == "disconnect" || scenario == "timeout" { - var second *websocket.Conn - select { - case second = <-peers: - case err := <-done: - t.Fatalf("transient failure discarded suspension: %v", err) - case <-time.After(3 * time.Second): - t.Fatal("recovery retry missing") - } - defer second.Close() - sendLifecycleFrame(t, second, proto.TypeEnvironmentResume, request) - if result := readLifecycleResult(t, second, proto.TypeEnvironmentResumed); !result.Accepted { - t.Fatal("matching resume rejected after retry") - } - } - select { - case err := <-done: - switch scenario { - case "revoked", "deleted": - if !errors.Is(err, transport.ErrPermanent) { - t.Fatalf("permanent rejection=%v", err) - } - case "cancelled": - if !errors.Is(err, context.Canceled) { - t.Fatalf("cancellation=%v", err) - } - default: - if err != nil || control.lastResumed == nil || !control.lastResumed.SameSuspension(request) { - t.Fatalf("recovery did not preserve suspension: %v", err) - } - } - case <-time.After(3 * time.Second): - t.Fatal("recovery failed to settle") - } - }) - } -} diff --git a/apps/daemon/internal/cli/logout.go b/apps/daemon/internal/cli/logout.go deleted file mode 100644 index 553c6fa57..000000000 --- a/apps/daemon/internal/cli/logout.go +++ /dev/null @@ -1,46 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "os" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" -) - -// runLogout removes the credential file for a profile. Idempotent -// so CI scripts can re-run it on every step. -// -// Logout is local-only: revoking the runtime on the server is the -// admin UI's disable-runtime button (which also kicks any live WS). -func runLogout(ctx *runContext, args []string) error { - fs := newFlagSet("logout") - profile := fs.String("profile", paths.DefaultProfile, "profile name to forget") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("logout: parse flags: %w", err) - } - if err := paths.ValidateProfile(*profile); err != nil { - return fmt.Errorf("logout: %w", err) - } - - // Stat first so we emit a distinct message for "nothing to - // remove" vs. "removed". - authPath, err := paths.AuthFile(*profile) - if err != nil { - return fmt.Errorf("logout: %w", err) - } - _, statErr := os.Stat(authPath) - missing := errors.Is(statErr, os.ErrNotExist) - - if err := auth.Delete(*profile); err != nil { - return fmt.Errorf("logout: %w", err) - } - if missing { - fmt.Fprintf(ctx.stdout, "Profile %q already had no credential — nothing to forget.\n", *profile) - } else { - fmt.Fprintf(ctx.stdout, "Forgot credential for profile %q (removed %s).\n", *profile, authPath) - } - fmt.Fprintln(ctx.stdout, "Note: this only removes local state. To revoke the runtime on the server, disable it in the admin UI.") - return nil -} diff --git a/apps/daemon/internal/cli/logs.go b/apps/daemon/internal/cli/logs.go index cc8132037..45f527695 100644 --- a/apps/daemon/internal/cli/logs.go +++ b/apps/daemon/internal/cli/logs.go @@ -12,31 +12,30 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) -// runLogs tails ~/.oac/daemon//connect.log. -f streams -// new bytes; -n sets trailing-line history (default 100). Missing log -// file surfaces an actionable hint instead of a path error. +// runLogs tails the background daemon's connect.log. -f streams new bytes; +// -n sets trailing-line history (default 100). Missing log file surfaces an +// actionable hint instead of a path error. func runLogs(ctx *runContext, args []string) error { fs := newFlagSet("logs") var ( - profile = fs.String("profile", paths.DefaultProfile, "profile name whose log to tail") - follow = fs.Bool("f", false, "follow the log (like `tail -f`)") - lines = fs.Int("n", 100, "print the last N lines before optionally following") + follow = fs.Bool("f", false, "follow the log (like `tail -f`)") + lines = fs.Int("n", 100, "print the last N lines before optionally following") ) if err := fs.Parse(args); err != nil { return fmt.Errorf("logs: parse flags: %w", err) } - if err := paths.ValidateProfile(*profile); err != nil { - return fmt.Errorf("logs: %w", err) + if fs.NArg() != 0 { + return fmt.Errorf("logs: unexpected arguments %q", fs.Args()) } - logPath, err := paths.LogFile(*profile) + logPath, err := paths.LogFile() if err != nil { return fmt.Errorf("logs: %w", err) } if _, err := os.Stat(logPath); err != nil { if errors.Is(err, os.ErrNotExist) { fmt.Fprintf(ctx.stderr, "oac-daemon: no log file yet at %s\n", logPath) - fmt.Fprintln(ctx.stderr, " Start the daemon with `oac-daemon connect -b` first.") + fmt.Fprintln(ctx.stderr, " Start the daemon with `oac-daemon start` first.") return fmt.Errorf("logs: log file does not exist") } return fmt.Errorf("logs: stat: %w", err) diff --git a/apps/daemon/internal/cli/native_discovery_test.go b/apps/daemon/internal/cli/native_discovery_test.go deleted file mode 100644 index d0ea7fade..000000000 --- a/apps/daemon/internal/cli/native_discovery_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package cli - -import ( - "context" - "errors" - "io" - "reflect" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestDiscoveryAndRegistration(t *testing.T) { - for _, selected := range []string{"codex", "mcode", "claude_sdk", ""} { - t.Run(selected, func(t *testing.T) { - called := []string{} - declarations := append([]agent.Declaration(nil), harnessDeclarations...) - for i := range declarations { - declarations[i].Discover = func(_ context.Context, _ agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { - called = append(called, info.Kind) - info.Available = true - info.Version = "test" - return &agent.Runtime{Info: info} - } - } - rc := &runContext{stdout: io.Discard, stderr: io.Discard} - expected := []string{"codex", "mcode", "claude_sdk"} - if selected != "" { - rc.installedKinds = map[string]bool{selected: true} - expected = []string{selected} - } - discovery, err := discoverAgentCLIs(t.Context(), rc, "default", declarations) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(called, expected) { - t.Fatalf("probes %v, want %v", called, expected) - } - registry := agent.NewRegistry() - registerAgentKinds(registry, discovery, agent.EnvironmentSupport{}) - kinds := registry.SupportedAgentKinds() - if len(kinds) != len(expected) { - t.Fatal(kinds) - } - for _, info := range kinds { - if !info.Available || info.Version != "test" { - t.Fatal(info) - } - } - }) - } -} -func TestDiscoveryUnavailableAndCancelled(t *testing.T) { - declarations := append([]agent.Declaration(nil), harnessDeclarations...) - for i := range declarations { - declarations[i].Discover = func(_ context.Context, _ agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { - return &agent.Runtime{Info: info} - } - } - rc := &runContext{stdout: io.Discard, stderr: io.Discard} - if _, err := discoverAgentCLIs(t.Context(), rc, "default", declarations); err == nil { - t.Fatal("unavailable runtimes admitted") - } - ctx, cancel := context.WithCancel(t.Context()) - cancel() - if _, err := discoverAgentCLIs(ctx, rc, "default", declarations); !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - declarations[0].Discover = func(context.Context, agent.DiscoveryOptions, proto.SupportedAgentKind) *agent.Runtime { return nil } - discovery, _ := discoverAgentCLIs(t.Context(), rc, "default", declarations) - if len(discovery) != 2 { - t.Fatal("unconfigured adapter retained") - } -} diff --git a/apps/daemon/internal/cli/native_install.go b/apps/daemon/internal/cli/native_install.go index 5347875fb..b26ec277d 100644 --- a/apps/daemon/internal/cli/native_install.go +++ b/apps/daemon/internal/cli/native_install.go @@ -31,14 +31,6 @@ type nativeInstallation struct { Harnesses []string `json:"harnesses"` } -func nativeInstallationPath() (string, error) { - root, err := paths.Root() - if err != nil { - return "", err - } - return filepath.Join(root, "daemon", "installation.json"), nil -} - func lockNativeInstallation(root string) (*os.Root, func(), error) { dir := filepath.Join(root, "daemon") if err := runtimefs.EnsurePrivateDir(dir); err != nil { diff --git a/apps/daemon/internal/cli/native_install_test.go b/apps/daemon/internal/cli/native_install_test.go index 1ab045f64..240344036 100644 --- a/apps/daemon/internal/cli/native_install_test.go +++ b/apps/daemon/internal/cli/native_install_test.go @@ -246,28 +246,3 @@ func TestNativeInstallationDoesNotRepairMissingDaemon(t *testing.T) { t.Fatal("recreated a removed daemon") } } - -func TestNativeInstallationConnectCannotBypassValidation(t *testing.T) { - rc, args, root, _ := nativeInstallFixture(t) - if err := runInstall(rc, args); err != nil { - t.Fatal(err) - } - for _, mode := range []string{"installed", "missing-component"} { - t.Run(mode, func(t *testing.T) { - if mode == "missing-component" { - if err := os.Remove(filepath.Join(nativeComponentRoot(root, "codex"), "program")); err != nil { - t.Fatal(err) - } - } - for _, connection := range [][]string{ - nil, - {"--remote", "ws://127.0.0.1:1/api/v1/agent-daemon/ws"}, - } { - err := runConnect(rc, connection) - if err == nil || !strings.Contains(err.Error(), "use oac-daemon start") { - t.Fatal("connect bypassed native installation validation", err) - } - } - }) - } -} diff --git a/apps/daemon/internal/cli/native_start_linux.go b/apps/daemon/internal/cli/native_start_linux.go index 3dd86d1a8..831bdab32 100644 --- a/apps/daemon/internal/cli/native_start_linux.go +++ b/apps/daemon/internal/cli/native_start_linux.go @@ -72,7 +72,7 @@ func runSandboxLauncher(parent context.Context, rc *runContext, background bool, return err } if background && !daemonize.IsBackgroundChild() { - return spawnBackground(parent, rc, paths.DefaultProfile, os.Args) + return spawnBackground(parent, rc, os.Args) } dir := filepath.Join(root, "daemon") held, err := os.OpenRoot(dir) @@ -149,3 +149,67 @@ func runSandboxIO(ctx context.Context, rc *runContext, held *os.Root, program, b cmd.WaitDelay = sandboxStopGrace return cmd.Run() } + +// spawnBackground forks the daemon into the background. Parent +// returns after printing the child PID; child re-enters runStart +// with BackgroundSentinelEnv set so the same launcher runs in either +// mode. +func spawnBackground(ctx context.Context, rc *runContext, argv []string) error { + logPath, err := paths.LogFile() + if err != nil { + return fmt.Errorf("start: %w", err) + } + pidPath, err := paths.PIDFile() + if err != nil { + return fmt.Errorf("start: %w", err) + } + // Serialize the live-process check and publication across concurrent starts. + if err := runtimefs.EnsurePrivateDir(filepath.Dir(pidPath)); err != nil { + return err + } + root, err := os.OpenRoot(filepath.Dir(pidPath)) + if err != nil { + return err + } + defer root.Close() + unlock, err := runtimefs.LockDirectory(root) + if err != nil { + return errors.New("start: startup is busy; wait and retry") + } + defer unlock() + // Refuse to start a second background daemon. + if pid, err := daemonize.ReadPIDFile(pidPath); err == nil { + return fmt.Errorf("start: background daemon already running (pid=%d); run `oac-daemon stop` first", pid) + } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, daemonize.ErrStaleOrCorrupt) { + return fmt.Errorf("start: check pidfile: %w", err) + } + // Stale pidfile → remove so Spawn starts clean. + _ = daemonize.RemovePIDFile(pidPath) + + if err := daemonize.EnsureLogFile(logPath); err != nil { + return fmt.Errorf("start: %w", err) + } + + if err := ctx.Err(); err != nil { + return err + } + pid, err := daemonize.Spawn(argv, daemonize.ReExecOptions{ + LogPath: logPath, + PIDPath: pidPath, + }) + if err != nil { + return fmt.Errorf("start: spawn background: %w", err) + } + + if err := ctx.Err(); err != nil { + if stopErr := daemonize.StopPIDFile(pidPath, stopTimeout); stopErr != nil { + return fmt.Errorf("start: interrupted startup cleanup: %w", stopErr) + } + return err + } + fmt.Fprintf(rc.stdout, "oac-daemon: backgrounded (pid=%d)\n", pid) + fmt.Fprintf(rc.stdout, " logs : %s\n", logPath) + fmt.Fprintf(rc.stdout, " pid : %s\n", pidPath) + fmt.Fprintln(rc.stdout, " stop : oac-daemon stop") + return nil +} diff --git a/apps/daemon/internal/cli/placement.go b/apps/daemon/internal/cli/placement.go deleted file mode 100644 index af2b661c4..000000000 --- a/apps/daemon/internal/cli/placement.go +++ /dev/null @@ -1,69 +0,0 @@ -package cli - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/placement" -) - -func runPlacement(ctx *runContext, args []string) error { - if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { - fmt.Fprintln(ctx.stdout, "Usage: oac-daemon placement enroll --container --owner --workspace [--environment ]") - fmt.Fprintln(ctx.stdout, " oac-daemon placement retire --container [--environment ]") - fmt.Fprintln(ctx.stdout, "Explicit operator-managed local Linux/Docker only; normal harness release is unaffected.") - fmt.Fprintln(ctx.stdout, "Enrollment requires label io.oac.placement= and the qualified private profile.") - fmt.Fprintln(ctx.stdout, "Scoped enrollment requires the same --environment on retirement; this is operator consent, not Core authentication.") - return nil - } - action := args[0] - if action != "enroll" && action != "retire" { - return fmt.Errorf("unknown placement action %q", action) - } - fs := newFlagSet("placement " + action) - id := fs.String("container", "", "full immutable container ID") - environment := fs.String("environment", "", "operator-confirmed Environment UUID") - var owner, workspace string - if action == "enroll" { - fs.StringVar(&owner, "owner", "", "operator-created placement label value") - fs.StringVar(&workspace, "workspace", "", "retained absolute host workspace path") - } - if err := fs.Parse(args[1:]); err != nil { - if err == flag.ErrHelp { - return runPlacement(ctx, []string{"--help"}) - } - return err - } - if fs.NArg() != 0 || *id == "" { - return fmt.Errorf("placement %s requires --container and no positional arguments", action) - } - scoped := false - fs.Visit(func(f *flag.Flag) { - if f.Name == "environment" { - scoped = true - } - }) - controller, err := placement.New() - if err != nil { - return err - } - operation, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - var receipt *placement.Receipt - if action == "enroll" && scoped { - receipt, err = controller.EnrollEnvironment(operation, *id, owner, workspace, *environment) - } else if action == "enroll" { - receipt, err = controller.Enroll(operation, *id, owner, workspace) - } else if scoped { - receipt, err = controller.RetireEnvironment(operation, *id, *environment) - } else { - receipt, err = controller.Retire(operation, *id) - } - if err != nil { - return err - } - return json.NewEncoder(ctx.stdout).Encode(receipt) -} diff --git a/apps/daemon/internal/cli/root.go b/apps/daemon/internal/cli/root.go index 2b3323e33..75d7ae0fb 100644 --- a/apps/daemon/internal/cli/root.go +++ b/apps/daemon/internal/cli/root.go @@ -19,13 +19,12 @@ type command struct { run func(ctx *runContext, args []string) error } -// runContext carries command I/O and an optional installed Harness selection. -// Tests inject streams; production uses the OS streams. +// runContext carries command I/O. Tests inject streams; production uses the +// OS streams. type runContext struct { - installedKinds map[string]bool - stdin io.Reader - stdout io.Writer - stderr io.Writer + stdin io.Reader + stdout io.Writer + stderr io.Writer } func defaultRunContext() *runContext { @@ -33,19 +32,13 @@ func defaultRunContext() *runContext { } // commands lists subcommands in --help render order: the user's -// likely flow connect → status → stop / logs → logout. +// likely flow install → start → stop / logs. var commands = []command{ {name: "install", summary: "Install a native daemon and selected Harnesses", run: runInstall}, {name: "start", summary: "Start the installed native daemon", run: runStart}, - {name: "resume", summary: "Wake one planned hosted suspension", run: runResume}, - {name: "runtime-mcp-exec", summary: "Execute an installed MCP server", run: runRuntimeMCP}, - {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, - {name: "connect", summary: "Open the reverse WebSocket and start serving prompts", run: runConnect}, {name: "agent-host", summary: "Serve Sessions from the agent-host container", run: runAgentHost}, - {name: "status", summary: "Print the credential profile and daemon state", run: runStatus}, - {name: "stop", summary: "Stop a background `connect -b` daemon", run: runStop}, + {name: "stop", summary: "Stop the background daemon", run: runStop}, {name: "logs", summary: "Tail the background daemon's log file", run: runLogs}, - {name: "logout", summary: "Forget the credential for a profile", run: runLogout}, {name: "version", summary: "Print the daemon version and exit", run: runVersion}, } diff --git a/apps/daemon/internal/cli/root_test.go b/apps/daemon/internal/cli/root_test.go index b235f3d2f..77b504579 100644 --- a/apps/daemon/internal/cli/root_test.go +++ b/apps/daemon/internal/cli/root_test.go @@ -60,18 +60,12 @@ func TestSubcommandsAreRegistered(t *testing.T) { // Guards against dropping a subcommand off the commands slice — // the public CLI surface is the shipped contract. want := map[string]bool{ - "install": false, - "start": false, - "resume": false, - "runtime-mcp-exec": false, - "placement": false, - "connect": false, - "agent-host": false, - "status": false, - "stop": false, - "logs": false, - "logout": false, - "version": false, + "install": false, + "start": false, + "agent-host": false, + "stop": false, + "logs": false, + "version": false, } for _, c := range commands { if _, ok := want[c.name]; !ok { diff --git a/apps/daemon/internal/cli/runtime_mcp.go b/apps/daemon/internal/cli/runtime_mcp.go deleted file mode 100644 index e3b62a039..000000000 --- a/apps/daemon/internal/cli/runtime_mcp.go +++ /dev/null @@ -1,113 +0,0 @@ -package cli - -import ( - "errors" - "os" - "path/filepath" - "runtime" - "sort" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" -) - -var errRuntimeMCP = errors.New("environment MCP unavailable") - -type mcpInvocation struct { - command string - args []string - cwd string - env []string -} - -// runRuntimeMCP forwards stdio to the selected installed MCP server. -func runRuntimeMCP(_ *runContext, args []string) error { - if len(args) != 3 || args[0] == "/" || agentcapabilities.ValidateLocalDirectories([]string{args[0]}) != nil { - return errRuntimeMCP - } - root, err := os.OpenRoot(args[0]) - if err != nil { - return errRuntimeMCP - } - manifest, err := agentcapabilities.Load(root) - root.Close() - if err != nil { - return errRuntimeMCP - } - values, err := localworkspace.ReadOptionalToolEnvironment() - if err != nil { - return errRuntimeMCP - } - invocation, err := resolveMCPInvocation(manifest, args[0], args[1], args[2], values) - if err != nil { - return errRuntimeMCP - } - return execRuntimeMCP(invocation) -} - -func resolveMCPInvocation(manifest agentcapabilities.Manifest, installationRoot, pkg, name string, values map[string]string) (mcpInvocation, error) { - if installationRoot == "/" || agentcapabilities.ValidateLocalDirectories([]string{installationRoot}) != nil { - return mcpInvocation{}, errRuntimeMCP - } - for _, installed := range manifest.MCP { - server := installed.Server - if installed.PackageRoot != pkg || server.Name != name { - continue - } - if server.Type != "stdio" { - return mcpInvocation{}, errRuntimeMCP - } - // MCP runs under the same user environment as the daemon. Explicit - // capability variables override inherited values. - env := map[string]string{} - for _, entry := range os.Environ() { - key, value, ok := strings.Cut(entry, "=") - if ok { - env[mcpEnvironmentKey(key)] = value - } - } - - for _, key := range server.EnvVars { - value, exists := values[key] - if !exists || strings.ContainsRune(value, 0) { - return mcpInvocation{}, errRuntimeMCP - } - env[mcpEnvironmentKey(key)] = value - } - cwd := server.CWD - if !filepath.IsAbs(cwd) { - cwd = filepath.Join(installationRoot, installed.PackageRoot, cwd) - } - result := mcpInvocation{command: server.Command, args: append([]string{server.Command}, server.Args...), cwd: cwd} - for key, value := range env { - result.env = append(result.env, key+"="+value) - } - sort.Strings(result.env) - return result, nil - } - return mcpInvocation{}, errRuntimeMCP -} - -func mcpEnvironmentKey(key string) string { - if runtime.GOOS == "windows" { - return strings.ToUpper(key) - } - return key -} - -// This command is a dedicated MCP launcher process. Apply its final environment -// before executable lookup so PATH and npm selection agree on every platform. -func configureMCPProcess(invocation mcpInvocation) error { - if os.Chdir(invocation.cwd) != nil { - return errRuntimeMCP - } - os.Clearenv() - for _, entry := range invocation.env { - key, value, _ := strings.Cut(entry, "=") - if os.Setenv(key, value) != nil { - return errRuntimeMCP - } - } - return nil -} diff --git a/apps/daemon/internal/cli/runtime_mcp_test.go b/apps/daemon/internal/cli/runtime_mcp_test.go deleted file mode 100644 index 9e592d142..000000000 --- a/apps/daemon/internal/cli/runtime_mcp_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package cli - -import ( - "slices" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" -) - -func TestRuntimeMCPOverlaysExplicitVariablesOnUserEnvironment(t *testing.T) { - manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ - Name: "local", Type: "stdio", Command: "python3", Args: []string{"proof.py", "${LITERAL}"}, EnvVars: []string{"SELECTED"}, CWD: "./server", - }}}} - t.Setenv("SELECTED", "private-native-value") - t.Setenv("NATIVE_ONLY", "private-native-value") - got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{"SELECTED": "user-value", "UNDECLARED": "not-injected"}) - if err != nil || got.command != "python3" || got.cwd != "/private/runtime/capabilities/plugins/0/server" || - !slices.Equal(got.args, []string{"python3", "proof.py", "${LITERAL}"}) || !slices.Contains(got.env, "SELECTED=user-value") || - slices.Contains(got.env, "UNDECLARED=not-injected") || !slices.Contains(got.env, "NATIVE_ONLY=private-native-value") { - t.Fatalf("incorrect invocation configuration: %v", err) - } - if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{}); err == nil { - t.Fatal("missing user value fell back to native environment") - } - if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/1", "local", map[string]string{"SELECTED": "x"}); err == nil { - t.Fatal("undeclared package selected") - } -} - -func TestRuntimeMCPKeepsAbsoluteCWDAndRejectsHTTP(t *testing.T) { - manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ - Name: "local", Type: "stdio", Command: "python3", CWD: "/workspace", - }}}} - got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil) - if err != nil || got.cwd != "/workspace" { - t.Fatalf("absolute cwd changed: %v", err) - } - manifest.MCP[0].Server.Type = "http" - if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil); err == nil { - t.Fatal("HTTP configuration treated as a process") - } -} - -func TestRuntimeMCPRejectsUncleanInstallationRoot(t *testing.T) { - for _, root := range []string{"", "/", "relative", "/tmp/../private", "/tmp/root/", "/tmp/line\n"} { - if _, err := resolveMCPInvocation(agentcapabilities.Manifest{}, root, "plugins/0", "local", nil); err == nil { - t.Fatal("invalid root accepted", root) - } - } -} diff --git a/apps/daemon/internal/cli/runtime_mcp_unix.go b/apps/daemon/internal/cli/runtime_mcp_unix.go deleted file mode 100644 index 7c74a3b64..000000000 --- a/apps/daemon/internal/cli/runtime_mcp_unix.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build !windows - -package cli - -import ( - "os/exec" - "syscall" -) - -func execRuntimeMCP(invocation mcpInvocation) error { - if err := configureMCPProcess(invocation); err != nil { - return err - } - command, err := exec.LookPath(invocation.command) - if err != nil { - return errRuntimeMCP - } - if syscall.Exec(command, invocation.args, invocation.env) != nil { - return errRuntimeMCP - } - return nil -} diff --git a/apps/daemon/internal/cli/runtime_mcp_windows.go b/apps/daemon/internal/cli/runtime_mcp_windows.go deleted file mode 100644 index d8e17fa7f..000000000 --- a/apps/daemon/internal/cli/runtime_mcp_windows.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build windows - -package cli - -import ( - "os" - "os/exec" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" -) - -func execRuntimeMCP(invocation mcpInvocation) error { - if err := configureMCPProcess(invocation); err != nil { - return err - } - binary, args, err := localworkspace.ResolvePackageManagerCommand(invocation.command, invocation.args[1:]) - if err != nil { - return errRuntimeMCP - } - command := exec.Command(binary, args...) - command.Dir, command.Env = invocation.cwd, invocation.env - command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr - if err := command.Run(); err != nil { - return errRuntimeMCP - } - return nil -} diff --git a/apps/daemon/internal/cli/runtime_mcp_windows_test.go b/apps/daemon/internal/cli/runtime_mcp_windows_test.go deleted file mode 100644 index 8b17a1a66..000000000 --- a/apps/daemon/internal/cli/runtime_mcp_windows_test.go +++ /dev/null @@ -1,179 +0,0 @@ -//go:build windows - -package cli - -import ( - "encoding/json" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - "testing" -) - -func TestWindowsRuntimeMCPPackageManagers(t *testing.T) { - node, err := exec.LookPath("node") - if err != nil { - t.Fatal("native test requires installed Node.js", err) - } - workspace := filepath.Join(t.TempDir(), "local stdio project") - bin := filepath.Join(workspace, "node_modules", ".bin") - pkg := filepath.Join(workspace, "node_modules", "oac-stdio-fixture") - if err = os.MkdirAll(bin, 0700); err != nil { - t.Fatal(err) - } - if err = os.MkdirAll(pkg, 0700); err != nil { - t.Fatal(err) - } - fixture := filepath.Join(pkg, "fixture.cjs") - files := map[string]string{ - filepath.Join(workspace, "package.json"): `{"name":"offline-test","version":"1.0.0","private":true}`, - filepath.Join(pkg, "package.json"): `{"name":"oac-stdio-fixture","version":"1.0.0","bin":{"oac-stdio-fixture":"fixture.cjs"}}`, - fixture: `let input="";process.stdin.setEncoding("utf8");process.stdin.on("data",v=>input+=v);process.stdin.on("end",()=>process.stdout.write(JSON.stringify({input,cwd:process.cwd(),value:process.env.OAC_MCP_FIXTURE_VALUE,args:process.argv.slice(2)})))`, - filepath.Join(bin, "oac-stdio-fixture.cmd"): "@\"" + node + "\" \"" + fixture + "\" %*\r\n", - } - for path, body := range files { - if err = os.WriteFile(path, []byte(body), 0600); err != nil { - t.Fatal(err) - } - } - // Each invocation uses only this installed local bin, with an empty offline cache. - environment := append(os.Environ(), "npm_config_offline=true", "npm_config_yes=false", "npm_config_cache="+t.TempDir(), "OAC_MCP_FIXTURE_VALUE=selected") - for _, name := range []string{"npm", "npx"} { - absolute, err := exec.LookPath(name + ".cmd") - if err != nil { - t.Fatal("native test requires installed npm/npx", err) - } - for _, command := range []string{name, name + ".cmd", absolute} { - t.Run(command, func(t *testing.T) { - args := []string{command} - if name == "npm" { - args = append(args, "exec") - } - args = append(args, "--offline", "--yes=false", "--", "oac-stdio-fixture", "two words") - checkWindowsMCPStdio(t, mcpInvocation{command: command, args: args, cwd: workspace, env: environment}, []string{"two words"}) - }) - } - } - checkWindowsMCPStdio(t, mcpInvocation{command: node, args: []string{node, fixture, "ordinary exe", "literal & %PATH%"}, cwd: workspace, env: environment}, []string{"ordinary exe", "literal & %PATH%"}) -} - -func checkWindowsMCPStdio(t *testing.T, invocation mcpInvocation, wantArgs []string) { - t.Helper() - previousEnv := os.Environ() - previousCWD, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - defer func() { - os.Clearenv() - for _, entry := range previousEnv { - key, value, ok := strings.Cut(entry, "=") - if ok { - _ = os.Setenv(key, value) - } - } - _ = os.Chdir(previousCWD) - }() - input, err := os.CreateTemp(t.TempDir(), "input") - if err != nil { - t.Fatal(err) - } - defer input.Close() - output, err := os.CreateTemp(t.TempDir(), "output") - if err != nil { - t.Fatal(err) - } - defer output.Close() - stderr, err := os.CreateTemp(t.TempDir(), "stderr") - if err != nil { - t.Fatal(err) - } - defer stderr.Close() - if _, err = input.WriteString("local stdio payload\n"); err != nil { - t.Fatal(err) - } - if _, err = input.Seek(0, 0); err != nil { - t.Fatal(err) - } - stdin, stdout, oldErr := os.Stdin, os.Stdout, os.Stderr - os.Stdin, os.Stdout, os.Stderr = input, output, stderr - defer func() { os.Stdin, os.Stdout, os.Stderr = stdin, stdout, oldErr }() - if err = execRuntimeMCP(invocation); err != nil { - detail, _ := os.ReadFile(stderr.Name()) - t.Fatalf("native local MCP failed: %v: %s", err, detail) - } - raw, err := os.ReadFile(output.Name()) - if err != nil { - t.Fatal(err) - } - var got struct { - Input, CWD, Value string - Args []string - } - if json.Unmarshal(raw, &got) != nil || got.Input != "local stdio payload\n" || filepath.Clean(got.CWD) != filepath.Clean(invocation.cwd) || got.Value != "selected" || !slices.Equal(got.Args, wantArgs) { - t.Fatalf("stdio invocation changed: %s", raw) - } -} - -func TestWindowsRuntimeMCPExplicitPath(t *testing.T) { - node, err := exec.LookPath("node") - if err != nil { - t.Fatal(err) - } - directory := t.TempDir() - body, err := os.ReadFile(node) - if err != nil { - t.Fatal(err) - } - if err = os.WriteFile(filepath.Join(directory, "selected-node.exe"), body, 0700); err != nil { - t.Fatal(err) - } - fixture := filepath.Join(directory, "stdio.cjs") - if err = os.WriteFile(fixture, []byte(`let input="";process.stdin.on("data",v=>input+=v);process.stdin.on("end",()=>process.stdout.write(JSON.stringify({input,cwd:process.cwd(),value:process.env.OAC_MCP_FIXTURE_VALUE,args:process.argv.slice(2)})))`), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("Path", os.Getenv("PATH")) - manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "test", Type: "stdio", Command: "selected-node", Args: []string{fixture}, CWD: directory, EnvVars: []string{"PATH", "OAC_MCP_FIXTURE_VALUE"}}}}} - invocation, err := resolveMCPInvocation(manifest, directory, "plugins/0", "test", map[string]string{"PATH": directory, "OAC_MCP_FIXTURE_VALUE": "selected"}) - if err != nil { - t.Fatal(err) - } - count := 0 - for _, entry := range invocation.env { - key, value, _ := strings.Cut(entry, "=") - if strings.EqualFold(key, "PATH") { - count++ - if value != directory { - t.Fatal("explicit PATH lost") - } - } - } - if count != 1 { - t.Fatal("duplicate PATH variants", count) - } - checkWindowsMCPStdio(t, invocation, nil) - // npm resolution must also use the selected installation, not ambient npm. - npmBin := filepath.Join(directory, "node_modules", "npm", "bin") - if err = os.MkdirAll(npmBin, 0700); err != nil { - t.Fatal(err) - } - if err = os.WriteFile(filepath.Join(directory, "npm.cmd"), []byte("@exit /b 1"), 0600); err != nil { - t.Fatal(err) - } - if err = os.WriteFile(filepath.Join(directory, "node.exe"), body, 0700); err != nil { - t.Fatal(err) - } - source, err := os.ReadFile(fixture) - if err != nil { - t.Fatal(err) - } - if err = os.WriteFile(filepath.Join(npmBin, "npm-cli.js"), source, 0600); err != nil { - t.Fatal(err) - } - invocation.command, invocation.args = "npm", []string{"npm", "selected installation"} - checkWindowsMCPStdio(t, invocation, []string{"selected installation"}) -} diff --git a/apps/daemon/internal/cli/status.go b/apps/daemon/internal/cli/status.go deleted file mode 100644 index beb9e444d..000000000 --- a/apps/daemon/internal/cli/status.go +++ /dev/null @@ -1,63 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "os" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/auth" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" -) - -// runStatus prints a one-screen profile summary. Deliberately omits -// runner_credential — that's the wire identity and showing it in -// shell history / CI logs would be a foot-gun. -func runStatus(ctx *runContext, args []string) error { - fs := newFlagSet("status") - profile := fs.String("profile", paths.DefaultProfile, "profile name to inspect") - if err := fs.Parse(args); err != nil { - return fmt.Errorf("status: parse flags: %w", err) - } - if err := paths.ValidateProfile(*profile); err != nil { - return fmt.Errorf("status: %w", err) - } - - dir, err := paths.ProfileDir(*profile) - if err != nil { - return fmt.Errorf("status: %w", err) - } - fmt.Fprintf(ctx.stdout, "profile : %s\n", *profile) - fmt.Fprintf(ctx.stdout, "state dir : %s\n", dir) - - prof, err := auth.Load(*profile) - switch { - case errors.Is(err, auth.ErrNotPaired): - fmt.Fprintln(ctx.stdout, "paired : no saved credential profile; check Host connection in Core for a self-hosted Runtime") - case err != nil: - fmt.Fprintf(ctx.stdout, "paired : ERROR — %v\n", err) - default: - fmt.Fprintln(ctx.stdout, "paired : yes") - fmt.Fprintf(ctx.stdout, "server_url : %s\n", prof.ServerURL) - fmt.Fprintf(ctx.stdout, "runtime_id : %s\n", prof.RuntimeID) - if prof.DeviceName != "" { - fmt.Fprintf(ctx.stdout, "device_name : %s\n", prof.DeviceName) - } - } - - // connect.pid existence is the cheap signal; the full liveness - // check (kill -0) would be more accurate but a bare existence - // check is honest enough for the "paired but not connected" - // diagnosis. - pidPath, err := paths.PIDFile(*profile) - if err != nil { - return fmt.Errorf("status: resolve pid path: %w", err) - } - if _, err := os.Stat(pidPath); errors.Is(err, os.ErrNotExist) { - fmt.Fprintln(ctx.stdout, "background : not started (no connect.pid)") - } else if err != nil { - fmt.Fprintf(ctx.stdout, "background : ERROR — %v\n", err) - } else { - fmt.Fprintf(ctx.stdout, "background : pidfile present at %s\n", pidPath) - } - return nil -} diff --git a/apps/daemon/internal/cli/stop.go b/apps/daemon/internal/cli/stop.go index 2ababd8ab..30f69bfb5 100644 --- a/apps/daemon/internal/cli/stop.go +++ b/apps/daemon/internal/cli/stop.go @@ -4,24 +4,28 @@ import ( "errors" "fmt" "os" + "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) +// stopTimeout allows the native process grace period and subsequent +// owner/pipe cleanup. +const stopTimeout = 10 * time.Second + // runStop requests cleanup from the recorded daemon identity. A timeout keeps // the ownership record; only confirmed exit permits removing it. func runStop(ctx *runContext, args []string) error { fs := newFlagSet("stop") - profile := fs.String("profile", paths.DefaultProfile, "profile name to stop") if err := fs.Parse(args); err != nil { return fmt.Errorf("stop: parse flags: %w", err) } - if err := paths.ValidateProfile(*profile); err != nil { - return fmt.Errorf("stop: %w", err) + if fs.NArg() != 0 { + return fmt.Errorf("stop: unexpected arguments %q", fs.Args()) } - pidPath, err := paths.PIDFile(*profile) + pidPath, err := paths.PIDFile() if err != nil { return fmt.Errorf("stop: %w", err) } diff --git a/apps/daemon/internal/cli/suspend_control.go b/apps/daemon/internal/cli/suspend_control.go deleted file mode 100644 index 9da262eeb..000000000 --- a/apps/daemon/internal/cli/suspend_control.go +++ /dev/null @@ -1,146 +0,0 @@ -//go:build linux - -package cli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/signal" - "path/filepath" - "syscall" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE" - -type suspendIdentity struct { - PID int `json:"pid"` - StartTime string `json:"start_time"` - EnvironmentID string `json:"environment_id"` - SuspendID string `json:"suspend_id"` -} - -type suspendControl struct { - // Keep one consumed token across ordinary socket failures so Core can - // retry its acknowledgement. Arm replaces it before the next suspension. - lastResumed *proto.EnvironmentSuspendPayload - path string - identity suspendIdentity - signal chan os.Signal -} - -func newSuspendControl() (*suspendControl, error) { - path := os.Getenv(suspendControlEnv) - if path == "" { - return nil, nil - } - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return nil, errors.New("connect: absolute suspend control file required") - } - info, err := os.Lstat(filepath.Dir(path)) - if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 { - return nil, errors.New("connect: private suspend control directory required") - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || st.Uid != uint32(os.Getuid()) { - return nil, errors.New("connect: owned suspend control directory required") - } - environment := os.Getenv("OAC_RUNTIME_ENVIRONMENT_ID") - if environment == "" { - return nil, errors.New("connect: suspend control requires an Environment binding") - } - start, err := suspendProcessStart(os.Getpid()) - if err != nil { - return nil, err - } - c := &suspendControl{path: path, identity: suspendIdentity{PID: os.Getpid(), StartTime: start, EnvironmentID: environment}, signal: make(chan os.Signal, 1)} - // Refuse a second owner. A new microVM must not adopt another live daemon. - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) - if err != nil { - return nil, fmt.Errorf("connect: create suspend control: %w", err) - } - err = json.NewEncoder(f).Encode(c.identity) - closeErr := f.Close() - if err != nil || closeErr != nil { - _ = os.Remove(path) - return nil, errors.New("connect: write suspend control failed") - } - signal.Notify(c.signal, syscall.SIGUSR1) - return c, nil -} - -func (c *suspendControl) Arm(request proto.EnvironmentSuspendPayload) error { - if request.EnvironmentID != c.identity.EnvironmentID || request.SuspendID == "" { - return errors.New("suspension identity mismatch") - } - // Signals delivered while serving cannot authorize a future suspension. - for { - select { - case <-c.signal: - continue - default: - } - break - } - c.lastResumed = nil - c.identity.SuspendID = request.SuspendID - return c.save() -} - -func (c *suspendControl) save() error { - f, err := os.CreateTemp(filepath.Dir(c.path), ".suspend-*") - if err != nil { - return err - } - defer os.Remove(f.Name()) - err = json.NewEncoder(f).Encode(c.identity) - if err == nil { - err = f.Sync() - } - closeErr := f.Close() - if err != nil { - return err - } - if closeErr != nil { - return closeErr - } - return os.Rename(f.Name(), c.path) -} - -func (c *suspendControl) Wait(ctx context.Context) error { - select { - case <-ctx.Done(): - return ctx.Err() - case <-c.signal: - return nil - } -} - -func (c *suspendControl) Disarm() error { c.identity.SuspendID = ""; return c.save() } -func (c *suspendControl) Close() { signal.Stop(c.signal); _ = os.Remove(c.path) } - -func runResume(_ *runContext, args []string) error { - flags := newFlagSet("resume") - path := flags.String("control-file", "", "absolute hosted daemon control file") - environment := flags.String("environment-id", "", "expected Environment identity") - suspension := flags.String("suspend-id", "", "expected suspension identity") - if err := flags.Parse(args); err != nil { - return err - } - if flags.NArg() != 0 || *environment == "" || *suspension == "" { - return errors.New("resume: Environment and suspension identities required") - } - raw, err := readEnvironmentPrivateFile(*path) - if err != nil { - return errors.New("resume: private control file unavailable") - } - var identity suspendIdentity - if decodeEnvironmentJSON(raw, &identity) != nil || identity.PID <= 0 || identity.StartTime == "" || identity.EnvironmentID != *environment || identity.SuspendID != *suspension { - return errors.New("resume: suspension identity mismatch") - } - return signalSuspendedProcess(identity) -} diff --git a/apps/daemon/internal/cli/suspend_control_linux_test.go b/apps/daemon/internal/cli/suspend_control_linux_test.go deleted file mode 100644 index 83eaa1601..000000000 --- a/apps/daemon/internal/cli/suspend_control_linux_test.go +++ /dev/null @@ -1,67 +0,0 @@ -//go:build linux - -package cli - -import ( - "context" - "os" - "path/filepath" - "syscall" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestResumeControlRejectsStaleIdentityAndDiscardsPreParkSignals(t *testing.T) { - dir := t.TempDir() - if err := os.Chmod(dir, 0700); err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, "control.json") - t.Setenv(suspendControlEnv, path) - t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", "env") - control, err := newSuspendControl() - if err != nil { - t.Fatal(err) - } - defer control.Close() - control.signal <- syscall.SIGUSR1 - if err := control.Arm(proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "current"}); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - if err := control.Wait(ctx); err == nil { - t.Fatal("stale signal woke future suspension") - } - cancel() - if err := runResume(nil, []string{"--control-file", path, "--environment-id", "env", "--suspend-id", "stale"}); err == nil { - t.Fatal("stale suspension accepted") - } - saved := control.identity.StartTime - control.identity.StartTime = "wrong" - if err := control.save(); err != nil { - t.Fatal(err) - } - if err := runResume(nil, []string{"--control-file", path, "--environment-id", "env", "--suspend-id", "current"}); err == nil { - t.Fatal("reused PID accepted") - } - control.identity.StartTime = saved - if err := control.save(); err != nil { - t.Fatal(err) - } - if err := runResume(nil, []string{"--control-file", path, "--environment-id", "env", "--suspend-id", "current"}); err != nil { - t.Fatal(err) - } - ctx, cancel = context.WithTimeout(context.Background(), time.Second) - defer cancel() - if err := control.Wait(ctx); err != nil { - t.Fatal("validated pidfd signal did not wake owner", err) - } - if err := control.Disarm(); err != nil { - t.Fatal(err) - } - if err := runResume(nil, []string{"--control-file", path, "--environment-id", "env", "--suspend-id", "current"}); err == nil { - t.Fatal("completed suspension accepted another wake") - } -} diff --git a/apps/daemon/internal/cli/suspend_process_linux.go b/apps/daemon/internal/cli/suspend_process_linux.go deleted file mode 100644 index 556172408..000000000 --- a/apps/daemon/internal/cli/suspend_process_linux.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build linux - -package cli - -import ( - "errors" - "fmt" - "os" - "strings" - - "golang.org/x/sys/unix" -) - -func suspendProcessStart(pid int) (string, error) { - raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) - if err != nil { - return "", errors.New("suspend process identity unavailable") - } - // comm may contain whitespace and parentheses; fields after its last closing - // parenthesis begin at the process state (field 3), starttime is field 22. - end := strings.LastIndexByte(string(raw), ')') - if end < 0 { - return "", errors.New("invalid process identity") - } - fields := strings.Fields(string(raw[end+1:])) - if len(fields) <= 19 { - return "", errors.New("invalid process identity") - } - return fields[19], nil -} - -func signalSuspendedProcess(identity suspendIdentity) error { - // Pin the process before reading its identity, eliminating PID reuse between - // validation and signal delivery. A dead pidfd can never target its successor. - fd, err := unix.PidfdOpen(identity.PID, 0) - if err != nil { - return errors.New("resume: daemon unavailable") - } - defer unix.Close(fd) - start, err := suspendProcessStart(identity.PID) - if err != nil || start != identity.StartTime { - return errors.New("resume: process identity mismatch") - } - own, err := os.Stat("/proc/self/exe") - if err != nil { - return errors.New("resume: executable identity unavailable") - } - target, err := os.Stat(fmt.Sprintf("/proc/%d/exe", identity.PID)) - if err != nil || !os.SameFile(own, target) { - return errors.New("resume: executable identity mismatch") - } - if err := unix.PidfdSendSignal(fd, unix.SIGUSR1, nil, 0); err != nil { - return errors.New("resume: signal failed") - } - return nil -} diff --git a/apps/daemon/internal/cli/suspend_process_other.go b/apps/daemon/internal/cli/suspend_process_other.go deleted file mode 100644 index 5d57afa2d..000000000 --- a/apps/daemon/internal/cli/suspend_process_other.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build !linux - -package cli - -import ( - "context" - "errors" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "os" -) - -const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE" - -type suspendIdentity struct { - EnvironmentID string - SuspendID string -} -type suspendControl struct { - identity suspendIdentity - signal chan os.Signal - lastResumed *proto.EnvironmentSuspendPayload -} - -func newSuspendControl() (*suspendControl, error) { - if os.Getenv(suspendControlEnv) != "" { - return nil, errors.New("hosted suspension requires Linux") - } - return nil, nil -} -func (c *suspendControl) Arm(proto.EnvironmentSuspendPayload) error { - return errors.New("hosted suspension requires Linux") -} -func (c *suspendControl) Wait(context.Context) error { - return errors.New("hosted suspension requires Linux") -} -func (c *suspendControl) Disarm() error { return errors.New("hosted suspension requires Linux") } -func (c *suspendControl) Close() {} -func runResume(*runContext, []string) error { return errors.New("hosted suspension requires Linux") } diff --git a/apps/daemon/internal/daemonize/fork.go b/apps/daemon/internal/daemonize/fork.go index 120e7dd75..b892a9858 100644 --- a/apps/daemon/internal/daemonize/fork.go +++ b/apps/daemon/internal/daemonize/fork.go @@ -1,4 +1,4 @@ -// Package daemonize gives `oac-daemon connect -b` a no-cgo way to +// Package daemonize gives `oac-daemon start -b` a no-cgo way to // detach from the controlling terminal on macOS, Linux and Windows. Strategy is // re-exec-the-binary rather than POSIX double-fork: the parent opens // connect.log + connect.pid, then starts a fresh copy of its own diff --git a/apps/daemon/internal/daemonize/logfile_test.go b/apps/daemon/internal/daemonize/logfile_test.go index 5d1caeb6e..4f30f6af7 100644 --- a/apps/daemon/internal/daemonize/logfile_test.go +++ b/apps/daemon/internal/daemonize/logfile_test.go @@ -169,7 +169,7 @@ func TestEnsureLogFileIdempotentOnExisting(t *testing.T) { } func TestEnsureLogFileCreatesMissingParentDir(t *testing.T) { - // Regression: first-ever `oac-daemon connect -b` on a host without + // Regression: first-ever `oac-daemon start -b` on a host without // ~/.oac/daemon// used to fail with ENOENT — // O_CREATE only creates the file leaf. dir := privateTempDir(t) diff --git a/apps/daemon/internal/dispatch/environment_test.go b/apps/daemon/internal/dispatch/environment_test.go index c2ef1cac0..b6d775bd7 100644 --- a/apps/daemon/internal/dispatch/environment_test.go +++ b/apps/daemon/internal/dispatch/environment_test.go @@ -1,22 +1,150 @@ package dispatch_test import ( + "archive/tar" "context" "crypto/sha256" "encoding/hex" "errors" + "io" + "path" + "slices" + "strings" + "sync" "sync/atomic" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "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/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/google/uuid" ) +// testOwner is the Environment owner of one Session in one Environment, as +// the agent host's is. Its workspace is in memory: a path maps to a file's +// bytes, or to nil for a directory. prepare, when set, replaces Prepare's +// result. +type testOwner struct { + environment, session string + prepare func(agent.PrepareRequest) (agent.PrepareRequest, error) + mu sync.Mutex + files map[string][]byte +} + +func newTestOwner(environment, session string) *testOwner { + return &testOwner{environment: environment, session: session, files: map[string][]byte{}} +} + +// Resolve serves the owner's Session in its Environment and no other. +func (o *testOwner) Resolve(ref proto.AssignmentRef, bind proto.AssignmentBindPayload) dispatch.Environment { + if bind.EnvironmentID != o.environment || ref.SessionID != o.session { + return nil + } + return o +} + +// put adds a file, or a directory when data is nil. +func (o *testOwner) put(name string, data []byte) { + o.mu.Lock() + defer o.mu.Unlock() + o.files[name] = data +} + +// file returns the file at name. +func (o *testOwner) file(name string) ([]byte, bool) { + o.mu.Lock() + defer o.mu.Unlock() + data, ok := o.files[name] + return data, ok && data != nil +} + +func (o *testOwner) Configure(r proto.PromptRequestPayload) error { + local := r.LocalEnvironment + switch { + case local == nil || r.DisableExecutionEnvironment || local.ID != o.environment: + return errors.New("the request does not name the Session's Environment") + case r.WorkspaceReadOnly: + return nil + case local.WorkspaceDirectory != "/workspace": + return errors.New("the workspace is not /workspace") + case local.CapabilitySources == nil || agentcapabilities.ValidateInput(*local.CapabilitySources) != nil: + return agentcapabilities.ErrInvalid + } + return nil +} + +func (o *testOwner) Prepare(_ context.Context, r agent.PrepareRequest) (agent.PrepareRequest, error) { + if o.prepare != nil { + return o.prepare(r) + } + r.WorkspaceRoot = "/workspace" + return r, nil +} + +func (o *testOwner) ApplyRuntimePreparation(context.Context, uuid.UUID, proto.RuntimePreparePayload, []byte) error { + return dispatch.ErrEnvironmentUnavailable +} + +func (o *testOwner) ListWorkspaceDirectory(_ context.Context, dir string, limit int) (dispatch.WorkspaceDirectoryResult, error) { + if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || dir != "" && !proto.ValidWorkspacePath(dir) { + return dispatch.WorkspaceDirectoryResult{}, dispatch.ErrWorkspaceReadInvalid + } + o.mu.Lock() + defer o.mu.Unlock() + if data, ok := o.files[dir]; dir != "" && (!ok || data != nil) { + return dispatch.WorkspaceDirectoryResult{}, dispatch.ErrWorkspaceNotDirectory + } + parent := dir + if parent == "" { + parent = "." + } + result := dispatch.WorkspaceDirectoryResult{Entries: []dispatch.WorkspaceDirectoryEntry{}} + for name, data := range o.files { + if path.Dir(name) != parent { + continue + } + entry := dispatch.WorkspaceDirectoryEntry{Name: path.Base(name), Kind: "directory"} + if data != nil { + size := int64(len(data)) + entry.Kind, entry.SizeBytes = "file", &size + } + result.Entries = append(result.Entries, entry) + } + slices.SortFunc(result.Entries, func(a, b dispatch.WorkspaceDirectoryEntry) int { return strings.Compare(a.Name, b.Name) }) + if len(result.Entries) > limit { + result.Entries, result.Truncated = result.Entries[:limit], true + } + return result, nil +} + +// WriteWorkspaceFile creates a new file below plain directories, as Files.create does. +func (o *testOwner) WriteWorkspaceFile(_ context.Context, name string, data []byte) (dispatch.WorkspaceWriteResult, error) { + o.mu.Lock() + defer o.mu.Unlock() + if existing, ok := o.files[name]; ok && existing == nil { + return dispatch.WorkspaceWriteResult{}, dispatch.ErrWorkspaceWriteDirectory + } else if ok { + return dispatch.WorkspaceWriteResult{}, dispatch.ErrWorkspaceWriteUnsafe + } + for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) { + if data, ok := o.files[parent]; ok && data != nil { + return dispatch.WorkspaceWriteResult{}, dispatch.ErrWorkspaceWriteRejected + } + } + o.files[name] = append([]byte{}, data...) + return dispatch.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil +} + +// ExportOutputs exports no outputs. +func (o *testOwner) ExportOutputs(_ context.Context, w io.Writer) error { + return tar.NewWriter(w).Close() +} + +func (o *testOwner) Close(context.Context) error { return nil } + // assertPreparationOutcome waits for the terminal admission status of id. An // admitted request fails in the controlled factory; a rejected one sends only // its rejection. @@ -99,19 +227,6 @@ func TestLocalEnvironmentRequiresAvailableCapability(t *testing.T) { } } -// installingOwner is the bound workspace with one installed MCP server -// labelled label. -type installingOwner struct { - *localworkspace.Binding - label string -} - -func (o installingOwner) Prepare(ctx context.Context, req agent.PrepareRequest) (agent.PrepareRequest, error) { - req, err := o.Binding.Prepare(ctx, req) - req.MCP = append(req.MCP, agent.EnvironmentMCP{Server: agentplugin.MCPServer{Name: o.label, Type: "http", URL: "https://mcp.example"}}) - return req, err -} - // The owner resolves installed MCP servers during preparation, and the kind's // declaration checks them before the factory sees them. func TestInstalledMCPIsCheckedBeforeTheFactory(t *testing.T) { @@ -121,9 +236,14 @@ func TestInstalledMCPIsCheckedBeforeTheFactory(t *testing.T) { if err := h.router.Shutdown(t.Context()); err != nil { t.Fatal(err) } - owner := installingOwner{preparationWorkspace(t), label} + // The owner installs one MCP server labelled label. + owner := newTestOwner(preparationEnvironmentID, preparationSessionID) + owner.prepare = func(req agent.PrepareRequest) (agent.PrepareRequest, error) { + req.MCP = append(req.MCP, agent.EnvironmentMCP{Server: agentplugin.MCPServer{Name: label, Type: "http", URL: "https://mcp.example"}}) + return req, nil + } var err error - h.router, err = dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, Environments: func(proto.AssignmentRef, proto.AssignmentBindPayload) dispatch.Environment { return owner }}) + h.router, err = dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, Environments: owner.Resolve}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/functions_native_test.go b/apps/daemon/internal/dispatch/functions_native_test.go deleted file mode 100644 index 2d0a04007..000000000 --- a/apps/daemon/internal/dispatch/functions_native_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package dispatch_test - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "reflect" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" -) - -type nativeFunctionSender chan proto.Envelope - -func (s nativeFunctionSender) Send(ctx context.Context, e proto.Envelope) error { - select { - case s <- e: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func TestNativeFunctionBridge(t *testing.T) { - root := os.Getenv("OAC_TEST_NATIVE_PROOF_DIR") - if root == "" { - t.Skip("explicit native Codex binary and proof directory required") - } - home, err := os.MkdirTemp(root, "daemon-functions-") - if err != nil { - t.Fatal(err) - } - if err := os.Chmod(home, 0o700); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_HOME", home) - var count atomic.Int32 - model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - return - } - n := count.Add(1) - raw, _ := json.MarshalIndent(body, "", " ") - _ = os.WriteFile(filepath.Join(home, fmt.Sprintf("request-%d.json", n)), raw, 0600) - var item map[string]any - if n%2 == 1 { - if !strings.Contains(string(raw), "lookup_ticket") { - t.Error("tool was not registered") - } - item = map[string]any{"id": fmt.Sprintf("fc_%d", n), "type": "function_call", "call_id": fmt.Sprintf("call_%d", n), "name": "lookup_ticket", "arguments": `{"ticket":"42"}`, "status": "completed"} - } else { - - var request struct { - Input []struct { - Type string `json:"type"` - CallID string `json:"call_id"` - Output json.RawMessage `json:"output"` - } `json:"input"` - } - if err := json.Unmarshal(raw, &request); err != nil { - t.Error(err) - } - found := false - for _, entry := range request.Input { - if entry.Type != "function_call_output" || entry.CallID != fmt.Sprintf("call_%d", n-1) { - continue - } - found = true - var parts []proto.InputContent - if err := json.Unmarshal(entry.Output, &parts); err != nil { - t.Error(err) - continue - } - expected := functionResultContent("TICKET-RESULT") - if !reflect.DeepEqual(parts, expected) { - t.Errorf("native result lost text/image content or order: %s", entry.Output) - } - } - if !found { - t.Error("native model did not receive function result") - } - item = map[string]any{"id": fmt.Sprintf("msg_%d", n), "type": "message", "role": "assistant", "phase": "final_answer", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": "FUNCTION-OK", "annotations": []any{}}}} - } - w.Header().Set("Content-Type", "text/event-stream") - send := func(kind string, data map[string]any) { - data["type"] = kind - b, _ := json.Marshal(data) - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, b) - w.(http.Flusher).Flush() - } - send("response.created", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "status": "in_progress", "output": []any{}}}) - send("response.output_item.added", map[string]any{"output_index": 0, "item": item}) - send("response.output_item.done", map[string]any{"output_index": 0, "item": item}) - send("response.completed", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "object": "response", "created_at": time.Now().Unix(), "status": "completed", "model": "gpt-5.5", "output": []any{item}}}) - })) - defer model.Close() - reg := agent.NewRegistry() - registerExecutorKind(reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported, FunctionResultImages: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}, codex.NewExecutorFactory()) - sender := make(nativeFunctionSender, 256) - ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) - defer cancel() - // answer is the text of the last completed assistant message. - var answer string - await := func(kind string) proto.Envelope { - t.Helper() - for { - select { - case env := <-sender: - if env.Type == proto.TypeError { - t.Fatalf("native error: %s", env.Payload) - } - var message proto.OutputMessagePayload - if env.Type == proto.TypeOutputMessage && env.DecodePayload(&message) == nil && message.Text != nil { - answer = *message.Text - } - if env.Type == kind { - return env - } - case <-ctx.Done(): - t.Fatalf("waiting for %s; evidence %s", kind, home) - } - } - } - awaitReady := func(id string) proto.PreparationStatusPayload { - t.Helper() - for { - env := await(proto.TypePreparationStatus) - var status proto.PreparationStatusPayload - if env.ID != id { - continue - } - if err := env.DecodePayload(&status); err != nil { - t.Fatal(env, err) - } - if status.State == "ready" { - return status - } - if status.State != "preparing" { - t.Fatalf("preparation %s: %+v", id, status) - } - } - } - const session = "native-functions" - nativeID := "" - // Each Run owns a fresh Router, so every resume starts a new native process. - run := func(index int) { - router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := router.Shutdown(ctx); err != nil { - t.Error(err) - } - }() - run := fmt.Sprintf("run-%d", index) - answer = "" - assign(t, router, session, "") - request := noEnvironmentPreparation(session, proto.PromptRequestPayload{AgentKind: "codex", AgentSessionID: nativeID, - FunctionTools: []proto.FunctionTool{{Name: "lookup_ticket", Description: "Read a synthetic ticket", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":false}`)}}}) - request.Configuration.Model, request.Configuration.ModelProvider = "gpt-5.5", &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: model.URL + "/v1", APIKey: "synthetic-local-token"} - if err := router.Handle(ctx, scoped(t, session, proto.TypeExecutionPrepare, run, request)); err != nil { - t.Fatal(err) - } - ready := awaitReady(run) - if err := router.Handle(ctx, scoped(t, session, proto.TypeExecutionStart, run, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: run, Input: proto.TextInput("Look up ticket 42.")})); err != nil { - t.Fatal(err) - } - call := await(proto.TypeFunctionCall) - var payload proto.FunctionCallPayload - if err := call.DecodePayload(&payload); err != nil || call.ID != run || payload.Name != "lookup_ticket" { - t.Fatal(call, err) - } - if index == 2 { - if err := router.Handle(ctx, scoped(t, session, proto.TypePromptCancel, run, proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { - t.Fatal(err) - } - ack := await(proto.TypeInteractionDecisionAck) - var receipt proto.InteractionDecisionAckPayload - _ = ack.DecodePayload(&receipt) - if !receipt.Applied { - t.Fatal(receipt) - } - if err := router.Handle(ctx, scoped(t, session, proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: payload.CallID, Success: true, Content: functionResultContent("late"), DeliveryID: "late"})); err != nil { - t.Fatal(err) - } - _ = await(proto.TypeInteractionDecisionAck).DecodePayload(&receipt) - if receipt.Applied || receipt.ErrorCode != "not_pending" { - t.Fatal(receipt) - } - return - } - if err := router.Handle(ctx, scoped(t, session, proto.TypeFunctionResult, run, proto.FunctionResultPayload{CallID: payload.CallID, Success: index == 0, Content: functionResultContent("TICKET-RESULT"), DeliveryID: "result"})); err != nil { - t.Fatal(err) - } - ack := await(proto.TypeInteractionDecisionAck) - var receipt proto.InteractionDecisionAckPayload - _ = ack.DecodePayload(&receipt) - if !receipt.Applied { - t.Fatal(receipt) - } - done := await(proto.TypeDone) - var output proto.DonePayload - _ = done.DecodePayload(&output) - id, _ := output.Metadata[proto.DoneMetaAgentSessionID].(string) - if answer != "FUNCTION-OK" || id == "" || (nativeID != "" && id != nativeID) { - t.Fatal(output) - } - nativeID = id - } - for index := range 3 { - run(index) - } - t.Logf("Native function success/failure, fresh-process resume, cancellation and late-result rejection passed; evidence %s", home) -} diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index b5898d219..de8155ab8 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -3,28 +3,20 @@ package dispatch_test import ( "context" "errors" - "os" - "path/filepath" "sync/atomic" "testing" "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/google/uuid" ) func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing.T) { - workspace := t.TempDir() - environment, session := uuid.NewString(), preparationSessionID - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) - if err != nil { - t.Fatal(err) - } + owner := newTestOwner(environment, session) var harnessCalls atomic.Int32 reg := agent.NewRegistry() reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, prototest.ModelConfiguration()) @@ -33,7 +25,7 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing return nil, errors.New("must not prepare a harness") }) sender := &recSender{} - r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, Environments: binding.Resolve}) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, Environments: owner.Resolve}) if err != nil { t.Fatal(err) } @@ -87,22 +79,16 @@ func waitWorkspaceRead(t *testing.T, sender *recSender, id string) proto.Workspa } func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { - workspace := t.TempDir() - if err := os.WriteFile(filepath.Join(workspace, "file"), nil, 0600); err != nil { - t.Fatal(err) - } environment, session := uuid.NewString(), preparationSessionID - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) - if err != nil { - t.Fatal(err) - } + owner := newTestOwner(environment, session) + owner.put("file", []byte{}) reg := agent.NewRegistry() reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, prototest.ModelConfiguration()) reg.RegisterExecutor("native", func(context.Context, agent.PrepareRequest) (agent.Executor, error) { return nil, errors.New("must not prepare a harness") }) sender := &recSender{} - r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, Environments: binding.Resolve}) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, Environments: owner.Resolve}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index bbeaba392..5187a4c48 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -3,7 +3,6 @@ package dispatch_test import ( "context" "errors" - "os" "sync" "sync/atomic" "testing" @@ -11,7 +10,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "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/agentdaemon/proto/prototest" @@ -72,30 +70,6 @@ func (p *controlledPreparation) CancellationOutcome() proto.DonePayload { const preparationEnvironmentID = "11111111-1111-4111-8111-111111111111" const preparationSessionID = "22222222-2222-4222-8222-222222222222" -func preparationWorkspace(t *testing.T) *localworkspace.Binding { - t.Helper() - runtimeHome := t.TempDir() - if err := os.Chmod(runtimeHome, 0o700); err != nil { - t.Fatal(err) - } - for name, value := range map[string]string{ - "OAC_RUNTIME_ENVIRONMENT_ID": preparationEnvironmentID, - "OAC_RUNTIME_SESSION_ID": preparationSessionID, - "OAC_RUNTIME_WORKSPACE": t.TempDir(), - "OAC_RUNTIME_CAPABILITY_DIRECTORY": t.TempDir(), - "OAC_RUNTIME_HOME": runtimeHome, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", - "OAC_RUNTIME_ALLOWED_DOMAINS": "", - } { - t.Setenv(name, value) - } - binding, err := localworkspace.Load() - if err != nil { - t.Fatal(err) - } - return binding -} - func localPreparationHarness(t *testing.T) *harness { t.Helper() h := newHarness(t) @@ -103,7 +77,7 @@ func localPreparationHarness(t *testing.T) *harness { t.Fatal(err) } var err error - h.router, err = dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, Environments: preparationWorkspace(t).Resolve}) + h.router, err = dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, Environments: newTestOwner(preparationEnvironmentID, preparationSessionID).Resolve}) if err != nil { t.Fatal(err) } @@ -116,11 +90,18 @@ func preparationRequest() proto.ExecutionPreparePayload { } func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router { + t.Helper() + return ownedPreparationRouter(t, sender, timeout, factory, newTestOwner(preparationEnvironmentID, preparationSessionID)) +} + +// ownedPreparationRouter is preparationRouter with owner as the Session's +// Environment owner. +func ownedPreparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory, owner *testOwner) *dispatch.Router { t.Helper() reg := agent.NewRegistry() reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, FunctionResultImages: proto.CapabilitySupported})}, prototest.ModelConfiguration()) reg.RegisterExecutor("prepared", preparationExecutorFixture(factory)) - r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, Environments: preparationWorkspace(t).Resolve}) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, Environments: owner.Resolve}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go index 9c57c7112..ecedbeb66 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go @@ -3,52 +3,34 @@ package dispatch_test import ( "context" "errors" - "os" - "path/filepath" "sync/atomic" "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// Directory selection is syntactically valid and passes frozen configuration -// admission. Runtime preparation must fail before creating a native Executor. +// A request that passes configuration admission but whose Environment the +// owner cannot prepare, as with an unreadable capability source, fails before +// the native Executor is created. func TestRuntimePreparationUnavailablePreventsNativeExecutor(t *testing.T) { - for _, mode := range []string{"missing-directory", "invalid-skill"} { - t.Run(mode, func(t *testing.T) { - source := filepath.Join(t.TempDir(), "capability") - if mode == "invalid-skill" { - if err := os.Mkdir(source, 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("not a portable Skill"), 0600); err != nil { - t.Fatal(err) - } - } - sender := &recSender{} - var calls atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { - calls.Add(1) - return nil, errors.New("native factory must not be reached") - }) - installation := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") - if info, err := os.Stat(installation); err != nil || !info.IsDir() { - t.Fatalf("capability fixture is unavailable: %v", err) - } - request := preparationRequest() - request.Configuration.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "missing-capability", request)); err != nil { - t.Fatalf("valid frozen selection rejected before preparation: %v", err) - } - status := waitPreparationStatus(t, sender, "missing-capability", "failed", "") - if _, err := os.Stat(filepath.Join(installation, agentcapabilities.ManifestName)); !os.IsNotExist(err) { - t.Fatal("invalid source published an installed snapshot") - } - if status.ErrorCode != "preparation_failed" || calls.Load() != 0 || r.ActiveRuns() != 0 { - t.Fatalf("failed capability preparation reached native execution: status=%+v calls=%d", status, calls.Load()) - } - }) + sender := &recSender{} + var calls atomic.Int32 + owner := newTestOwner(preparationEnvironmentID, preparationSessionID) + owner.prepare = func(r agent.PrepareRequest) (agent.PrepareRequest, error) { return r, agentcapabilities.ErrInvalid } + r := ownedPreparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { + calls.Add(1) + return nil, errors.New("native factory must not be reached") + }, owner) + request := preparationRequest() + request.Configuration.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{"/capabilities/missing"}} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "missing-capability", request)); err != nil { + t.Fatalf("valid frozen selection rejected before preparation: %v", err) + } + status := waitPreparationStatus(t, sender, "missing-capability", "failed", "") + if status.ErrorCode != "preparation_failed" || calls.Load() != 0 || r.ActiveRuns() != 0 { + t.Fatalf("failed capability preparation reached native execution: status=%+v calls=%d", status, calls.Load()) } } diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go index 840f68cf1..2c0407850 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -226,7 +226,7 @@ func TestRuntimePreparationUploadBlocksWorkspaceWriteAndSuspension(t *testing.T) t.Fatal(err) } capabilitiesReceipt(t, sender, id, "ready") - if err := r.Quiesce(t.Context(), capabilityRef, proto.EnvironmentSuspendPayload{EnvironmentID: environment, SuspendID: uuid.NewString()}); !errors.Is(err, ErrRouterBusy) { + if err := r.fenceEnvironment(capabilityRef, proto.EnvironmentSuspendPayload{EnvironmentID: environment, SuspendID: uuid.NewString()}); !errors.Is(err, ErrRouterBusy) { t.Fatal(err) } digest := sha256.Sum256([]byte("abc")) diff --git a/apps/daemon/internal/dispatch/suspend.go b/apps/daemon/internal/dispatch/suspend.go index 64b3d44bf..c1f04eb02 100644 --- a/apps/daemon/internal/dispatch/suspend.go +++ b/apps/daemon/internal/dispatch/suspend.go @@ -29,38 +29,6 @@ type suspension struct { by proto.AssignmentRef } -// Quiesce quiesces the request's Environment for a caller that then -// suspends the whole Runtime: after fencing the Environment it waits for every -// output and receipt admitted on the connection, then closes the -// Environment's Executors and owners before it returns. Busy rejection leaves -// admission open; a failed drain keeps the Environment quiesced until a -// matching resume or shutdown. ref must admit work in the Environment. -func (r *Router) Quiesce(ctx context.Context, ref proto.AssignmentRef, request proto.EnvironmentSuspendPayload) error { - if err := r.fenceEnvironment(ref, request); err != nil { - return err - } - if err := r.shutdownWG.waitContext(ctx); err != nil { - return err - } - return r.drainEnvironment(ctx, request.EnvironmentID) -} - -// Resume reopens the quiesced Environment and replaces the sender, after the -// caller authenticated a new connection and Core confirmed the exact -// suspension on it under the assignment that quiesced. -func (r *Router) Resume(ref proto.AssignmentRef, request proto.EnvironmentSuspendPayload, sender Sender) error { - if sender == nil { - return errors.New("dispatch: no sender") - } - r.mu.Lock() - defer r.mu.Unlock() - if err := r.resumeLocked(ref, request); err != nil { - return err - } - r.sender = sender - return nil -} - // handleQuiesce quiesces an Environment for Core on this connection. It // replies environment_quiesced once the drain settles, without blocking // Handle; a failed drain replies resource_busy. diff --git a/apps/daemon/internal/dispatch/suspend_test.go b/apps/daemon/internal/dispatch/suspend_test.go index afe1debca..60de1bb11 100644 --- a/apps/daemon/internal/dispatch/suspend_test.go +++ b/apps/daemon/internal/dispatch/suspend_test.go @@ -3,7 +3,6 @@ package dispatch import ( "context" "errors" - "sync" "sync/atomic" "testing" "time" @@ -52,6 +51,31 @@ func suspensionRouter(t *testing.T, sender Sender) *Router { return r } +// suspendResult sends a quiesce or resume through Handle and returns its +// result. +func suspendResult(t *testing.T, r *Router, frames <-chan proto.Envelope, typ, id string, ref proto.AssignmentRef, request proto.EnvironmentSuspendPayload) proto.EnvironmentSuspendResultPayload { + t.Helper() + env, err := proto.NewEnvelope(typ, id, request) + if err != nil { + t.Fatal(err) + } + env.Assignment = ref + if err := r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + for { + select { + case frame := <-frames: + var result proto.EnvironmentSuspendResultPayload + if frame.ID == id && frame.DecodePayload(&result) == nil { + return result + } + case <-time.After(3 * time.Second): + t.Fatalf("%s %s has no result", typ, id) + } + } +} + func TestQuiesceRejectsEveryUnsettledResource(t *testing.T) { session := suspendRef.SessionID cases := map[string]func(*Router){ @@ -68,7 +92,7 @@ func TestQuiesceRejectsEveryUnsettledResource(t *testing.T) { t.Run(name, func(t *testing.T) { r := suspensionRouter(t, suspendSender(func(context.Context, proto.Envelope) error { return nil })) setup(r) - err := r.Quiesce(context.Background(), suspendRef, proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"}) + err := r.fenceEnvironment(suspendRef, proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"}) if !errors.Is(err, ErrRouterBusy) { t.Fatalf("quiesce=%v", err) } @@ -84,76 +108,26 @@ func TestQuiesceRejectsEveryUnsettledResource(t *testing.T) { } } -func TestQuiesceDrainsPendingReceiptAndFencesConcurrentAdmission(t *testing.T) { - entered, release, once := make(chan struct{}), make(chan struct{}), sync.Once{} - r := suspensionRouter(t, suspendSender(func(context.Context, proto.Envelope) error { once.Do(func() { close(entered); <-release }); return nil })) - // Rejection receipts run independently of the preparation resource map. - _ = r.Handle(context.Background(), proto.Envelope{Type: proto.TypeExecutionPrepare, ID: "invalid"}) - <-entered - request := proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"} - quiet := make(chan error, 1) - go func() { quiet <- r.Quiesce(context.Background(), suspendRef, request) }() - deadline := time.After(time.Second) - for { - r.mu.Lock() - parked := r.suspensions["env"] != nil - r.mu.Unlock() - if parked { - break - } - select { - case <-deadline: - t.Fatal("quiesce did not fence admission") - default: - time.Sleep(time.Millisecond) - } - } - admitted := make(chan error, 1) - go func() { - admitted <- r.Handle(context.Background(), proto.Envelope{Type: proto.TypeExecutionPrepare, ID: "late", Assignment: suspendRef}) - }() - select { - case err := <-quiet: - t.Fatalf("acknowledged before receipt settled: %v", err) - case <-time.After(20 * time.Millisecond): - } - close(release) - if err := <-quiet; err != nil { - t.Fatal(err) - } - if err := <-admitted; !errors.Is(err, ErrRouterQuiesced) { - t.Fatalf("new admission = %v", err) - } -} - func TestResumeRequiresExactSuspensionAndAssignment(t *testing.T) { - sender := suspendSender(func(context.Context, proto.Envelope) error { return nil }) - r := suspensionRouter(t, sender) + frames := make(chan proto.Envelope, 16) + r := suspensionRouter(t, suspendSender(func(_ context.Context, env proto.Envelope) error { frames <- env; return nil })) request := proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"} foreign := suspendRef foreign.AssignmentID = "other" - if err := r.Quiesce(context.Background(), foreign, request); !errors.Is(err, AssignmentError(proto.AssignmentConflict)) { - t.Fatalf("foreign quiesce = %v", err) + if got := suspendResult(t, r, frames, proto.TypeEnvironmentQuiesce, "foreign", foreign, request); got.ErrorCode != proto.AssignmentConflict { + t.Fatalf("foreign quiesce = %+v", got) } - if err := r.Quiesce(context.Background(), suspendRef, request); err != nil { - t.Fatal(err) - } - wrong := request - wrong.SuspendID = "obsolete" - if err := r.Resume(suspendRef, wrong, sender); err == nil { - t.Fatal("stale operation reopened admission") - } - if err := r.Resume(foreign, request, sender); err == nil { - t.Fatal("foreign assignment reopened admission") + if got := suspendResult(t, r, frames, proto.TypeEnvironmentQuiesce, "quiesce", suspendRef, request); !got.Accepted { + t.Fatalf("quiesce = %+v", got) } - // Another bound assignment did not quiesce the Runtime. + // Another bound assignment did not quiesce the Environment. other := proto.AssignmentRef{SessionID: "other", AssignmentID: "other", Epoch: 1} bindAssignment(r, other, "env") - if err := r.Resume(other, request, sender); !errors.Is(err, AssignmentError(proto.AssignmentConflict)) { - t.Fatalf("other assignment resume = %v", err) + if got := suspendResult(t, r, frames, proto.TypeEnvironmentResume, "other", other, request); got.ErrorCode != proto.AssignmentConflict { + t.Fatalf("other assignment resume = %+v", got) } - if err := r.Resume(suspendRef, request, sender); err != nil { - t.Fatal(err) + if got := suspendResult(t, r, frames, proto.TypeEnvironmentResume, "resume", suspendRef, request); !got.Accepted { + t.Fatalf("resume = %+v", got) } } @@ -199,25 +173,7 @@ func TestQuiescingOneEnvironmentLeavesAnotherRunning(t *testing.T) { r.mu.Unlock() suspend := func(typ, id string, ref proto.AssignmentRef, request proto.EnvironmentSuspendPayload) proto.EnvironmentSuspendResultPayload { t.Helper() - env, err := proto.NewEnvelope(typ, id, request) - if err != nil { - t.Fatal(err) - } - env.Assignment = ref - if err := r.Handle(t.Context(), env); err != nil { - t.Fatal(err) - } - for { - select { - case frame := <-frames: - var result proto.EnvironmentSuspendResultPayload - if frame.ID == id && frame.DecodePayload(&result) == nil { - return result - } - case <-time.After(3 * time.Second): - t.Fatalf("%s %s has no result", typ, id) - } - } + return suspendResult(t, r, frames, typ, id, ref, request) } prepare := func(ref proto.AssignmentRef) error { return r.Handle(t.Context(), proto.Envelope{Type: proto.TypeExecutionPrepare, ID: "prepare", Assignment: ref}) @@ -270,32 +226,36 @@ func TestQuiescingOneEnvironmentLeavesAnotherRunning(t *testing.T) { } func TestShutdownDestroysQuiescedOwnerAndCannotResume(t *testing.T) { - sender := suspendSender(func(context.Context, proto.Envelope) error { return nil }) - r := suspensionRouter(t, sender) + frames := make(chan proto.Envelope, 16) + r := suspensionRouter(t, suspendSender(func(_ context.Context, env proto.Envelope) error { frames <- env; return nil })) request := proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"} - if err := r.Quiesce(context.Background(), suspendRef, request); err != nil { - t.Fatal(err) + if got := suspendResult(t, r, frames, proto.TypeEnvironmentQuiesce, "quiesce", suspendRef, request); !got.Accepted { + t.Fatalf("quiesce = %+v", got) } if err := r.Shutdown(context.Background()); err != nil { t.Fatal(err) } - if !errors.Is(r.Resume(suspendRef, request, sender), ErrRouterClosed) { - t.Fatal("closed Router resurrected") + if got := suspendResult(t, r, frames, proto.TypeEnvironmentResume, "resume", suspendRef, request); got.Accepted || got.ErrorCode != "resource_busy" { + t.Fatalf("the closed Router's resume = %+v", got) } } func TestQuiesceDrainDeadlineCannotReopenAdmission(t *testing.T) { r := suspensionRouter(t, suspendSender(func(context.Context, proto.Envelope) error { return nil })) - r.shutdownWG.Add(1) + if err := r.fenceEnvironment(suspendRef, proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"}); err != nil { + t.Fatal(err) + } + work := &r.assignments[suspendRef.SessionID].work + work.Add(1) ctx, cancel := context.WithCancel(context.Background()) cancel() - if err := r.Quiesce(ctx, suspendRef, proto.EnvironmentSuspendPayload{EnvironmentID: "env", SuspendID: "attempt"}); !errors.Is(err, context.Canceled) { - t.Fatalf("quiesce=%v", err) + if err := r.drainEnvironment(ctx, "env"); !errors.Is(err, context.Canceled) { + t.Fatalf("drain=%v", err) } if err := r.Handle(context.Background(), proto.Envelope{Type: proto.TypeExecutionPrepare, ID: "late", Assignment: suspendRef}); !errors.Is(err, ErrRouterQuiesced) { t.Fatalf("deadline reopened admission: %v", err) } - r.shutdownWG.Done() + work.Done() // Starting cleanup after the drain observer timed out must not reuse a // sync.WaitGroup while an abandoned waiter is still returning from Wait. if err := r.Shutdown(context.Background()); err != nil { diff --git a/apps/daemon/internal/dispatch/workspace_directory_test.go b/apps/daemon/internal/dispatch/workspace_directory_test.go index 68b006490..8c1adb1fd 100644 --- a/apps/daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/daemon/internal/dispatch/workspace_directory_test.go @@ -3,8 +3,6 @@ package dispatch_test import ( "context" "fmt" - "os" - "path/filepath" "testing" "time" @@ -17,12 +15,10 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { p.start = func(ctx context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (fixtureSession, error) { return &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) - for _, name := range []string{"file", "second"} { - if err := os.WriteFile(filepath.Join(os.Getenv("OAC_RUNTIME_WORKSPACE"), name), []byte("abc"), 0600); err != nil { - t.Fatal(err) - } - } + owner := newTestOwner(preparationEnvironmentID, preparationSessionID) + owner.put("file", []byte("abc")) + owner.put("second", []byte("abc")) + r := ownedPreparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }, owner) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) ready := waitPreparationStatus(t, sender, "prepare", "ready", "") request := proto.WorkspaceReadPayload{Handle: ready.Handle, EnvironmentID: preparationEnvironmentID, MaxEntries: 1} diff --git a/apps/daemon/internal/dispatch/workspace_write_test.go b/apps/daemon/internal/dispatch/workspace_write_test.go index 88b4360ad..8ed6b6b46 100644 --- a/apps/daemon/internal/dispatch/workspace_write_test.go +++ b/apps/daemon/internal/dispatch/workspace_write_test.go @@ -4,29 +4,22 @@ import ( "context" "crypto/sha256" "encoding/hex" - "os" - "path/filepath" "testing" "testing/synctest" "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) -func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, string) { +func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, *testOwner) { t.Helper() - workspace := t.TempDir() environment, session := uuid.NewString(), preparationSessionID - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) - if err != nil { - t.Fatal(err) - } + owner := newTestOwner(environment, session) sender := &recSender{} - r, err := dispatch.New(dispatch.Config{Registry: agent.NewRegistry(), Sender: sender, Environments: binding.Resolve}) + r, err := dispatch.New(dispatch.Config{Registry: agent.NewRegistry(), Sender: sender, Environments: owner.Resolve}) if err != nil { t.Fatal(err) } @@ -37,7 +30,7 @@ func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.Worksp }) assign(t, r, session, environment) digest := sha256.Sum256([]byte("abc")) - return r, sender, proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment, SessionID: session, Path: "file", SizeBytes: 3, SHA256: hex.EncodeToString(digest[:])}, workspace + return r, sender, proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment, SessionID: session, Path: "file", SizeBytes: 3, SHA256: hex.EncodeToString(digest[:])}, owner } func waitWorkspaceWrite(t *testing.T, sender *recSender, id, outcome string) proto.WorkspaceWriteResultPayload { @@ -63,7 +56,7 @@ func waitWorkspaceWrite(t *testing.T, sender *recSender, id, outcome string) pro } func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t) + r, sender, request, owner := localWriterRouter(t) for _, field := range []string{"environment", "session"} { bad := request if field == "environment" { @@ -90,7 +83,7 @@ func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { t.Fatal(err) } } - if _, err := os.Stat(filepath.Join(workspace, "file")); !os.IsNotExist(err) { + if _, ok := owner.file("file"); ok { t.Fatal("file created before commit") } if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})); err != nil { @@ -99,15 +92,15 @@ func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { if got := waitWorkspaceWrite(t, sender, id, "completed"); got.SizeBytes != 3 { t.Fatal(got) } - if data, err := os.ReadFile(filepath.Join(workspace, "file")); err != nil || string(data) != "abc" { - t.Fatal("committed bytes differ", err) + if data, _ := owner.file("file"); string(data) != "abc" { + t.Fatal("committed bytes differ", data) } } func TestLocalUploadRejectsReorderedOrCorruptBodiesWithoutMutation(t *testing.T) { for _, mode := range []string{"offset", "digest", "short"} { t.Run(mode, func(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t) + r, sender, request, owner := localWriterRouter(t) id := uuid.NewString() _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, request)) chunk := proto.WorkspaceWritePayload{Step: "chunk", Data: []byte("abc")} @@ -122,7 +115,7 @@ func TestLocalUploadRejectsReorderedOrCorruptBodiesWithoutMutation(t *testing.T) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, chunk)) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})) waitWorkspaceWrite(t, sender, id, "rejected") - if _, err := os.Stat(filepath.Join(workspace, "file")); !os.IsNotExist(err) { + if _, ok := owner.file("file"); ok { t.Fatal("bad transfer mutated workspace") } }) @@ -136,20 +129,14 @@ func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) { "write_failed": "", } { t.Run(helperError, func(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t) + r, sender, request, owner := localWriterRouter(t) switch helperError { case "destination_directory": - if err := os.Mkdir(filepath.Join(workspace, "file"), 0700); err != nil { - t.Fatal(err) - } + owner.put("file", nil) case "unsafe_destination": - if err := os.WriteFile(filepath.Join(workspace, "file"), []byte("existing"), 0600); err != nil { - t.Fatal(err) - } + owner.put("file", []byte("existing")) case "write_failed": - if err := os.WriteFile(filepath.Join(workspace, "parent"), nil, 0600); err != nil { - t.Fatal(err) - } + owner.put("parent", []byte{}) request.Path = "parent/file" } id := uuid.NewString() @@ -171,7 +158,7 @@ func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) { } func TestReleaseFencesUnfinishedWorkspaceWrite(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t) + r, sender, request, owner := localWriterRouter(t) id := uuid.NewString() if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, request)); err != nil { t.Fatal(err) @@ -203,14 +190,14 @@ func TestReleaseFencesUnfinishedWorkspaceWrite(t *testing.T) { if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})); err != nil { t.Fatal(err) } - if _, err := os.Stat(filepath.Join(workspace, "file")); !os.IsNotExist(err) { - t.Fatal("a released assignment's write applied", err) + if _, ok := owner.file("file"); ok { + t.Fatal("a released assignment's write applied") } } func TestReleaseWaitsUntilTheWriteResultIsSent(t *testing.T) { synctest.Test(t, func(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t) + r, sender, request, owner := localWriterRouter(t) sent := make(chan struct{}) sender.hold = func(env proto.Envelope) { var result proto.WorkspaceWriteResultPayload @@ -236,8 +223,8 @@ func TestReleaseWaitsUntilTheWriteResultIsSent(t *testing.T) { t.Fatal(got) } waitWorkspaceWrite(t, sender, id, "completed") - if data, err := os.ReadFile(filepath.Join(workspace, "file")); err != nil || string(data) != "abc" { - t.Fatal("the committed write did not apply", err) + if data, _ := owner.file("file"); string(data) != "abc" { + t.Fatal("the committed write did not apply", data) } }) } diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go deleted file mode 100644 index e5b67cd90..000000000 --- a/apps/daemon/internal/localworkspace/binding.go +++ /dev/null @@ -1,93 +0,0 @@ -package localworkspace - -import ( - "context" - "errors" - "os" - "strings" - "sync" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// Binding freezes operator-owned identity and paths for one Runtime lifetime. -type Binding struct { - environment string - networkAccess string - allowedDomains []string - session string - workspace string - writer *fileWriter - capabilityMu sync.Mutex - capabilityRoot string -} - -// NewWithCapabilityDirectory freezes paths selected by the Runtime operator. -func NewWithCapabilityDirectory(environment, session, workspace, directory string) (*Binding, error) { - return newNativeBinding(environment, session, workspace, directory) -} - -func Load() (*Binding, error) { - values := []string{os.Getenv("OAC_RUNTIME_ENVIRONMENT_ID"), os.Getenv("OAC_RUNTIME_SESSION_ID"), os.Getenv("OAC_RUNTIME_WORKSPACE")} - policy, err := RuntimeNetworkPolicy() - if err != nil { - return nil, err - } - network := policy.Access - capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") - if strings.Join(values, "") == "" && network == "" && capabilityDirectory == "" { - return nil, nil - } - if capabilityDirectory == "" { - capabilityDirectory = agentcapabilities.Directory - } - b, err := NewWithCapabilityDirectory(values[0], values[1], values[2], capabilityDirectory) - if err != nil { - return nil, err - } - b.networkAccess = network - b.allowedDomains = policy.Hosts() - - return b, nil -} - -// Configure checks the request against the bound Environment and workspace. -func (b *Binding) Configure(r proto.PromptRequestPayload) error { - local := r.LocalEnvironment - switch { - case local == nil || local.ID != b.environment || r.DisableExecutionEnvironment: - return errors.New("request does not match the dedicated local Environment") - case r.WorkspaceReadOnly: - return nil - case local.WorkspaceDirectory != "/workspace" && local.WorkspaceDirectory != b.workspace: - return errors.New("request does not match the local workspace selection") - case local.CapabilitySources == nil || agentcapabilities.ValidateInput(*local.CapabilitySources) != nil: - return agentcapabilities.ErrInvalid - } - return nil -} - -// Resolve is the Session's Environment owner: b for the one Session it is -// bound to, and none for any other. -func (b *Binding) Resolve(ref proto.AssignmentRef, bind proto.AssignmentBindPayload) dispatch.Environment { - if !b.Matches(bind.EnvironmentID, ref.SessionID) { - return nil - } - return b -} - -// Support is what the guest serves: its bound Session's local Environment, -// or, when b is nil, environment none. -func (b *Binding) Support() agent.EnvironmentSupport { - return agent.EnvironmentSupport{Local: b != nil, None: b == nil} -} - -func (b *Binding) Matches(environment, session string) bool { - return b != nil && b.environment == environment && b.session == session -} - -// Close keeps the workspace, which outlives each assignment of its Session. -func (b *Binding) Close(context.Context) error { return nil } diff --git a/apps/daemon/internal/localworkspace/binding_test.go b/apps/daemon/internal/localworkspace/binding_test.go deleted file mode 100644 index efa036b82..000000000 --- a/apps/daemon/internal/localworkspace/binding_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package localworkspace - -import ( - "os" - "path/filepath" - "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/google/uuid" -) - -func testBinding(t *testing.T) (*Binding, agent.PrepareRequest) { - t.Helper() - private := t.TempDir() - if err := os.Chmod(private, 0700); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_HOME", private) - root := t.TempDir() - environment, session := uuid.NewString(), uuid.NewString() - b, err := NewWithCapabilityDirectory(environment, session, root, t.TempDir()) - if err != nil { - t.Fatal(err) - } - return b, agent.PrepareRequest{PromptRequestPayload: proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} -} - -func TestBindingRejectsScopeOverrides(t *testing.T) { - b, prepared := testBinding(t) - valid := prepared.PromptRequestPayload - if err := b.Configure(valid); err != nil { - t.Fatal(err) - } - for name, mutate := range map[string]func(*proto.PromptRequestPayload){ - "missing reference": func(r *proto.PromptRequestPayload) { r.LocalEnvironment = nil }, - "other Environment": func(r *proto.PromptRequestPayload) { - r.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString()} - }, - "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, - } { - t.Run(name, func(t *testing.T) { - r := valid - mutate(&r) - if err := b.Configure(r); err == nil { - t.Fatal("unsafe request accepted") - } - }) - } -} - -func TestDirectoryValidatesRelativePaths(t *testing.T) { - b, _ := testBinding(t) - got, err := b.ListWorkspaceDirectory(t.Context(), "", 2) - if err != nil || got.Entries == nil || len(got.Entries) != 0 || got.Truncated { - t.Fatalf("empty directory: %+v %v", got, err) - } - for _, path := range []string{"/etc", "..", "a/../b", "a//b", ".", "a\\b"} { - if _, err := b.ListWorkspaceDirectory(t.Context(), path, 2); err == nil { - t.Fatalf("invalid path accepted: %q", path) - } - } -} - -func TestCapabilityLayoutUsesOperatorDirectories(t *testing.T) { - b, _ := testBinding(t) - for _, directory := range []string{filepath.Join(b.workspace, "capabilities"), filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "capabilities")} { - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, directory); err != nil { - t.Fatal(err) - } - } - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "relative"); err == nil { - t.Fatal("relative installation path accepted") - } -} diff --git a/apps/daemon/internal/localworkspace/capabilities.go b/apps/daemon/internal/localworkspace/capabilities.go deleted file mode 100644 index a85b24143..000000000 --- a/apps/daemon/internal/localworkspace/capabilities.go +++ /dev/null @@ -1,203 +0,0 @@ -package localworkspace - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - - "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/runtimefs" - "github.com/google/uuid" -) - -// Prepare runs under the admitted executor's lifetime, before native startup. -// Reconnection validates installed contents without reopening mutable sources. -func (b *Binding) Prepare(ctx context.Context, r agent.PrepareRequest) (agent.PrepareRequest, error) { - if b == nil && r.LocalEnvironment == nil || r.WorkspaceReadOnly { - return r, nil - } - if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.CapabilitySources == nil || r.LocalEnvironment.ID != b.environment { - return r, agentcapabilities.ErrInvalid - } - b.capabilityMu.Lock() - defer b.capabilityMu.Unlock() - marker, err := b.openSnapshotMarker() - if err != nil { - return r, err - } - defer marker.close() - root, unlock, err := b.openCapabilities(ctx, marker.completed) - if err != nil { - return r, err - } - defer unlock() - input := *r.LocalEnvironment.CapabilitySources - identity := b.capabilityIdentity() - if err := prepareToolEnvironment(r.LocalEnvironment.ToolEnvironment, marker.completed); err != nil { - return r, err - } - manifest, err := b.loadCapabilitySnapshot(root, input, identity, marker.completed) - if err != nil || marker.complete() != nil { - return r, agentcapabilities.ErrInvalid - } - if err = ctx.Err(); err != nil { - return r, err - } - r.WorkspaceRoot, r.CapabilityRoot, r.Skills, r.MCP = b.workspace, b.capabilityRoot, manifest.Skills, nil - for i := range r.Skills { - r.Skills[i].InstallationRoot = b.capabilityRoot - } - if r.LocalEnvironment.ToolEnvironment { - if _, err = ReadToolEnvironment(); err != nil { - return r, err - } - } - if len(manifest.MCP) != 0 { - if b.NetworkPolicy().Access != "enabled" { - return r, agentcapabilities.ErrInvalid - } - values, readErr := b.ReadToolEnvironment() - if readErr != nil { - return r, readErr - } - r.MCP, err = resolveEnvironmentMCP(manifest.MCP, values) - for i := range r.MCP { - r.MCP[i].InstallationRoot = b.capabilityRoot - r.MCP[i].WorkspaceRoot = b.workspace - } - if err != nil { - return r, err - } - } - return r, nil -} - -// ApplyRuntimePreparation settles every filesystem operation before returning. A -// cancelled caller never leaves an unowned installation goroutine behind. -func (b *Binding) ApplyRuntimePreparation(ctx context.Context, _ uuid.UUID, input proto.RuntimePreparePayload, data []byte) error { - if b == nil || !b.Matches(input.EnvironmentID, input.SessionID) || !proto.ValidRuntimePrepareRequest(input) || input.Step != "begin" { - return agentcapabilities.ErrInvalid - } - b.capabilityMu.Lock() - defer b.capabilityMu.Unlock() - marker, err := b.openSnapshotMarker() - if err != nil { - return err - } - defer marker.close() - if marker.completed { - return agentcapabilities.ErrInvalid - } - if err := ctx.Err(); err != nil { - return err - } - switch input.Action { - case "file": - if len(data) != input.SizeBytes { - return agentcapabilities.ErrInvalid - } - return b.installInitialFile(ctx, *input.File, data) - case "initialize": - if len(data) != 0 { - return agentcapabilities.ErrInvalid - } - return b.initializeRuntime(ctx, *input.Initialization) - } - root, unlock, err := b.openCapabilities(ctx, false) - if err != nil { - return err - } - defer unlock() - switch input.Action { - case "skill": - err = agentcapabilities.InstallSkill(root, data, *input.Skill) - case "plugin": - err = agentcapabilities.InstallPlugin(root, input.Slot, data, *input.Plugin) - case "finalize": - if err := prepareToolEnvironment(false, false); err != nil { - return err - } - _, err = b.loadCapabilitySnapshot(root, *input.Sources, b.capabilityIdentity(), false) - if err == nil { - err = marker.complete() - } - default: - err = agentcapabilities.ErrInvalid - } - if err != nil { - return agentcapabilities.ErrInvalid - } - return ctx.Err() -} - -func (b *Binding) loadCapabilitySnapshot(root *os.Root, input agentcapabilities.Input, identity agentcapabilities.Identity, completed bool) (agentcapabilities.Manifest, error) { - if _, err := root.Lstat(agentcapabilities.ManifestName); errors.Is(err, os.ErrNotExist) { - if completed || agentcapabilities.Finalize(root, input, identity, b.resolveCapabilityDirectory) != nil { - return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid - } - } else if err != nil { - return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid - } - manifest, err := agentcapabilities.Load(root) - if err != nil || agentcapabilities.ValidateSelection(manifest, input, identity) != nil { - return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid - } - return manifest, nil -} - -func (b *Binding) capabilityIdentity() agentcapabilities.Identity { - return agentcapabilities.Identity{EnvironmentID: b.environment, SessionID: b.session} -} - -// The current Linux Runtime layout is shared by user-owned and managed hosts. -// Deployment paths never come from a capability transport request. -func (b *Binding) openCapabilities(ctx context.Context, completed bool) (*os.Root, func(), error) { - if err := ctx.Err(); err != nil { - return nil, nil, err - } - if b.capabilityRoot == "" || runtimefs.ValidateLocalPath(b.capabilityRoot) != nil { - return nil, nil, agentcapabilities.ErrInvalid - } - if !completed { - if err := os.MkdirAll(b.capabilityRoot, 0700); err != nil { - return nil, nil, agentcapabilities.ErrInvalid - } - } - root, err := os.OpenRoot(b.capabilityRoot) - if err != nil { - return nil, nil, agentcapabilities.ErrInvalid - } - unlock, err := runtimefs.LockDirectory(root) - if err != nil { - root.Close() - return nil, nil, agentcapabilities.ErrInvalid - } - return root, func() { unlock(); root.Close() }, nil -} - -func containsPath(parent, child string) bool { - relative, err := filepath.Rel(parent, child) - return err == nil && (relative == "." || filepath.IsLocal(relative)) -} - -func (b *Binding) resolveCapabilityDirectory(source string) (*os.Root, error) { - if agentcapabilities.ValidateLocalDirectories([]string{source}) != nil { - return nil, agentcapabilities.ErrInvalid - } - if source == "/workspace" || strings.HasPrefix(source, "/workspace/") { - source = filepath.Join(b.workspace, strings.TrimPrefix(source, "/workspace")) - } - // Refuse recursive installation into the selected source itself. - if containsPath(source, b.capabilityRoot) || containsPath(b.capabilityRoot, source) { - return nil, agentcapabilities.ErrInvalid - } - root, err := os.OpenRoot(source) - if err != nil { - return nil, agentcapabilities.ErrInvalid - } - return root, nil -} diff --git a/apps/daemon/internal/localworkspace/capabilities_test.go b/apps/daemon/internal/localworkspace/capabilities_test.go deleted file mode 100644 index 42e1c3c94..000000000 --- a/apps/daemon/internal/localworkspace/capabilities_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package localworkspace - -import "testing" - -func TestCapabilitiesDoNotGateReads(t *testing.T) { - binding, request := testBinding(t) - request.WorkspaceReadOnly = true - request.LocalEnvironment.CapabilitySources = nil - if err := binding.Configure(request.PromptRequestPayload); err != nil { - t.Fatal("read-only binding required a capability installation", err) - } -} diff --git a/apps/daemon/internal/localworkspace/capability_preparation_test.go b/apps/daemon/internal/localworkspace/capability_preparation_test.go deleted file mode 100644 index 575537844..000000000 --- a/apps/daemon/internal/localworkspace/capability_preparation_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package localworkspace - -import ( - "context" - "fmt" - "os" - "path/filepath" - "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/google/uuid" -) - -func writeSourceSkill(t *testing.T, directory, text string) { - t.Helper() - if err := os.MkdirAll(directory, 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(directory, "SKILL.md"), []byte("---\nname: local-proof\ndescription: Local preparation proof\n---\n"+text), 0600); err != nil { - t.Fatal(err) - } -} - -func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { - b, req := testBinding(t) - source := t.TempDir() - writeSourceSkill(t, source, "first") - req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} - err := b.Configure(req.PromptRequestPayload) - if err != nil { - t.Fatal(err) - } - if _, err = os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { - t.Fatal("binding installed before asynchronous admission") - } - first, err := b.Prepare(t.Context(), req) - if err != nil || len(first.Skills) != 1 { - t.Fatalf("first preparation: %v", err) - } - writeSourceSkill(t, source, "second") - reconnect, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.capabilityRoot) - if err != nil { - t.Fatal(err) - } - reconnect.networkAccess = b.networkAccess - again, err := reconnect.Prepare(t.Context(), req) - if err != nil || len(again.Skills) != 1 { - t.Fatalf("reconnection: %v", err) - } - frozen, err := os.ReadFile(filepath.Join(b.capabilityRoot, again.Skills[0].RelativeRoot, "SKILL.md")) - if err != nil || string(frozen[len(frozen)-5:]) != "first" { - t.Fatal("reconnection recaptured source", err) - } - next, nextReq := testBinding(t) - nextReq.LocalEnvironment.CapabilitySources = req.LocalEnvironment.CapabilitySources - if err := next.Configure(nextReq.PromptRequestPayload); err != nil { - t.Fatal(err) - } - if _, err = next.Prepare(t.Context(), nextReq); err != nil { - t.Fatal(err) - } - fresh, err := os.ReadFile(filepath.Join(next.capabilityRoot, "directories/0/SKILL.md")) - if err != nil || string(fresh[len(fresh)-6:]) != "second" { - t.Fatal("new Session did not capture new source", err) - } - // Reusing a snapshot under another identity or selection cannot start native work. - reconnect.session = uuid.NewString() - if _, err = reconnect.Prepare(t.Context(), req); err == nil { - t.Fatal("foreign snapshot accepted") - } - changed := req - local := *req.LocalEnvironment - local.CapabilitySources = &agentcapabilities.Input{} - changed.LocalEnvironment = &local - if _, err = b.Prepare(t.Context(), changed); err == nil { - t.Fatal("changed selection accepted") - } -} - -func TestPreparationUsesOperatorSourcesAndLeavesFailuresInert(t *testing.T) { - b, req := testBinding(t) - private := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", private) - writeSourceSkill(t, private, "private") - held, err := b.resolveCapabilityDirectory(private) - if err != nil { - t.Fatal("operator source rejected", err) - } - held.Close() - if held, err = b.resolveCapabilityDirectory(b.capabilityRoot); err == nil { - held.Close() - t.Fatal("recursive snapshot source accepted") - } - source := filepath.Join(b.workspace, "selected") - writeSourceSkill(t, source, "valid") - root, err := b.resolveCapabilityDirectory("/workspace/selected") - if err != nil { - t.Fatal("logical workspace source rejected", err) - } - root.Close() - req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source, t.TempDir()}} - err = b.Configure(req.PromptRequestPayload) - if err != nil { - t.Fatal(err) - } - if _, err = b.Prepare(t.Context(), req); err == nil { - t.Fatal("invalid second source accepted") - } - if _, err = os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { - t.Fatal("partial snapshot ready") - } - if _, err = b.Prepare(t.Context(), req); err == nil { - t.Fatal("partial snapshot silently replayed") - } -} - -func TestPreparationEmptySelectionAndCancellation(t *testing.T) { - b, req := testBinding(t) - err := b.Configure(req.PromptRequestPayload) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(t.Context()) - cancel() - if _, err = b.Prepare(ctx, req); err == nil { - t.Fatal("cancelled preparation started") - } - if _, err = b.Prepare(t.Context(), req); err != nil { - t.Fatal(err) - } - read := agent.PrepareRequest{PromptRequestPayload: proto.PromptRequestPayload{WorkspaceReadOnly: true}} - if _, err = b.Prepare(t.Context(), read); err != nil { - t.Fatal("Files required capability installation", err) - } -} - -func TestRuntimePreparationRejectsMissingRequiredToolEnvironment(t *testing.T) { - b, req := testBinding(t) - t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", "") - req.LocalEnvironment.ToolEnvironment = true - err := b.Configure(req.PromptRequestPayload) - if err != nil { - t.Fatal(err) - } - if _, err = b.Prepare(t.Context(), req); err == nil { - t.Fatal("missing required tool environment admitted") - } - directory, err := InitializationDirectory() - if err != nil { - t.Fatal(err) - } - if err = os.WriteFile(filepath.Join(directory, "tool-env.json"), []byte(`{"READY":"yes"}`), 0600); err != nil { - t.Fatal(err) - } - if _, err = b.Prepare(t.Context(), req); err != nil { - t.Fatal("prepared tool environment rejected", err) - } - if err = os.Remove(filepath.Join(directory, "tool-env.json")); err != nil { - t.Fatal(err) - } - req.LocalEnvironment.ToolEnvironment = false - if _, err = b.Prepare(t.Context(), req); err == nil { - t.Fatal("deleted prepared tool environment was silently recreated") - } -} - -func TestRuntimePreparationRejectsMissingExplicitToolEnvironment(t *testing.T) { - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", filepath.Join(t.TempDir(), "missing.json")) - if _, err := ReadOptionalToolEnvironment(); err == nil { - t.Fatal("missing explicitly configured tool environment ignored") - } -} - -func TestPreparationFreezesToolOnlyEnvironmentAcrossReconnect(t *testing.T) { - for _, initialFile := range []bool{false, true} { - t.Run(fmt.Sprint(initialFile), func(t *testing.T) { - b, req := testBinding(t) - t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", t.TempDir()) - t.Setenv("OAC_RUNTIME_PACKAGE_DIRECTORY", t.TempDir()) - source := filepath.Join(t.TempDir(), "operator.json") - if err := os.WriteFile(source, []byte(`{"LOCAL_ONLY":"original"}`), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", source) - if initialFile { - if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: "/workspace/input"}, []byte("file")); err != nil { - t.Fatal(err) - } - } - if err := b.Configure(req.PromptRequestPayload); err != nil { - t.Fatal(err) - } - if _, err := b.Prepare(t.Context(), req); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(source, []byte(`{"LOCAL_ONLY":"changed"}`), 0600); err != nil { - t.Fatal(err) - } - reconnect, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.capabilityRoot) - if err != nil { - t.Fatal(err) - } - reconnect.networkAccess = b.networkAccess - if _, err := reconnect.Prepare(t.Context(), req); err != nil { - t.Fatal(err) - } - values, err := ReadOptionalToolEnvironment() - if err != nil || values["LOCAL_ONLY"] != "original" { - t.Fatal("reconnect reread mutable source", err) - } - }) - } -} diff --git a/apps/daemon/internal/localworkspace/directory.go b/apps/daemon/internal/localworkspace/directory.go deleted file mode 100644 index a1cc7bb41..000000000 --- a/apps/daemon/internal/localworkspace/directory.go +++ /dev/null @@ -1,15 +0,0 @@ -package localworkspace - -import ( - "context" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit int) (dispatch.WorkspaceDirectoryResult, error) { - if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || path != "" && !proto.ValidWorkspacePath(path) { - return dispatch.WorkspaceDirectoryResult{}, dispatch.ErrWorkspaceReadInvalid - } - return b.listNativeDirectory(ctx, path, limit) -} diff --git a/apps/daemon/internal/localworkspace/directory_native_test.go b/apps/daemon/internal/localworkspace/directory_native_test.go deleted file mode 100644 index 645e73d7a..000000000 --- a/apps/daemon/internal/localworkspace/directory_native_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package localworkspace - -import ( - "errors" - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" -) - -func TestNativeDirectoryAPI(t *testing.T) { - b := nativeFileBinding(t) - if err := os.Mkdir(filepath.Join(b.workspace, "nested"), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(b.workspace, "nested", "data.bin"), []byte{0, 1, 255, 17}, 0600); err != nil { - t.Fatal(err) - } - got, err := b.ListWorkspaceDirectory(t.Context(), "nested", 10) - if err != nil || got.Truncated || len(got.Entries) != 1 || got.Entries[0].Name != "data.bin" || got.Entries[0].SizeBytes == nil || *got.Entries[0].SizeBytes != 4 { - t.Fatal(got, err) - } - for _, path := range []string{"missing", "nested/data.bin"} { - if _, err := b.ListWorkspaceDirectory(t.Context(), path, 10); !errors.Is(err, dispatch.ErrWorkspaceNotDirectory) { - t.Fatal(path, err) - } - } - if err := os.WriteFile(filepath.Join(b.workspace, "second"), nil, 0600); err != nil { - t.Fatal(err) - } - got, err = b.ListWorkspaceDirectory(t.Context(), "", 1) - if err != nil || !got.Truncated || len(got.Entries) != 1 { - t.Fatal(got, err) - } -} diff --git a/apps/daemon/internal/localworkspace/export_test.go b/apps/daemon/internal/localworkspace/export_test.go deleted file mode 100644 index f3224dba5..000000000 --- a/apps/daemon/internal/localworkspace/export_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package localworkspace - -import ( - "archive/tar" - "bytes" - "context" - "errors" - "io" - "os" - "path/filepath" - "testing" - "time" -) - -func outputBinding(t *testing.T) *Binding { - t.Helper() - b := nativeFileBinding(t) - if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/nested/proof.bin", []byte{0, 255, 17}); err != nil { - t.Fatal(err) - } - if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/empty", nil); err != nil { - t.Fatal(err) - } - return b -} -func TestNativeOutputArchive(t *testing.T) { - b := outputBinding(t) - var output bytes.Buffer - if err := b.ExportOutputs(t.Context(), &output); err != nil { - t.Fatal(err) - } - reader := tar.NewReader(&output) - got := map[string][]byte{} - for { - header, err := reader.Next() - if err == io.EOF { - break - } - if err != nil { - t.Fatal(err) - } - if header.Typeflag != tar.TypeReg { - t.Fatal(header) - } - raw, err := io.ReadAll(reader) - if err != nil { - t.Fatal(err) - } - got[header.Name] = raw - } - if len(got) != 2 || !bytes.Equal(got["outputs/nested/proof.bin"], []byte{0, 255, 17}) { - t.Fatal(got) - } -} -func TestNativeExportMissingOutputsAndBound(t *testing.T) { - b := nativeFileBinding(t) - var output bytes.Buffer - if err := b.ExportOutputs(t.Context(), &output); err != nil { - t.Fatal(err) - } - if _, err := tar.NewReader(&output).Next(); err != io.EOF { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(b.workspace, "outputs"), 0700); err != nil { - t.Fatal(err) - } - f, err := os.Create(filepath.Join(b.workspace, "outputs", "large")) - if err != nil { - t.Fatal(err) - } - if err = f.Truncate(ExportFileBytes + 1); err != nil { - t.Fatal(err) - } - f.Close() - if err = b.ExportOutputs(t.Context(), io.Discard); err == nil { - t.Fatal("oversize artifact accepted") - } -} - -type failedExportWriter struct{ err error } - -func (w failedExportWriter) Write([]byte) (int, error) { return 0, w.err } -func TestNativeExportCancellationAndConsumerFailure(t *testing.T) { - b := outputBinding(t) - want := errors.New("consumer rejected") - if err := b.ExportOutputs(t.Context(), failedExportWriter{want}); !errors.Is(err, want) { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(t.Context()) - reader, writer := io.Pipe() - defer reader.Close() - stop := context.AfterFunc(ctx, func() { _ = reader.CloseWithError(ctx.Err()) }) - defer stop() - done := make(chan error, 1) - go func() { err := b.ExportOutputs(ctx, writer); _ = writer.CloseWithError(err); done <- err }() - var prefix [1]byte - if _, err := reader.Read(prefix[:]); err != nil { - t.Fatal(err) - } - cancel() - select { - case err := <-done: - if err == nil { - t.Fatal("cancelled export succeeded") - } - case <-time.After(time.Second): - t.Fatal("export did not settle") - } -} - -type changingOutput struct { - bytes.Buffer - change func() -} - -func (w *changingOutput) Write(data []byte) (int, error) { - if w.change != nil { - change := w.change - w.change = nil - change() - } - return w.Buffer.Write(data) -} -func TestNativeExportRejectsChangedFile(t *testing.T) { - b := nativeFileBinding(t) - if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/file", []byte("before")); err != nil { - t.Fatal(err) - } - w := &changingOutput{change: func() { - if err := os.WriteFile(filepath.Join(b.workspace, "outputs", "file"), []byte("changed-length"), 0600); err != nil { - t.Error(err) - } - }} - if err := b.ExportOutputs(t.Context(), w); err == nil { - t.Fatal("changed artifact accepted") - } -} diff --git a/apps/daemon/internal/localworkspace/initialization.go b/apps/daemon/internal/localworkspace/initialization.go deleted file mode 100644 index 33aafd694..000000000 --- a/apps/daemon/internal/localworkspace/initialization.go +++ /dev/null @@ -1,129 +0,0 @@ -package localworkspace - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "runtime" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" -) - -// InitializationDirectory resolves the operator's local resource layout. The -// protocol and preparation operations do not distinguish Environment sources. -func InitializationDirectory() (string, error) { - return initializationPath("OAC_RUNTIME_INITIALIZATION_DIRECTORY", "initialization") -} -func PackageDirectory() (string, error) { - return initializationPath("OAC_RUNTIME_PACKAGE_DIRECTORY", "packages") -} -func initializationPath(setting, name string) (string, error) { - if path := os.Getenv(setting); path != "" { - if runtimefs.ValidateLocalPath(path) != nil { - return "", errors.New("invalid Runtime initialization directory") - } - return path, nil - } - root, err := paths.Root() - if err != nil { - return "", err - } - return filepath.Join(root, name), nil -} - -// ReadToolEnvironment reads bounded explicit configuration, never ambient secrets. -func ReadToolEnvironment() (map[string]string, error) { - path, err := toolEnvironmentPath() - if err != nil { - return nil, err - } - return readToolEnvironmentFile(path) -} - -func readToolEnvironmentFile(path string) (map[string]string, error) { - body, err := runtimefs.ReadPrivatePath(path, 1<<20) - var values map[string]string - if err != nil || json.Unmarshal(body, &values) != nil || values == nil || !agentcapabilities.ValidToolEnvironment(values, runtime.GOOS == "windows") { - return nil, errors.New("initialized user environment unavailable") - } - return values, nil -} - -// ReadOptionalToolEnvironment permits a Runtime without explicit user variables. -func ReadOptionalToolEnvironment() (map[string]string, error) { - values, _, err := readOptionalToolEnvironment() - return values, err -} - -// ToolEnvironmentFile returns the validated Runtime-owned source without copying -// its values into a Harness profile. An unconfigured environment has no file. -func ToolEnvironmentFile() (string, error) { - _, path, err := readOptionalToolEnvironment() - return path, err -} - -func readOptionalToolEnvironment() (map[string]string, string, error) { - path, err := toolEnvironmentPath() - if err != nil { - return nil, "", err - } - if _, err = os.Stat(path); errors.Is(err, os.ErrNotExist) && os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE") == "" { - return map[string]string{}, "", nil - } - values, err := readToolEnvironmentFile(path) - if err != nil { - return nil, "", err - } - return values, path, nil -} - -// The prepared snapshot takes precedence over the operator's source file. -// Changing that source after preparation must not change an existing Session. -func toolEnvironmentPath() (string, error) { - path, err := initializedToolEnvironmentPath() - if err != nil { - return "", err - } - if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { - return path, err - } - if source := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); source != "" { - if runtimefs.ValidateLocalPath(source) != nil { - return "", errors.New("invalid Runtime tool environment file") - } - return source, nil - } - return path, nil -} - -func initializedToolEnvironmentPath() (string, error) { - directory, err := InitializationDirectory() - if err != nil { - return "", err - } - return filepath.Join(directory, "tool-env.json"), nil -} - -// Freeze local defaults even when Core has no setup operations to send. Once -// the capability snapshot is complete, a missing environment is corruption. -func prepareToolEnvironment(required, completed bool) error { - path, err := initializedToolEnvironmentPath() - if err != nil { - return err - } - if _, err = os.Lstat(path); errors.Is(err, os.ErrNotExist) { - if required || completed { - return errors.New("prepared tool environment unavailable") - } - if err := configureRuntime(nil); err != nil { - return err - } - } else if err != nil { - return err - } - _, err = readToolEnvironmentFile(path) - return err -} diff --git a/apps/daemon/internal/localworkspace/mcp.go b/apps/daemon/internal/localworkspace/mcp.go deleted file mode 100644 index 37d371991..000000000 --- a/apps/daemon/internal/localworkspace/mcp.go +++ /dev/null @@ -1,29 +0,0 @@ -package localworkspace - -import ( - "os" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" -) - -// MCPStdioCommand resolves the common installed manifest in the daemon. -func MCPStdioCommand(server agent.EnvironmentMCP) (string, []string) { - executable, err := os.Executable() - if err != nil { - return "", nil - } - return executable, []string{"runtime-mcp-exec", server.InstallationRoot, server.PackageRoot, server.Server.Name} -} - -func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values map[string]string) ([]agent.EnvironmentMCP, error) { - tokens, err := agentcapabilities.ResolveMCP(installed, values) - if err != nil { - return nil, err - } - result := make([]agent.EnvironmentMCP, 0, len(installed)) - for i, item := range installed { - result = append(result, agent.EnvironmentMCP{PackageRoot: item.PackageRoot, Server: item.Server, BearerToken: tokens[i]}) - } - return result, nil -} diff --git a/apps/daemon/internal/localworkspace/mcp_test.go b/apps/daemon/internal/localworkspace/mcp_test.go deleted file mode 100644 index c461be413..000000000 --- a/apps/daemon/internal/localworkspace/mcp_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package localworkspace - -import ( - "os" - "slices" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" -) - -func TestEnvironmentMCPCredentialsNeverFallBackToNativeEnv(t *testing.T) { - t.Setenv("PLUGIN_TOKEN", "native-private-value") - installed := []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ - Name: "remote", Type: "http", URL: "https://example.com/mcp", BearerTokenEnvVar: "PLUGIN_TOKEN", - }}} - if _, err := resolveEnvironmentMCP(installed, nil); err == nil { - t.Fatal("native credential became a user Plugin credential") - } - servers, err := resolveEnvironmentMCP(installed, map[string]string{"PLUGIN_TOKEN": "user-token"}) - if err != nil || len(servers) != 1 || servers[0].BearerToken == nil || *servers[0].BearerToken != "user-token" { - t.Fatal("initialized credential was not selected", err) - } - installed = append(installed, agentcapabilities.InstalledMCP{PackageRoot: "plugins/1", Server: installed[0].Server}) - if _, err := resolveEnvironmentMCP(installed, map[string]string{"PLUGIN_TOKEN": "user-token"}); err == nil { - t.Fatal("ambiguous server identity accepted") - } -} - -func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { - server := agent.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ - Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, - }} - command, args := MCPStdioCommand(server) - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - if command != executable || !slices.Equal(args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "package_tool"}) { - t.Fatal("native configuration included untrusted process configuration") - } -} diff --git a/apps/daemon/internal/localworkspace/native_binding.go b/apps/daemon/internal/localworkspace/native_binding.go deleted file mode 100644 index 2a76761c0..000000000 --- a/apps/daemon/internal/localworkspace/native_binding.go +++ /dev/null @@ -1,34 +0,0 @@ -package localworkspace - -import ( - "errors" - "os" - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" - "github.com/google/uuid" -) - -func newNativeBinding(environment, session, workspace, capabilities string) (*Binding, error) { - for _, value := range []string{environment, session} { - id, err := uuid.Parse(value) - if err != nil || id == uuid.Nil || id.String() != value { - return nil, errors.New("local workspace requires canonical resource identities") - } - } - for _, path := range []string{workspace, capabilities} { - if runtimefs.ValidateLocalPath(path) != nil || filepath.Dir(path) == path { - return nil, errors.New("local workspace requires absolute installation paths") - } - } - info, err := os.Stat(workspace) - if err != nil || !info.IsDir() { - return nil, errors.New("local workspace root must be an existing directory") - } - return &Binding{environment: environment, session: session, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil -} - -// ReadToolEnvironment reads explicit initialization values for this installation. -func (b *Binding) ReadToolEnvironment() (map[string]string, error) { - return ReadOptionalToolEnvironment() -} diff --git a/apps/daemon/internal/localworkspace/native_files.go b/apps/daemon/internal/localworkspace/native_files.go deleted file mode 100644 index 5c23cb49b..000000000 --- a/apps/daemon/internal/localworkspace/native_files.go +++ /dev/null @@ -1,284 +0,0 @@ -package localworkspace - -import ( - "archive/tar" - "context" - "errors" - "io" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -// Logical API paths stay slash-separated on every host. os.Root anchors API -// file operations to the selected workspace; it does not constrain native tools. -func nativeAPIPath(path string) (string, error) { - if path == "" { - return ".", nil - } - if !proto.ValidWorkspacePath(path) { - return "", fs.ErrInvalid - } - return filepath.Localize(path) -} - -func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit int) (dispatch.WorkspaceDirectoryResult, error) { - result := dispatch.WorkspaceDirectoryResult{Entries: []dispatch.WorkspaceDirectoryEntry{}} - if b == nil || ctx.Err() != nil { - return result, dispatch.ErrWorkspaceReadUnavailable - } - local, err := nativeAPIPath(path) - if err != nil { - return result, dispatch.ErrWorkspaceReadInvalid - } - root, err := os.OpenRoot(b.workspace) - if err != nil { - return result, dispatch.ErrWorkspaceReadUnavailable - } - defer root.Close() - if local != "." { - partial := "" - for _, part := range strings.Split(path, "/") { - partial = filepath.Join(partial, part) - info, err := root.Lstat(partial) - if errors.Is(err, fs.ErrPermission) { - return result, fs.ErrPermission - } - if err != nil || !info.IsDir() { - return result, dispatch.ErrWorkspaceNotDirectory - } - } - } - dir, err := openNativePath(root, local) - if errors.Is(err, fs.ErrPermission) { - return result, fs.ErrPermission - } - if err != nil { - return result, dispatch.ErrWorkspaceNotDirectory - } - defer dir.Close() - entries, err := dir.ReadDir(limit + 1) - if err != nil && err != io.EOF { - return result, dispatch.ErrWorkspaceNotDirectory - } - if ctx.Err() != nil { - return result, dispatch.ErrWorkspaceReadUnavailable - } - result.Truncated = len(entries) > limit - if result.Truncated { - entries = entries[:limit] - } - sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) - wire := &proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{}, Truncated: result.Truncated} - for _, entry := range entries { - info, err := entry.Info() - if err != nil { - return result, dispatch.ErrWorkspaceReadUncertain - } - item := proto.WorkspaceDirectoryEntry{Name: entry.Name(), Kind: "other"} - switch { - case info.Mode().IsRegular(): - item.Kind = "file" - size := info.Size() - item.SizeBytes = &size - case info.IsDir(): - item.Kind = "directory" - case info.Mode()&os.ModeSymlink != 0: - item.Kind = "symlink" - } - wire.Entries = append(wire.Entries, item) - } - if !proto.ValidWorkspaceDirectory(wire, limit) { - return result, dispatch.ErrWorkspaceReadInvalid - } - for _, item := range wire.Entries { - result.Entries = append(result.Entries, dispatch.WorkspaceDirectoryEntry{Name: item.Name, Kind: item.Kind, SizeBytes: item.SizeBytes}) - } - return result, nil -} - -func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) (dispatch.WorkspaceWriteResult, error) { - result := dispatch.WorkspaceWriteResult{} - local, err := nativeAPIPath(path) - if err != nil { - return result, dispatch.ErrWorkspaceWriteInvalid - } - root, err := os.OpenRoot(b.workspace) - if err != nil { - return result, dispatch.ErrEnvironmentUnavailable - } - defer root.Close() - if err = root.MkdirAll(filepath.Dir(local), 0700); err != nil { - return result, dispatch.ErrWorkspaceWriteRejected - } - temporary := ".oac-write-" + uuid.NewString() - file, err := root.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) - if err != nil { - return result, dispatch.ErrWorkspaceWriteRejected - } - defer root.Remove(temporary) - _, err = file.Write(data) - if err == nil { - err = file.Sync() - } - closeErr := file.Close() - if err != nil || closeErr != nil { - return result, dispatch.ErrWorkspaceWriteRejected - } - // Link publishes complete bytes without replacing an existing destination. - if err = root.Link(temporary, local); err != nil { - if info, e := root.Lstat(local); e == nil { - if info.IsDir() { - return result, dispatch.ErrWorkspaceWriteDirectory - } - return result, dispatch.ErrWorkspaceWriteUnsafe - } - return result, dispatch.ErrWorkspaceWriteRejected - } - return dispatch.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil -} - -// The bounds of an output export: each file's bytes, the export's bytes, its -// entries and its directory depth. -const ( - ExportFileBytes int64 = 200 << 20 - ExportBatchBytes int64 = 500 << 20 - ExportEntries = 4096 - ExportDepth = 64 -) - -type nativeExport struct { - ctx context.Context - root *os.Root - archive *tar.Writer - entries int - bytes int64 -} - -func (b *Binding) ExportOutputs(ctx context.Context, output io.Writer) error { - if err := ctx.Err(); err != nil { - return err - } - root, err := os.OpenRoot(b.workspace) - if err != nil { - return err - } - defer root.Close() - archive := tar.NewWriter(output) - info, err := root.Lstat("outputs") - if errors.Is(err, fs.ErrNotExist) { - return archive.Close() - } - if err != nil { - return err - } - if !info.IsDir() { - return errors.New("workspace outputs is not a directory") - } - exporter := nativeExport{ctx: ctx, root: root, archive: archive} - if err = exporter.walk("outputs", 0); err != nil { - return err - } - if err = ctx.Err(); err != nil { - return err - } - return archive.Close() -} -func (x *nativeExport) walk(path string, depth int) error { - if err := x.ctx.Err(); err != nil { - return err - } - if depth > ExportDepth || len(path) > 4096 { - return errors.New("workspace export exceeds traversal bound") - } - dir, err := openNativePath(x.root, path) - if err != nil { - return err - } - entries, err := dir.ReadDir(ExportEntries + 1) - _ = dir.Close() - if err != nil && err != io.EOF { - return err - } - x.entries += len(entries) - if x.entries > ExportEntries { - return errors.New("workspace export exceeds entry bound") - } - sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) - for _, entry := range entries { - name := path + "/" + entry.Name() - if _, err := nativeAPIPath(name); err != nil { - return err - } - info, err := entry.Info() - if err != nil { - return err - } - switch { - case info.Mode()&os.ModeSymlink != 0: - continue - case info.IsDir(): - if err = x.walk(name, depth+1); err != nil { - return err - } - case info.Mode().IsRegular(): - if err = x.append(name); err != nil { - return err - } - default: - return errors.New("workspace output is not a regular file") - } - } - return nil -} -func (x *nativeExport) append(path string) error { - if err := x.ctx.Err(); err != nil { - return err - } - file, err := openNativePath(x.root, path) - if err != nil { - return err - } - defer file.Close() - before, err := file.Stat() - if err != nil { - return err - } - size := before.Size() - if !before.Mode().IsRegular() || size < 0 || size > ExportFileBytes || size > ExportBatchBytes-x.bytes { - return errors.New("workspace export exceeds file bound") - } - x.bytes += size - if err = x.archive.WriteHeader(&tar.Header{Name: path, Typeflag: tar.TypeReg, Mode: 0600, Size: size, Format: tar.FormatPAX}); err != nil { - return err - } - if _, err = io.CopyN(x.archive, nativeContextReader{ctx: x.ctx, reader: file}, size); err != nil { - return err - } - after, err := file.Stat() - if err != nil { - return err - } - if after.Size() != size || !after.ModTime().Equal(before.ModTime()) { - return errors.New("workspace output changed during export") - } - return nil -} - -type nativeContextReader struct { - ctx context.Context - reader io.Reader -} - -func (r nativeContextReader) Read(data []byte) (int, error) { - if err := r.ctx.Err(); err != nil { - return 0, err - } - return r.reader.Read(data) -} diff --git a/apps/daemon/internal/localworkspace/native_open_unix.go b/apps/daemon/internal/localworkspace/native_open_unix.go deleted file mode 100644 index 5d085c04e..000000000 --- a/apps/daemon/internal/localworkspace/native_open_unix.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build unix - -package localworkspace - -import ( - "os" - "syscall" -) - -// A file or directory can become a FIFO after its metadata was checked. -// Opening must return before the caller validates the opened descriptor. -func openNativePath(root *os.Root, path string) (*os.File, error) { - return root.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK|syscall.O_NOFOLLOW, 0) -} diff --git a/apps/daemon/internal/localworkspace/native_open_unix_test.go b/apps/daemon/internal/localworkspace/native_open_unix_test.go deleted file mode 100644 index cc4d9fab1..000000000 --- a/apps/daemon/internal/localworkspace/native_open_unix_test.go +++ /dev/null @@ -1,69 +0,0 @@ -//go:build unix - -package localworkspace - -import ( - "archive/tar" - "io" - "os" - "path/filepath" - "syscall" - "testing" - "time" -) - -func TestNativeExportRejectsReplacementFIFO(t *testing.T) { - for _, directory := range []bool{false, true} { - name := "file" - if directory { - name = "directory" - } - t.Run(name, func(t *testing.T) { - workspace := t.TempDir() - path := filepath.Join(workspace, "replaced") - if directory { - if err := os.Mkdir(path, 0700); err != nil { - t.Fatal(err) - } - } else if err := os.WriteFile(path, nil, 0600); err != nil { - t.Fatal(err) - } - if _, err := os.Lstat(path); err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := syscall.Mkfifo(path, 0600); err != nil { - t.Fatal(err) - } - root, err := os.OpenRoot(workspace) - if err != nil { - t.Fatal(err) - } - defer root.Close() - exporter := nativeExport{ctx: t.Context(), root: root, archive: tar.NewWriter(io.Discard)} - done := make(chan error, 1) - go func() { - if directory { - done <- exporter.walk("replaced", 0) - } else { - done <- exporter.append("replaced") - } - }() - select { - case err := <-done: - if err == nil { - t.Fatal("replacement FIFO accepted") - } - case <-time.After(time.Second): - // Release a blocking regression before reporting it. - peer, err := os.OpenFile(path, os.O_RDWR|syscall.O_NONBLOCK, 0) - if err == nil { - peer.Close() - } - t.Fatal("opening the replacement FIFO blocked") - } - }) - } -} diff --git a/apps/daemon/internal/localworkspace/native_open_windows.go b/apps/daemon/internal/localworkspace/native_open_windows.go deleted file mode 100644 index 697926206..000000000 --- a/apps/daemon/internal/localworkspace/native_open_windows.go +++ /dev/null @@ -1,7 +0,0 @@ -package localworkspace - -import "os" - -func openNativePath(root *os.Root, path string) (*os.File, error) { - return root.Open(path) -} diff --git a/apps/daemon/internal/localworkspace/network.go b/apps/daemon/internal/localworkspace/network.go deleted file mode 100644 index 220561590..000000000 --- a/apps/daemon/internal/localworkspace/network.go +++ /dev/null @@ -1,32 +0,0 @@ -package localworkspace - -import ( - "encoding/json" - "errors" - "os" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" -) - -// RuntimeNetworkPolicy reads deployment configuration, never caller options. -func RuntimeNetworkPolicy() (agentnetwork.Policy, error) { - policy := agentnetwork.Policy{Access: os.Getenv("OAC_RUNTIME_NETWORK_ACCESS")} - if raw := os.Getenv("OAC_RUNTIME_ALLOWED_DOMAINS"); raw != "" { - if err := json.Unmarshal([]byte(raw), &policy.AllowedDomains); err != nil { - return policy, errors.New("invalid local Runtime network domains") - } - } - // Unbound runtimes may serve operations without a hosted workspace. - if policy.Access == "" && len(policy.AllowedDomains) == 0 { - return policy, nil - } - if err := policy.Validate(); err != nil { - return policy, errors.New("unsupported local Runtime network policy") - } - return policy, nil -} - -// NetworkPolicy returns a copy of the frozen execution authority. -func (b *Binding) NetworkPolicy() agentnetwork.Policy { - return agentnetwork.Policy{Access: b.networkAccess, AllowedDomains: append([]string(nil), b.allowedDomains...)} -} diff --git a/apps/daemon/internal/localworkspace/network_policy_test.go b/apps/daemon/internal/localworkspace/network_policy_test.go deleted file mode 100644 index 6a170292c..000000000 --- a/apps/daemon/internal/localworkspace/network_policy_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package localworkspace - -import "testing" - -func TestRuntimeNetworkPolicyRejectsMalformedDeploymentInput(t *testing.T) { - for _, tc := range []struct { - access, domains string - valid bool - }{ - {"restricted", `["example.com"]`, true}, - {"restricted", `[]`, false}, - {"restricted", `["*"]`, false}, - {"restricted", `"example.com"`, false}, - {"enabled", `["example.com"]`, false}, - {"", `["example.com"]`, false}, - {"disabled", `[]`, true}, - } { - t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", tc.access) - t.Setenv("OAC_RUNTIME_ALLOWED_DOMAINS", tc.domains) - if _, err := RuntimeNetworkPolicy(); (err == nil) != tc.valid { - t.Fatalf("%s/%s: %v", tc.access, tc.domains, err) - } - } -} diff --git a/apps/daemon/internal/localworkspace/package_command.go b/apps/daemon/internal/localworkspace/package_command.go deleted file mode 100644 index 9ae5f053c..000000000 --- a/apps/daemon/internal/localworkspace/package_command.go +++ /dev/null @@ -1,45 +0,0 @@ -package localworkspace - -import ( - "errors" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" -) - -// ResolvePackageManagerCommand runs Windows npm/npx shims through their installed -// JavaScript entrypoints. It does not pass arguments through an extra shell. -func ResolvePackageManagerCommand(command string, args []string) (string, []string, error) { - if runtime.GOOS != "windows" { - return command, args, nil - } - name := strings.ToLower(filepath.Base(command)) - switch name { - case "npm", "npm.cmd", "npx", "npx.cmd": - default: - return command, args, nil - } - shim, err := exec.LookPath(command) - if err != nil { - return "", nil, errors.New("npm or npx launcher unavailable") - } - if !strings.EqualFold(filepath.Ext(shim), ".cmd") { - return shim, args, nil - } - name = strings.TrimSuffix(name, ".cmd") - directory := filepath.Dir(shim) - cli := filepath.Join(directory, "node_modules", "npm", "bin", name+"-cli.js") - if info, err := os.Stat(cli); err != nil || !info.Mode().IsRegular() { - return "", nil, errors.New("npm or npx CLI unavailable") - } - node := filepath.Join(directory, "node.exe") - if info, err := os.Stat(node); err != nil || !info.Mode().IsRegular() { - node, err = exec.LookPath("node") - if err != nil { - return "", nil, errors.New("Node.js unavailable") - } - } - return node, append([]string{cli}, args...), nil -} diff --git a/apps/daemon/internal/localworkspace/package_command_windows_test.go b/apps/daemon/internal/localworkspace/package_command_windows_test.go deleted file mode 100644 index 442398204..000000000 --- a/apps/daemon/internal/localworkspace/package_command_windows_test.go +++ /dev/null @@ -1,81 +0,0 @@ -//go:build windows - -package localworkspace - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "slices" - "testing" -) - -func TestWindowsPackageManagerResolution(t *testing.T) { - installation := filepath.Join(t.TempDir(), "selected node") - if err := os.MkdirAll(filepath.Join(installation, "node_modules", "npm", "bin"), 0700); err != nil { - t.Fatal(err) - } - for _, name := range []string{"node.exe", "npm.cmd", "npx.cmd", "node_modules/npm/bin/npm-cli.js", "node_modules/npm/bin/npx-cli.js"} { - if err := os.WriteFile(filepath.Join(installation, filepath.FromSlash(name)), nil, 0600); err != nil { - t.Fatal(err) - } - } - args := []string{"two words", "literal & | < > ^ %PATH%", "quote\"value"} - for _, name := range []string{"npm", "npx"} { - command := filepath.Join(installation, name+".cmd") - binary, got, err := ResolvePackageManagerCommand(command, args) - want := append([]string{filepath.Join(installation, "node_modules", "npm", "bin", name+"-cli.js")}, args...) - if err != nil || binary != filepath.Join(installation, "node.exe") || !slices.Equal(got, want) { - t.Fatal(binary, got, err) - } - } - for _, name := range []string{"server.exe", "npm.exe", "other.cmd"} { - binary, got, err := ResolvePackageManagerCommand(name, args) - if err != nil || binary != name || !slices.Equal(got, args) { - t.Fatal("ordinary command changed", binary, got, err) - } - } - if err := os.Remove(filepath.Join(installation, "node_modules", "npm", "bin", "npx-cli.js")); err != nil { - t.Fatal(err) - } - if _, _, err := ResolvePackageManagerCommand(filepath.Join(installation, "npx.cmd"), args); err == nil { - t.Fatal("missing selected CLI accepted") - } -} - -func TestWindowsPackageManagerArguments(t *testing.T) { - node, err := exec.LookPath("node") - if err != nil { - t.Fatal("native test requires installed Node.js", err) - } - installation := filepath.Join(t.TempDir(), "npm installation") - directory := filepath.Join(installation, "node_modules", "npm", "bin") - if err = os.MkdirAll(directory, 0700); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", installation+string(os.PathListSeparator)+os.Getenv("PATH")) - args := []string{"two words", "literal & | < > ^ %PATH%", "quote\"value"} - for _, name := range []string{"npm", "npx"} { - if err = os.WriteFile(filepath.Join(installation, name+".cmd"), []byte("@exit /b 99\r\n"), 0600); err != nil { - t.Fatal(err) - } - if err = os.WriteFile(filepath.Join(directory, name+"-cli.js"), []byte("process.stdout.write(JSON.stringify(process.argv.slice(2)))"), 0600); err != nil { - t.Fatal(err) - } - for _, command := range []string{name, name + ".cmd", filepath.Join(installation, name+".cmd")} { - binary, argv, err := ResolvePackageManagerCommand(command, args) - if err != nil || filepath.Clean(binary) != filepath.Clean(node) { - t.Fatal("PATH Node fallback failed", err) - } - out, err := exec.CommandContext(t.Context(), binary, argv...).Output() - if err != nil { - t.Fatal(err) - } - var got []string - if json.Unmarshal(out, &got) != nil || !slices.Equal(got, args) { - t.Fatal("arguments were interpreted", string(out)) - } - } - } -} diff --git a/apps/daemon/internal/localworkspace/runtime_initialization.go b/apps/daemon/internal/localworkspace/runtime_initialization.go deleted file mode 100644 index 687a0adb6..000000000 --- a/apps/daemon/internal/localworkspace/runtime_initialization.go +++ /dev/null @@ -1,197 +0,0 @@ -package localworkspace - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" -) - -var ErrInitializationUnconfirmed = errors.New("Runtime initialization unconfirmed") - -func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInitialization) error { - raw, err := json.Marshal(input) - if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { - return agentcapabilities.ErrInvalid - } - if ctx.Err() != nil { - return ErrInitializationUnconfirmed - } - if input.Action == "configure" { - return configureRuntime(input.Env) - } - if input.Action != "setup" && input.Action != "npm" && input.Action != "python" { - return agentcapabilities.ErrInvalid - } - directory, err := b.initializationCWD(input.CWD) - if err != nil { - return err - } - values, err := ReadToolEnvironment() - if err != nil { - return &dispatch.InitializationFailure{} - } - packages, err := PackageDirectory() - if err != nil { - return &dispatch.InitializationFailure{} - } - tail, err := agentcapabilities.InitializationArgs(input.Action, input.Command, input.Packages, filepath.Join(packages, "npm"), filepath.Join(packages, "python")) - if err != nil { - return err - } - var binary string - var args []string - switch input.Action { - case "setup": - binary, err = initializationBash() - case "npm", "python": - if err = os.MkdirAll(packages, 0700); err != nil { - return &dispatch.InitializationFailure{} - } - if input.Action == "npm" { - binary, args, err = initializationNPM() - } else { - binary, err = initializationPython() - } - } - if err != nil { - return err - } - return runInitializationProcess(ctx, binary, append(args, tail...), directory, initializationEnvironment(values)) -} - -func (b *Binding) initializationCWD(value string) (string, error) { - if value == "" || value == "/workspace" { - return b.workspace, nil - } - if !strings.HasPrefix(value, "/workspace/") { - return "", agentcapabilities.ErrInvalid - } - relative, err := nativeAPIPath(strings.TrimPrefix(value, "/workspace/")) - if err != nil { - return "", agentcapabilities.ErrInvalid - } - return filepath.Join(b.workspace, relative), nil -} - -func configureRuntime(values map[string]string) error { - if !agentcapabilities.ValidToolEnvironment(values, runtime.GOOS == "windows") { - return agentcapabilities.ErrInvalid - } - path, err := initializedToolEnvironmentPath() - if err != nil { - return &dispatch.InitializationFailure{} - } - packages, err := PackageDirectory() - if err != nil { - return &dispatch.InitializationFailure{} - } - if os.MkdirAll(filepath.Dir(path), 0700) != nil || os.MkdirAll(packages, 0700) != nil { - return &dispatch.InitializationFailure{} - } - root, err := os.OpenRoot(filepath.Dir(path)) - if err != nil { - return &dispatch.InitializationFailure{} - } - defer root.Close() - unlock, err := runtimefs.LockDirectory(root) - if err != nil { - return &dispatch.InitializationFailure{} - } - defer unlock() - if _, err = root.Stat(filepath.Base(path)); !errors.Is(err, os.ErrNotExist) { - return &dispatch.InitializationFailure{} - } - configured := make(map[string]string, len(values)+2) - if source := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); source != "" { - if runtimefs.ValidateLocalPath(source) != nil { - return &dispatch.InitializationFailure{} - } - local, err := readToolEnvironmentFile(source) - if err != nil { - return &dispatch.InitializationFailure{} - } - for key, value := range local { - if runtime.GOOS == "windows" { - key = strings.ToUpper(key) - } - configured[key] = value - } - } - // Explicit Session values override the operator's base tool configuration. - for key, value := range values { - if runtime.GOOS == "windows" { - key = strings.ToUpper(key) - } - configured[key] = value - } - npmBin := filepath.Join(packages, "npm", "bin") - pythonBin := filepath.Join(packages, "python", "bin") - if runtime.GOOS == "windows" { - npmBin = filepath.Join(packages, "npm") - pythonBin = filepath.Join(packages, "python", "Scripts") - } - configured = agentcapabilities.ToolEnvironment(configured, os.Getenv("PATH"), npmBin, pythonBin, filepath.Join(packages, "python"), string(os.PathListSeparator)) - raw, err := json.Marshal(configured) - if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { - return agentcapabilities.ErrInvalid - } - if runtimefs.WritePrivateAtomic(root, filepath.Base(path), raw) != nil { - return ErrInitializationUnconfirmed - } - return nil -} - -func (b *Binding) installInitialFile(ctx context.Context, input proto.RuntimeInitialFile, data []byte) (err error) { - if b.writer == nil || !strings.HasPrefix(input.Path, "/workspace/") || len(data) > proto.RuntimePrepareMaxBytes { - return agentcapabilities.ErrInvalid - } - relative, err := nativeAPIPath(strings.TrimPrefix(input.Path, "/workspace/")) - if err != nil { - return agentcapabilities.ErrInvalid - } - if ctx.Err() != nil { - return ErrInitializationUnconfirmed - } - w := b.writer - if !w.mu.TryLock() { - return agentcapabilities.ErrInvalid - } - defer w.mu.Unlock() - if w.uncertain { - return ErrInitializationUnconfirmed - } - defer func() { w.uncertain = errors.Is(err, ErrInitializationUnconfirmed) }() - root, err := os.OpenRoot(b.workspace) - if err != nil { - return &dispatch.InitializationFailure{} - } - defer root.Close() - if root.MkdirAll(filepath.Dir(relative), 0700) != nil { - return &dispatch.InitializationFailure{} - } - parent, err := root.OpenRoot(filepath.Dir(relative)) - if err != nil { - return &dispatch.InitializationFailure{} - } - defer parent.Close() - // Initial files replace existing contents, unlike public Files create. Finish - // the synchronous atomic write before returning even if cancellation arrives. - if runtimefs.WritePrivateAtomic(parent, filepath.Base(relative), data) != nil { - return ErrInitializationUnconfirmed - } - return nil -} - -func initializationDependency(name string) error { - return fmt.Errorf("Runtime initialization requires %s: %w", name, &dispatch.InitializationFailure{}) -} diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_process.go b/apps/daemon/internal/localworkspace/runtime_initialization_process.go deleted file mode 100644 index 481392ef1..000000000 --- a/apps/daemon/internal/localworkspace/runtime_initialization_process.go +++ /dev/null @@ -1,146 +0,0 @@ -package localworkspace - -import ( - "context" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "sort" - "strings" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" -) - -func initializationBash() (string, error) { - if runtime.GOOS != "windows" { - if binary, err := exec.LookPath("bash"); err == nil { - return binary, nil - } - return "", initializationDependency("bash") - } - // Git Bash preserves setup's Bash syntax. Never substitute cmd.exe, PowerShell - // or the Windows WSL launcher for a missing Bash implementation. - candidates := []string{} - if explicit := os.Getenv("CLAUDE_CODE_GIT_BASH_PATH"); explicit != "" { - candidates = append(candidates, explicit) - } else if git, err := exec.LookPath("git.exe"); err == nil { - candidates = append(candidates, filepath.Join(filepath.Dir(git), "..", "bin", "bash.exe"), filepath.Join(filepath.Dir(git), "bash.exe")) - } - for _, candidate := range candidates { - candidate = filepath.Clean(candidate) - if runtimefs.ValidateLocalPath(candidate) != nil { - continue - } - if info, err := os.Stat(candidate); err == nil && info.Mode().IsRegular() { - return candidate, nil - } - } - return "", initializationDependency("Git Bash") -} - -func initializationPython() (string, error) { - for _, name := range []string{"python3", "python"} { - if binary, err := exec.LookPath(name); err == nil { - return binary, nil - } - } - return "", initializationDependency("Python with pip") -} - -func initializationNPM() (string, []string, error) { - if _, err := exec.LookPath("node"); err != nil { - return "", nil, initializationDependency("Node.js") - } - npm, err := exec.LookPath("npm") - if err != nil { - return "", nil, initializationDependency("npm") - } - binary, args, err := ResolvePackageManagerCommand(npm, nil) - if err != nil { - return "", nil, initializationDependency("npm CLI") - } - return binary, args, nil -} - -func initializationEnvironment(configured map[string]string) []string { - values := map[string]string{} - for _, key := range []string{"PATH", "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "PATHEXT", "LANG"} { - if value := os.Getenv(key); value != "" { - values[key] = value - } - } - if runtime.GOOS != "windows" && values["LANG"] == "" { - values["LANG"] = "C.UTF-8" - } - for key, value := range configured { - if runtime.GOOS == "windows" { - for existing := range values { - if strings.EqualFold(existing, key) { - delete(values, existing) - } - } - } - values[key] = value - } - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - result := make([]string, 0, len(keys)) - for _, key := range keys { - result = append(result, key+"="+values[key]) - } - return result -} - -// InitializationGrace is how long a cancelled setup step may run before it is -// killed. -const InitializationGrace = 250 * time.Millisecond - -// The shared process owner settles the leader and descendants. Readers finish -// before return; output is discarded with constant memory, never put in errors. -func runInitializationProcess(ctx context.Context, binary string, args []string, directory string, env []string) error { - operation, cancel := context.WithTimeout(ctx, 30*time.Minute) - defer cancel() - if operation.Err() != nil { - return ErrInitializationUnconfirmed - } - if info, err := os.Stat(directory); err != nil || !info.IsDir() { - return &dispatch.InitializationFailure{} - } - process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, - Dir: directory, Env: env, KillTimeout: InitializationGrace}) - if err != nil { - return ErrInitializationUnconfirmed - } - defer process.Cancel() - finished := make(chan error, 2) - for _, stream := range []io.Reader{process.Stdout, process.Stderr} { - go func(stream io.Reader) { - _, err := io.Copy(io.Discard, stream) - if err != nil { - process.Cancel() - } - finished <- err - }(stream) - } - first, second := <-finished, <-finished - _ = process.Wait() - if operation.Err() != nil || first != nil || second != nil || process.Cmd.ProcessState == nil { - return ErrInitializationUnconfirmed - } - code := process.Cmd.ProcessState.ExitCode() - if code == 0 { - return nil - } - if code < 1 || code > 255 { - return ErrInitializationUnconfirmed - } - return &dispatch.InitializationFailure{ExitCode: &code} -} diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_test.go b/apps/daemon/internal/localworkspace/runtime_initialization_test.go deleted file mode 100644 index 9cdcebe22..000000000 --- a/apps/daemon/internal/localworkspace/runtime_initialization_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package localworkspace - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -// The test executable doubles as a native package-manager fixture on all OSes. -// Dispatch before testing parses npm/pip arguments; no dependencies are fetched. -func init() { - if os.Getenv("OAC_INITIALIZATION_PACKAGE_FIXTURE") != "1" { - return - } - raw, _ := json.Marshal(os.Args[1:]) - if os.WriteFile(os.Getenv("OAC_INITIALIZATION_PACKAGE_RECEIPT"), raw, 0600) != nil { - os.Exit(9) - } - os.Exit(0) -} - -func TestRuntimeInitializationPackageTargets(t *testing.T) { - b := initializationFixture(t) - binary, err := os.Executable() - if err != nil { - t.Fatal(err) - } - source, err := os.ReadFile(binary) - if err != nil { - t.Fatal(err) - } - bin := t.TempDir() - suffix := "" - if runtime.GOOS == "windows" { - suffix = ".exe" - } - for _, name := range []string{"node", "npm", "python3"} { - if name == "npm" && runtime.GOOS == "windows" { - if err = os.WriteFile(filepath.Join(bin, "npm.cmd"), []byte("@exit /b 99\r\n"), 0600); err != nil { - t.Fatal(err) - } - continue - } - if err = os.WriteFile(filepath.Join(bin, name+suffix), source, 0700); err != nil { - t.Fatal(err) - } - } - if runtime.GOOS == "windows" { - cli := filepath.Join(bin, "node_modules", "npm", "bin", "npm-cli.js") - if err = os.MkdirAll(filepath.Dir(cli), 0700); err != nil { - t.Fatal(err) - } - if err = os.WriteFile(cli, nil, 0600); err != nil { - t.Fatal(err) - } - } - t.Setenv("PATH", bin) - receipt := filepath.Join(t.TempDir(), "args.json") - if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"OAC_INITIALIZATION_PACKAGE_FIXTURE": "1", "OAC_INITIALIZATION_PACKAGE_RECEIPT": receipt}}); err != nil { - t.Fatal(err) - } - packages, _ := PackageDirectory() - for _, action := range []string{"npm", "python"} { - if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Packages: []string{"name with spaces", "second"}}); err != nil { - t.Fatal(action, err) - } - raw, err := os.ReadFile(receipt) - if err != nil { - t.Fatal(err) - } - var args []string - if json.Unmarshal(raw, &args) != nil { - t.Fatal("invalid fixture receipt") - } - if action == "npm" && runtime.GOOS == "windows" { - cli := filepath.Join(bin, "node_modules", "npm", "bin", "npm-cli.js") - if len(args) == 0 || args[0] != cli { - t.Fatal("initializer bypassed the shared npm shim resolver", args) - } - } - flag, target := "--prefix", filepath.Join(packages, "npm") - if action == "python" { - flag, target = "--target", filepath.Join(packages, "python") - } - found := false - for i := 0; i+1 < len(args); i++ { - if args[i] == flag && args[i+1] == target { - found = true - } - } - if !found || len(args) < 3 || args[len(args)-3] != "--" || args[len(args)-2] != "name with spaces" || args[len(args)-1] != "second" { - t.Fatal("package argv or local target mismatch", args) - } - } -} - -func TestRuntimeInitializationChild(t *testing.T) { - mode := os.Getenv("OAC_INITIALIZATION_FIXTURE") - if mode == "" { - return - } - directory := os.Getenv("OAC_INITIALIZATION_FIXTURE_DIR") - switch mode { - case "complete": - if os.Getenv("RUNTIME_TEST_PRIVATE") != "" { - os.Exit(9) - } - os.Stdout.Write(bytes.Repeat([]byte("private-output"), 10000)) - os.Stderr.Write(bytes.Repeat([]byte("private-error"), 10000)) - case "fail": - os.Exit(7) - case "child": - time.Sleep(time.Second) - _ = os.WriteFile(filepath.Join(directory, "late"), []byte("bad"), 0600) - case "parent": - binary, _ := os.Executable() - child := exec.Command(binary, "-test.run=^TestRuntimeInitializationChild$") - child.Env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=child", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) - child.Stdout, child.Stderr = os.Stdout, os.Stderr - if child.Start() != nil { - os.Exit(8) - } - _ = os.WriteFile(filepath.Join(directory, "started"), []byte("ready"), 0600) - _ = child.Wait() - } - os.Exit(0) -} - -func initializationFixture(t *testing.T) *Binding { - t.Helper() - t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", "") - t.Setenv("OAC_RUNTIME_PACKAGE_DIRECTORY", "") - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", "") - return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} -} - -func TestRuntimeInitializationConfigurationAndDirectories(t *testing.T) { - b := initializationFixture(t) - config, err := InitializationDirectory() - if err != nil || config != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "initialization") { - t.Fatal(config, err) - } - packages, err := PackageDirectory() - if err != nil || packages != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "packages") { - t.Fatal(packages, err) - } - env := map[string]string{"VALUE": "'\n$(not-a-command)", "PYTHONPATH": "operator-path"} - if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: env}); err != nil { - t.Fatal(err) - } - values, err := ReadToolEnvironment() - if err != nil || values["VALUE"] != env["VALUE"] || !strings.HasPrefix(values["PYTHONPATH"], filepath.Join(packages, "python")) { - t.Fatal("configuration mismatch", err) - } - if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "changed"}}) == nil { - t.Fatal("configuration replaced") - } - values, _ = ReadToolEnvironment() - if values["VALUE"] != env["VALUE"] { - t.Fatal("configuration changed") - } - for setting, resolver := range map[string]func() (string, error){"OAC_RUNTIME_INITIALIZATION_DIRECTORY": InitializationDirectory, "OAC_RUNTIME_PACKAGE_DIRECTORY": PackageDirectory} { - selected := t.TempDir() - t.Setenv(setting, selected) - if actual, err := resolver(); err != nil || actual != selected { - t.Fatal("operator directory ignored", err) - } - } -} - -func TestRuntimeInitializationExplicitToolEnvironment(t *testing.T) { - b := initializationFixture(t) - file := filepath.Join(t.TempDir(), "explicit.json") - original := []byte(`{"DECLARED":"value","OVERRIDE":"local"}`) - if err := os.WriteFile(file, original, 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) - env, err := ReadOptionalToolEnvironment() - if err != nil || env["DECLARED"] != "value" { - t.Fatal("explicit tool environment", err) - } - if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"OVERRIDE": "session"}}); err != nil { - t.Fatal(err) - } - if raw, err := os.ReadFile(file); err != nil || string(raw) != string(original) { - t.Fatal("operator source was modified", err) - } - // Reconnect reads the frozen result even after the operator edits its source. - if err := os.WriteFile(file, []byte(`{"DECLARED":"changed"}`), 0600); err != nil { - t.Fatal(err) - } - env, err = ReadOptionalToolEnvironment() - if err != nil || env["DECLARED"] != "value" || env["OVERRIDE"] != "session" { - t.Fatal("tool snapshot was not frozen", err) - } - if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}) == nil { - t.Fatal("configuration replay succeeded") - } -} - -func TestRuntimeInitializationRejectsMissingExplicitToolEnvironment(t *testing.T) { - b := initializationFixture(t) - t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", filepath.Join(t.TempDir(), "missing.json")) - if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}) == nil { - t.Fatal("missing explicit environment was ignored") - } - path, err := initializedToolEnvironmentPath() - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatal("failed configuration left a prepared snapshot") - } -} - -func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { - b := initializationFixture(t) - if _, err := initializationBash(); err != nil { - t.Skip("Bash unavailable; native dependency failure is tested separately") - } - if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "configured"}}); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(b.workspace, "sub"), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(b.workspace, "proof.sh"), []byte("printf skill-proof"), 0600); err != nil { - t.Fatal(err) - } - err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) - if err != nil { - t.Fatal(err) - } - for name, want := range map[string]string{"proof": "skill-proof", "value": "configured"} { - raw, err := os.ReadFile(filepath.Join(b.workspace, "sub", name)) - if err != nil || string(raw) != want { - t.Fatal(name, err) - } - } -} - -func TestRuntimeInitializationMissingDependenciesAndInvalidRequests(t *testing.T) { - b := initializationFixture(t) - t.Setenv("PATH", t.TempDir()) - t.Setenv("CLAUDE_CODE_GIT_BASH_PATH", "") - if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}); err != nil { - t.Fatal(err) - } - for _, input := range []proto.RuntimeInitialization{{Action: "setup", Command: "true"}, {Action: "npm", Packages: []string{"valid"}}, {Action: "python", Packages: []string{"valid"}}} { - var failed *dispatch.InitializationFailure - if err := b.initializeRuntime(t.Context(), input); !errors.As(err, &failed) || !strings.Contains(err.Error(), "requires") { - t.Fatal("missing dependency was not explicit", input.Action, err) - } - } - for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Packages: []string{"--unsafe"}}} { - if !errors.Is(b.initializeRuntime(t.Context(), input), agentcapabilities.ErrInvalid) { - t.Fatal("invalid request accepted", input.Action) - } - } -} - -func TestRuntimeInitializationProcessSettlesAndDiscardsOutput(t *testing.T) { - t.Setenv("RUNTIME_TEST_PRIVATE", "do-not-inherit") - binary, err := os.Executable() - if err != nil { - t.Fatal(err) - } - directory := t.TempDir() - env := append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=complete") - if err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env); err != nil { - t.Fatal(err) - } - env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=fail") - err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) - var failed *dispatch.InitializationFailure - if !errors.As(err, &failed) || failed.ExitCode == nil || *failed.ExitCode != 7 { - t.Fatal("exit status", err) - } - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=parent", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) - done := make(chan error, 1) - go func() { - done <- runInitializationProcess(ctx, binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) - }() - deadline := time.Now().Add(5 * time.Second) - for { - if _, err = os.Stat(filepath.Join(directory, "started")); err == nil { - break - } - if time.Now().After(deadline) { - cancel() - <-done - t.Fatal("fixture startup timeout") - } - time.Sleep(5 * time.Millisecond) - } - cancel() - if err = <-done; !errors.Is(err, ErrInitializationUnconfirmed) { - t.Fatal("cancel should be unknown", err) - } - time.Sleep(1100 * time.Millisecond) - if _, err = os.Stat(filepath.Join(directory, "late")); !os.IsNotExist(err) { - t.Fatal("descendant survived initialization") - } -} - -func TestRuntimeInitialFileAtomicReplacement(t *testing.T) { - b := initializationFixture(t) - path := "/workspace/nested/child/file" - for _, body := range [][]byte{nil, []byte("first"), bytes.Repeat([]byte("x"), 1<<20), []byte("replacement")} { - if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: path}, body); err != nil { - t.Fatal(err) - } - actual, err := os.ReadFile(filepath.Join(b.workspace, "nested", "child", "file")) - if err != nil || !bytes.Equal(actual, body) { - t.Fatal("atomic replacement", err) - } - } - for _, invalid := range []string{"/elsewhere/file", "/workspace/../outside", "/workspace/a\\b"} { - if !errors.Is(b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: invalid}, []byte("x")), agentcapabilities.ErrInvalid) { - t.Fatal("invalid path accepted") - } - } -} -func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { - b, req := testBinding(t) - err := b.Configure(req.PromptRequestPayload) - if err != nil { - t.Fatal(err) - } - if _, err = b.Prepare(t.Context(), req); err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(nil) - input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, - Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])} - if err = b.ApplyRuntimePreparation(t.Context(), uuid.New(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("finalized Runtime accepted file", err) - } - input.Action = "initialize" - input.File = nil - input.SHA256 = "" - input.Initialization = &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{}} - if err = b.ApplyRuntimePreparation(t.Context(), uuid.New(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("finalized Runtime accepted initialize", err) - } -} diff --git a/apps/daemon/internal/localworkspace/snapshot_marker.go b/apps/daemon/internal/localworkspace/snapshot_marker.go deleted file mode 100644 index 2e2cf4f4a..000000000 --- a/apps/daemon/internal/localworkspace/snapshot_marker.go +++ /dev/null @@ -1,119 +0,0 @@ -package localworkspace - -import ( - "bytes" - "errors" - "io" - "os" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" -) - -// snapshotMarker remembers completion outside the installed tree. It contains -// only the operator root, never capability configuration or a second inventory. -type snapshotMarker struct { - directory *os.Root - unlock func() - name string - body []byte - completed bool -} - -func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { - name, body, err := agentcapabilities.Marker(b.capabilityIdentity(), b.capabilityRoot) - if err != nil { - return nil, err - } - private, err := paths.Root() - if err != nil || agentcapabilities.ValidateLocalDirectories([]string{private}) != nil { - return nil, agentcapabilities.ErrInvalid - } - current, err := os.OpenRoot(private) - if err != nil { - return nil, agentcapabilities.ErrInvalid - } - for _, child := range []string{"daemon", "capability-installations"} { - if !privateSnapshotDirectory(current) { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - if err = runtimefs.MkdirPrivate(current, child); err != nil || runtimefs.SyncDirectory(current) != nil { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - next, err := current.OpenRoot(child) - current.Close() - if err != nil { - return nil, agentcapabilities.ErrInvalid - } - current = next - } - if !privateSnapshotDirectory(current) { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - unlock, err := runtimefs.LockDirectory(current) - if err != nil { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - marker := &snapshotMarker{directory: current, unlock: unlock, name: name, body: body} - completed, err := marker.read() - if err != nil { - marker.close() - return nil, agentcapabilities.ErrInvalid - } - marker.completed = completed - return marker, nil -} - -func privateSnapshotDirectory(root *os.Root) bool { return runtimefs.PrivateDirectory(root) == nil } - -func (m *snapshotMarker) read() (bool, error) { - file, err := runtimefs.OpenPrivate(m.directory, m.name, os.O_RDONLY) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if err != nil { - return false, err - } - defer file.Close() - info, err := file.Stat() - if err != nil { - return false, err - } - if info.Size() > 8192 { - return false, agentcapabilities.ErrInvalid - } - // Exact bytes also reject duplicate members, trailing data and extra fields. - actual, err := io.ReadAll(io.LimitReader(file, 8193)) - if err != nil || !bytes.Equal(actual, m.body) { - return false, agentcapabilities.ErrInvalid - } - return true, nil -} - -func (m *snapshotMarker) complete() error { - if m.completed { - return nil - } - file, err := runtimefs.OpenPrivate(m.directory, m.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL) - if err != nil { - return agentcapabilities.ErrInvalid - } - _, writeErr := file.Write(m.body) - syncErr := file.Sync() - closeErr := file.Close() - if writeErr != nil || syncErr != nil || closeErr != nil || runtimefs.SyncDirectory(m.directory) != nil { - return agentcapabilities.ErrInvalid - } - m.completed = true - return nil -} - -func (m *snapshotMarker) close() { - m.unlock() - m.directory.Close() -} diff --git a/apps/daemon/internal/localworkspace/snapshot_marker_test.go b/apps/daemon/internal/localworkspace/snapshot_marker_test.go deleted file mode 100644 index 4194d30af..000000000 --- a/apps/daemon/internal/localworkspace/snapshot_marker_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package localworkspace - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "runtime" - "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/agentskill" - "github.com/google/uuid" -) - -func markerBinding(t *testing.T) (*Binding, agent.PrepareRequest) { - t.Helper() - b, request := testBinding(t) - // This host's inherited ACL gives TempDir group permissions unless cleared. - if err := os.Chmod(os.Getenv("OAC_RUNTIME_HOME"), 0700); err != nil { - t.Fatal(err) - } - if err := b.Configure(request.PromptRequestPayload); err != nil { - t.Fatal(err) - } - return b, request -} -func markerPath(b *Binding) string { - identity := b.capabilityIdentity() - return filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "daemon", "capability-installations", identity.EnvironmentID+"-"+identity.SessionID+".json") -} -func TestSnapshotMarkerRoundTrip(t *testing.T) { - b, _ := markerBinding(t) - m, err := b.openSnapshotMarker() - if err != nil { - t.Fatal(err) - } - if m.completed { - t.Fatal("fresh marker complete") - } - if err := m.complete(); err != nil { - t.Fatal(err) - } - m.close() - info, err := os.Stat(markerPath(b)) - if err != nil || runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { - t.Fatal("marker not private", err) - } - raw, err := os.ReadFile(markerPath(b)) - if err != nil { - t.Fatal(err) - } - var fields map[string]string - if json.Unmarshal(raw, &fields) != nil || len(fields) != 1 || fields["capability_root"] != b.capabilityRoot { - t.Fatal("marker contains more than installation root") - } - m, err = b.openSnapshotMarker() - if err != nil { - t.Fatal(err) - } - defer m.close() - if !m.completed { - t.Fatal("completion not retained") - } - if err := m.complete(); err != nil { - t.Fatal("completed marker rewritten", err) - } -} - -func TestCompletedSnapshotLossNeverRecapturesSources(t *testing.T) { - for _, populated := range []bool{false, true} { - for _, removeRoot := range []bool{false, true} { - name := map[bool]string{false: "empty", true: "directory"}[populated] + "/" + map[bool]string{false: "manifest", true: "root"}[removeRoot] - t.Run(name, func(t *testing.T) { - b, request := markerBinding(t) - source := t.TempDir() - if populated { - writeSourceSkill(t, source, "first") - request.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} - } - if _, err := b.Prepare(t.Context(), request); err != nil { - t.Fatal(err) - } - marker, err := os.ReadFile(markerPath(b)) - if err != nil { - t.Fatal(err) - } - if removeRoot { - if err := os.RemoveAll(b.capabilityRoot); err != nil { - t.Fatal(err) - } - } else if err := os.Remove(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); err != nil { - t.Fatal(err) - } - if populated { - writeSourceSkill(t, source, "changed") - } - if _, err := b.Prepare(t.Context(), request); err == nil { - t.Fatal("lost snapshot recreated") - } - if _, err := os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { - t.Fatal("manifest recreated", err) - } - if removeRoot { - if _, err := os.Stat(b.capabilityRoot); !os.IsNotExist(err) { - t.Fatal("installation root recreated", err) - } - } - after, _ := os.ReadFile(markerPath(b)) - if string(after) != string(marker) { - t.Fatal("completion evidence changed") - } - }) - } - } -} - -func TestSnapshotMarkerBackfillsOnlyVerifiedManifest(t *testing.T) { - b, request := markerBinding(t) - source := t.TempDir() - writeSourceSkill(t, source, "frozen") - input := agentcapabilities.Input{Directories: []string{source}} - root, err := os.OpenRoot(b.capabilityRoot) - if err != nil { - t.Fatal(err) - } - if err := agentcapabilities.Finalize(root, input, b.capabilityIdentity(), b.resolveCapabilityDirectory); err != nil { - t.Fatal(err) - } - root.Close() - if err := os.RemoveAll(source); err != nil { - t.Fatal(err) - } - request.LocalEnvironment.CapabilitySources = &input - if _, err := b.Prepare(t.Context(), request); err != nil { - t.Fatal("valid manifest was not recovered without sources", err) - } - if _, err := os.Stat(markerPath(b)); err != nil { - t.Fatal("verified completion not recorded", err) - } - previousRoot := b.capabilityRoot - b.capabilityRoot = t.TempDir() - if _, err := b.Prepare(t.Context(), request); err == nil { - t.Fatal("operator root changed after completion") - } - if _, err := os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { - t.Fatal("replacement root installed", err) - } - b.capabilityRoot = previousRoot -} - -func TestSnapshotMarkerRefusesDamagedState(t *testing.T) { - for _, kind := range []string{"corrupt", "foreign root"} { - t.Run(kind, func(t *testing.T) { - b, request := markerBinding(t) - if _, err := b.Prepare(t.Context(), request); err != nil { - t.Fatal(err) - } - name := markerPath(b) - switch kind { - case "corrupt": - if err := os.WriteFile(name, []byte("{"), 0600); err != nil { - t.Fatal(err) - } - case "foreign root": - if err := os.WriteFile(name, []byte("{\"capability_root\":\"/another\"}\n"), 0600); err != nil { - t.Fatal(err) - } - - } - before, _ := os.ReadFile(name) - if _, err := b.Prepare(t.Context(), request); err == nil { - t.Fatal("invalid completion evidence accepted") - } - after, _ := os.ReadFile(name) - if string(before) != string(after) { - t.Fatal("invalid evidence overwritten") - } - }) - } -} - -func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) { - b, _ := markerBinding(t) - identity := b.capabilityIdentity() - finalize := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &agentcapabilities.Input{}} - if err := b.ApplyRuntimePreparation(t.Context(), uuid.New(), finalize, nil); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(markerPath(b)); err != nil { - t.Fatal("finalize omitted marker", err) - } - // Even a fresh empty root cannot make an already completed identity writable. - if err := os.RemoveAll(b.capabilityRoot); err != nil { - t.Fatal(err) - } - skill := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - if err := b.ApplyRuntimePreparation(t.Context(), uuid.New(), skill, []byte("x")); err == nil { - t.Fatal("completed identity accepted import") - } - if err := b.ApplyRuntimePreparation(t.Context(), uuid.New(), finalize, nil); err == nil { - t.Fatal("completed identity finalized again") - } - if _, err := os.Stat(b.capabilityRoot); !os.IsNotExist(err) { - t.Fatal("rejected operation recreated root", err) - } -} - -func TestSnapshotMarkerCancellationDoesNotPublishCompletion(t *testing.T) { - b, request := markerBinding(t) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - if _, err := b.Prepare(ctx, request); err == nil { - t.Fatal("cancelled preparation accepted") - } - if _, err := os.Stat(markerPath(b)); !os.IsNotExist(err) { - t.Fatal("cancelled preparation published marker", err) - } -} diff --git a/apps/daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go deleted file mode 100644 index ba9924c14..000000000 --- a/apps/daemon/internal/localworkspace/write.go +++ /dev/null @@ -1,34 +0,0 @@ -package localworkspace - -import ( - "context" - "errors" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// This private transfer bound is distinct from the public inline-file limit. -const WriteMaxBytes = proto.WorkspaceWriteMaxBytes - -// WriteWorkspaceFile starts only after the caller supplies the complete bounded -// body. Core must persist mutation ownership before invoking this operation. -func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []byte) (result dispatch.WorkspaceWriteResult, err error) { - if len(data) > WriteMaxBytes || !proto.ValidWorkspacePath(path) { - return result, dispatch.ErrWorkspaceWriteInvalid - } - if ctx.Err() != nil { - return result, dispatch.ErrEnvironmentUnavailable - } - w := b.writer - if !w.mu.TryLock() { - return result, dispatch.ErrWorkspaceWriteBusy - } - defer w.mu.Unlock() - if w.uncertain { - return result, dispatch.ErrWorkspaceWriteUncertain - } - defer func() { w.uncertain = errors.Is(err, dispatch.ErrWorkspaceWriteUncertain) }() - // Once admitted, finish this synchronous mutation before returning ownership. - return b.writeNativeFile(context.WithoutCancel(ctx), path, data) -} diff --git a/apps/daemon/internal/localworkspace/write_binding.go b/apps/daemon/internal/localworkspace/write_binding.go deleted file mode 100644 index e5d54ab16..000000000 --- a/apps/daemon/internal/localworkspace/write_binding.go +++ /dev/null @@ -1,8 +0,0 @@ -package localworkspace - -import "sync" - -type fileWriter struct { - mu sync.Mutex - uncertain bool -} diff --git a/apps/daemon/internal/localworkspace/write_test.go b/apps/daemon/internal/localworkspace/write_test.go deleted file mode 100644 index 7edc1a371..000000000 --- a/apps/daemon/internal/localworkspace/write_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package localworkspace - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" -) - -func nativeFileBinding(t *testing.T) *Binding { - t.Helper() - return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} -} -func TestNativeFileCreateAndNoReplace(t *testing.T) { - b := nativeFileBinding(t) - for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} { - name := fmt.Sprintf("nested/file-%d", size) - data := bytes.Repeat([]byte{17}, size) - got, err := b.WriteWorkspaceFile(t.Context(), name, data) - if err != nil || got.SizeBytes != int64(size) { - t.Fatal(size, got, err) - } - if _, err = b.WriteWorkspaceFile(t.Context(), name, []byte("overwrite")); !errors.Is(err, dispatch.ErrWorkspaceWriteUnsafe) { - t.Fatal("existing file replaced", err) - } - raw, err := os.ReadFile(filepath.Join(b.workspace, filepath.FromSlash(name))) - if err != nil || !bytes.Equal(raw, data) { - t.Fatal("file content changed", err) - } - } - if _, err := b.WriteWorkspaceFile(t.Context(), "nested", nil); !errors.Is(err, dispatch.ErrWorkspaceWriteDirectory) { - t.Fatal(err) - } - if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { - t.Fatal(err) - } - entries, err := os.ReadDir(b.workspace) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if len(entry.Name()) >= 11 && entry.Name()[:11] == ".oac-write-" { - t.Fatal("temporary retained") - } - } -} -func TestNativeFileAdmission(t *testing.T) { - b := nativeFileBinding(t) - for _, path := range []string{"", ".", "..", "/etc/passwd", "a/../b", "a//b", "a\\b", "a\nb"} { - if _, err := b.WriteWorkspaceFile(t.Context(), path, nil); !errors.Is(err, dispatch.ErrWorkspaceWriteInvalid) { - t.Fatal(path, err) - } - } - if _, err := b.WriteWorkspaceFile(t.Context(), "large", make([]byte, WriteMaxBytes+1)); !errors.Is(err, dispatch.ErrWorkspaceWriteInvalid) { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(t.Context()) - cancel() - if _, err := b.WriteWorkspaceFile(ctx, "cancelled", nil); !errors.Is(err, dispatch.ErrEnvironmentUnavailable) { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(b.workspace, "cancelled")); !os.IsNotExist(err) { - t.Fatal("cancelled request mutated workspace") - } - b.writer.mu.Lock() - _, err := b.WriteWorkspaceFile(t.Context(), "busy", nil) - b.writer.mu.Unlock() - if !errors.Is(err, dispatch.ErrWorkspaceWriteBusy) { - t.Fatal(err) - } - b.writer.uncertain = true - if _, err = b.WriteWorkspaceFile(t.Context(), "uncertain", nil); !errors.Is(err, dispatch.ErrWorkspaceWriteUncertain) { - t.Fatal(err) - } -} diff --git a/apps/daemon/internal/paths/paths.go b/apps/daemon/internal/paths/paths.go index accdd85c6..d33f47611 100644 --- a/apps/daemon/internal/paths/paths.go +++ b/apps/daemon/internal/paths/paths.go @@ -1,39 +1,17 @@ // Package paths resolves on-disk locations for oac-daemon state under -// ~/.oac/daemon// — one subdir per profile so "test" -// and "prod" servers can be connected in parallel without colliding. -// -// Files are 0o600, parent dir 0o700. These functions only resolve -// paths — callers do the I/O. +// ~/.oac. These functions only resolve paths; callers do the I/O. package paths import ( "fmt" "os" "path/filepath" - "regexp" - "strings" ) +// DefaultProfile names the directory below daemon/ that holds the +// background daemon's pid and log files. const DefaultProfile = "default" -// profilePattern restricts profile names to filesystem-safe chars so -// a malicious --profile can't escape via "../etc/passwd" tricks. -var profilePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`) - -// ValidateProfile rejects names that wouldn't survive being used as -// a directory component. -func ValidateProfile(name string) error { - if name == "" { - return fmt.Errorf("profile name must not be empty") - } - base := strings.ToUpper(strings.SplitN(name, ".", 2)[0]) - reserved := base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') - if strings.HasSuffix(name, ".") || reserved || !profilePattern.MatchString(name) { - return fmt.Errorf("profile %q must match %s", name, profilePattern.String()) - } - return nil -} - // Root returns ~/.oac. Honours OAC_RUNTIME_HOME for tests / // sandbox environments without a writable home. func Root() (string, error) { @@ -50,41 +28,16 @@ func Root() (string, error) { return filepath.Join(home, ".oac"), nil } -// ProfileDir returns ~/.oac/daemon/. It is not created here. -func ProfileDir(profile string) (string, error) { - if err := ValidateProfile(profile); err != nil { - return "", err - } - root, err := Root() - if err != nil { - return "", err - } - return filepath.Join(root, "daemon", profile), nil -} - -// AuthFile returns the absolute path to auth.json for a profile. -func AuthFile(profile string) (string, error) { - dir, err := ProfileDir(profile) - if err != nil { - return "", err - } - return filepath.Join(dir, "auth.json"), nil -} +// PIDFile returns the background daemon's connect.pid. +func PIDFile() (string, error) { return daemonFile("connect.pid") } -// PIDFile returns the absolute path to connect.pid for a profile. -func PIDFile(profile string) (string, error) { - dir, err := ProfileDir(profile) - if err != nil { - return "", err - } - return filepath.Join(dir, "connect.pid"), nil -} +// LogFile returns the background daemon's connect.log. +func LogFile() (string, error) { return daemonFile("connect.log") } -// LogFile returns the absolute path to connect.log for a profile. -func LogFile(profile string) (string, error) { - dir, err := ProfileDir(profile) +func daemonFile(name string) (string, error) { + root, err := Root() if err != nil { return "", err } - return filepath.Join(dir, "connect.log"), nil + return filepath.Join(root, "daemon", DefaultProfile, name), nil } diff --git a/apps/daemon/internal/paths/paths_test.go b/apps/daemon/internal/paths/paths_test.go index cd6fc605f..c7a100414 100644 --- a/apps/daemon/internal/paths/paths_test.go +++ b/apps/daemon/internal/paths/paths_test.go @@ -1,104 +1,23 @@ package paths_test import ( - "errors" - "os" "path/filepath" - "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" ) -// withTempHome points OAC_RUNTIME_HOME at a fresh tempdir for the test. -// t.Setenv refuses to run with t.Parallel — the exact constraint -// we want. -func withTempHome(t *testing.T) string { - t.Helper() - dir, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) +func TestRootAndFilesHonourRuntimeHome(t *testing.T) { + home := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", home) + if got, err := paths.Root(); err != nil || got != home { + t.Fatalf("Root = %q, %v, want %q", got, err, home) } - t.Setenv("OAC_RUNTIME_HOME", dir) - return dir -} - -func TestValidateProfile(t *testing.T) { - good := []string{"default", "test", "prod", "alpha-1", "alice_mac", "a.b.c", strings.Repeat("a", 64)} - for _, name := range good { - if err := paths.ValidateProfile(name); err != nil { - t.Errorf("ValidateProfile(%q) returned %v, want nil", name, err) + for name, file := range map[string]func() (string, error){"connect.pid": paths.PIDFile, "connect.log": paths.LogFile} { + if got, err := file(); err != nil || got != filepath.Join(home, "daemon", "default", name) { + t.Errorf("%s = %q, %v", name, got, err) } } - bad := []string{ - "", - "../escape", ".", "..", "profile.", "CON", "nul.json", "com1", "LPT9", - "with/slash", - "with\\backslash", - "with space", - strings.Repeat("a", 65), - "emoji_\xf0\x9f\x98\x80", - } - for _, name := range bad { - if err := paths.ValidateProfile(name); err == nil { - t.Errorf("ValidateProfile(%q) returned nil, want error", name) - } - } -} - -func TestRootHonoursOpenAgentCoreHome(t *testing.T) { - home := withTempHome(t) - got, err := paths.Root() - if err != nil { - t.Fatalf("Root: %v", err) - } - if got != home { - t.Fatalf("Root = %q, want %q", got, home) - } -} - -func TestProfileDirAndFiles(t *testing.T) { - home := withTempHome(t) - want := filepath.Join(home, "daemon", "test") - - gotDir, err := paths.ProfileDir("test") - if err != nil { - t.Fatalf("ProfileDir: %v", err) - } - if gotDir != want { - t.Fatalf("ProfileDir = %q, want %q", gotDir, want) - } - - // ProfileDir alone must not create the directory. - if _, err := os.Stat(gotDir); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("ProfileDir created dir prematurely: stat err = %v", err) - } - - cases := map[string]func(string) (string, error){ - "auth.json": paths.AuthFile, - "connect.pid": paths.PIDFile, - "connect.log": paths.LogFile, - } - for filename, fn := range cases { - got, err := fn("test") - if err != nil { - t.Fatalf("%s resolver: %v", filename, err) - } - expect := filepath.Join(want, filename) - if got != expect { - t.Errorf("%s = %q, want %q", filename, got, expect) - } - } -} - -func TestInvalidProfileShortCircuits(t *testing.T) { - _ = withTempHome(t) - if _, err := paths.ProfileDir("bad/profile"); err == nil { - t.Fatal("ProfileDir accepted invalid profile name") - } - if _, err := paths.AuthFile("bad/profile"); err == nil { - t.Fatal("AuthFile accepted invalid profile name") - } } func TestRootRejectsRelativeOverride(t *testing.T) { diff --git a/apps/daemon/internal/placement/controller_linux.go b/apps/daemon/internal/placement/controller_linux.go deleted file mode 100644 index f31c0f378..000000000 --- a/apps/daemon/internal/placement/controller_linux.go +++ /dev/null @@ -1,211 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "time" -) - -var fullID = regexp.MustCompile(`^[a-f0-9]{64}$`) -var ownerID = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`) - -// Controller is the bounded local Linux/Docker retirement consumer. Its authority -// is the operator's private enrollment, never a public executor credential. -type Controller struct { - root string - run func(context.Context, ...string) ([]byte, error) - procRoot string - cgroupRoot string - socketPath string - syncDir func(string) error -} - -// New uses a fixed local Docker endpoint and private state beneath ~/.oac. -func New() (*Controller, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - return &Controller{root: filepath.Join(home, ".oac", "placements"), run: runDocker, - procRoot: "/proc", cgroupRoot: "/sys/fs/cgroup", socketPath: localDockerSocket, syncDir: syncDirectory}, nil -} - -func (c *Controller) enroll(ctx context.Context, id, owner, workspace, environment string) (*Receipt, error) { - if !ownerID.MatchString(owner) { - return nil, errors.New("invalid placement owner") - } - unlock, err := c.lock(ctx, id) - if err != nil { - return nil, err - } - defer unlock() - if _, err := os.Lstat(c.recordPath(id)); !errors.Is(err, os.ErrNotExist) { - return nil, errors.New("placement already enrolled or record unavailable; use retire to reconcile") - } - unit, err := c.inspect(ctx, id) - if err != nil { - return nil, err - } - if !unit.State.Running { - return nil, errors.New("enrollment requires a running placement") - } - if err := c.validateProfile(unit, owner, workspace); err != nil { - return nil, err - } - host, supervisor, err := c.host(ctx) - if err != nil { - return nil, err - } - init, _, err := c.process(unit.State.Pid) - if err != nil { - return nil, err - } - group, err := c.group(init.PID, id) - if err != nil { - return nil, err - } - members, err := c.members(group) - if err != nil { - return nil, err - } - if len(members) == 0 { - return nil, errors.New("running placement has no observed members") - } - version := 1 - if environment != "" { - version = 2 - } - r := &Receipt{Version: version, EnvironmentID: environment, State: "enrolled", Owner: owner, RequestedAt: time.Now().UTC(), - Members: members, Target: Target{HostBootID: host, Supervisor: supervisor, Container: id, - Created: unit.Created, Started: unit.State.StartedAt, Restarts: unit.RestartCount, - Image: unit.Image, Workspace: workspace, Cgroup: group, Init: init}} - if err := c.save(r); err != nil { - return nil, err - } - return r, nil -} - -func (c *Controller) retirePlacement(ctx context.Context, id, environment string) (*Receipt, error) { - unlock, err := c.lock(ctx, id) - if err != nil { - return nil, err - } - defer unlock() - r, err := c.load(id) - if err != nil { - return nil, err - } - if r.EnvironmentID != environment { - return nil, errors.New("placement Environment does not match enrollment") - } - if r.State == "retired" { - // A previous process may have published the rename without completing - // its directory sync. Finish that barrier before recovering success. - if err := c.syncDir(c.root); err != nil { - return nil, fmt.Errorf("placement receipt durability unknown: %w", err) - } - return r, nil - } - err = c.retire(ctx, r) - if err != nil { - return r, fmt.Errorf("placement retirement unknown: %w", err) - } - return r, nil -} - -func (c *Controller) retire(ctx context.Context, r *Receipt) error { - host, supervisor, err := c.host(ctx) - if err != nil { - return err - } - if host != r.Target.HostBootID || supervisor != r.Target.Supervisor { - return errors.New("local supervisor incarnation changed") - } - unit, err := c.inspect(ctx, r.Target.Container) - if err != nil { - return err - } - if err := c.validateProfile(unit, r.Owner, r.Target.Workspace); err != nil { - return err - } - if unit.Created != r.Target.Created || unit.Image != r.Target.Image || - unit.State.StartedAt != r.Target.Started || unit.RestartCount != r.Target.Restarts { - return errors.New("container incarnation changed") - } - if unit.State.Running { - init, _, err := c.process(unit.State.Pid) - if err != nil { - return err - } - if init != r.Target.Init { - return errors.New("container init identity changed") - } - group, err := c.group(init.PID, unit.ID) - if err != nil || group != r.Target.Cgroup { - return errors.New("container cgroup changed") - } - members, err := c.members(group) - if err != nil { - return err - } - r.Members = append(r.Members, members...) - } - // The immutable target and current members are durable before any stop. - r.State = "stopping" - if err := c.save(r); err != nil { - return err - } - if unit.State.Running { - if _, err := c.run(ctx, "container", "stop", "--time", "1", r.Target.Container); err != nil { - return err - } - } - unit, err = c.inspect(ctx, r.Target.Container) - if err != nil { - return err - } - if unit.State.Running || unit.State.Pid != 0 || unit.State.StartedAt != r.Target.Started || - unit.RestartCount != r.Target.Restarts { - return errors.New("container is live or restarted") - } - if err := c.observeRetired(r); err != nil { - return err - } - // Non-forced removal fails if an external operator restarted the unit. No - // volumes are deleted. A crash between removal and save stays unknown. - if _, err := c.run(ctx, "container", "rm", r.Target.Container); err != nil { - return err - } - r.State = "retired" - now := time.Now().UTC() - r.RetiredAt = &now - if err := c.save(r); err != nil { - r.State = "stopping" - r.RetiredAt = nil - return err - } - return nil -} - -func (c *Controller) host(ctx context.Context) (string, string, error) { - boot, err := os.ReadFile(filepath.Join(c.procRoot, "sys/kernel/random/boot_id")) - if err != nil { - return "", "", err - } - out, err := c.run(ctx, "info", "--format", "{{json .ID}}") - if err != nil { - return "", "", err - } - var id string - if err := json.Unmarshal(out, &id); err != nil || id == "" || len(boot) == 0 { - return "", "", errors.New("missing supervisor identity") - } - return string(boot), id, nil -} diff --git a/apps/daemon/internal/placement/controller_linux_test.go b/apps/daemon/internal/placement/controller_linux_test.go deleted file mode 100644 index 16e5d2026..000000000 --- a/apps/daemon/internal/placement/controller_linux_test.go +++ /dev/null @@ -1,411 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "sync" - "testing" - "time" -) - -const testID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - -type fixture struct { - c *Controller - unit *container - workspace string - stops, removals int - fail string - t *testing.T -} - -func writeTestFile(t *testing.T, path, data string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(data), 0600); err != nil { - t.Fatal(err) - } -} - -func newFixture(t *testing.T) *fixture { - t.Helper() - home, err := os.UserHomeDir() - if err != nil { - t.Fatal(err) - } - base := filepath.Join(home, ".oac", "placement-tests") - if err := os.MkdirAll(base, 0700); err != nil { - t.Fatal(err) - } - dir, err := os.MkdirTemp(base, "case-") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - f := &fixture{t: t, workspace: filepath.Join(dir, "workspace")} - if err := os.Mkdir(f.workspace, 0700); err != nil { - t.Fatal(err) - } - f.c = &Controller{root: filepath.Join(dir, "state"), procRoot: filepath.Join(dir, "proc"), cgroupRoot: filepath.Join(dir, "cgroup"), socketPath: filepath.Join(dir, "run/docker.sock"), syncDir: syncDirectory} - f.c.run = f.run - writeTestFile(t, f.c.socketPath, "fake supervisor socket") - f.unit = &container{ID: testID, Created: "created-1", Image: "sha256:image"} - f.unit.State.Running = true - f.unit.State.Pid = 123 - f.unit.State.StartedAt = "start-1" - f.unit.Config.User = "1000:1000" - f.unit.Config.Labels = map[string]string{BindingLabel: "owner-1"} - f.unit.HostConfig.NetworkMode = "none" - f.unit.HostConfig.IpcMode = "private" - f.unit.HostConfig.CgroupnsMode = "private" - f.unit.HostConfig.CapDrop = []string{"ALL"} - f.unit.HostConfig.SecurityOpt = []string{"no-new-privileges"} - f.unit.HostConfig.RestartPolicy.Name = "no" - f.unit.Mounts = append(f.unit.Mounts, struct { - Type, Source, Destination, Propagation string - RW bool - }{Type: "bind", Source: f.workspace, Destination: "/workspace", Propagation: "rprivate", RW: true}) - writeTestFile(t, filepath.Join(f.c.procRoot, "sys/kernel/random/boot_id"), "boot-1") - writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), "1 0 8:2 / / rw - ext4 /dev/test rw\n") - writeTestFile(t, filepath.Join(f.c.procRoot, "123/stat"), "123 (native (worker)) S "+strings.Repeat("0 ", 18)+"999 0") - writeTestFile(t, filepath.Join(f.c.procRoot, "123/cgroup"), "0::/docker-"+testID+".scope\n") - writeTestFile(t, filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope/cgroup.procs"), "123\n") - writeTestFile(t, filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope/cgroup.events"), "populated 1\n") - return f -} - -func (f *fixture) run(_ context.Context, args ...string) ([]byte, error) { - if args[0] == "info" { - if f.fail == "supervisor" { - return nil, errors.New("unavailable") - } - return []byte(`"supervisor-1"`), nil - } - switch args[1] { - case "inspect": - if f.unit == nil { - return nil, os.ErrNotExist - } - return json.Marshal([]container{*f.unit}) - case "stop": - r, err := f.c.load(testID) - if err != nil || r.State != "stopping" || len(r.Members) == 0 { - f.t.Fatal("stop before durable intent", err) - } - f.stops++ - if f.fail == "stop" { - return nil, errors.New("stop unavailable") - } - f.unit.State.Running = false - f.unit.State.Pid = 0 - if f.fail != "live" && f.fail != "escaped" { - _ = os.RemoveAll(filepath.Join(f.c.procRoot, "123")) - _ = os.RemoveAll(filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope")) - } - if f.fail == "escaped" { - _ = os.RemoveAll(filepath.Join(f.c.cgroupRoot, "docker-"+testID+".scope")) - } - return nil, nil - case "rm": - f.removals++ - f.unit = nil - if f.fail == "lost-remove-ack" { - return nil, errors.New("controller lost removal acknowledgement") - } - return nil, nil - } - return nil, errors.New("unexpected Docker command") -} - -func (f *fixture) enroll() *Receipt { - f.t.Helper() - r, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace) - if err != nil { - f.t.Fatal(err) - } - return r -} - -func TestRetirementPersistsBeforeStopAndRecoversIdenticalReceipt(t *testing.T) { - f := newFixture(t) - f.enroll() - writeTestFile(t, filepath.Join(f.workspace, "history"), "retained") - r, err := f.c.Retire(context.Background(), testID) - if err != nil || r.State != "retired" { - t.Fatal(r, err) - } - fresh := *f.c - fresh.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("completed receipt must not need supervisor") - return nil, nil - } - again, err := fresh.Retire(context.Background(), testID) - if err != nil || !reflect.DeepEqual(r, again) { - t.Fatal("receipt changed across controller restart", err) - } - if f.stops != 1 || f.removals != 1 { - t.Fatal("repeated destructive action") - } - if data, err := os.ReadFile(filepath.Join(f.workspace, "history")); err != nil || string(data) != "retained" { - t.Fatal("history lost") - } -} - -func TestUnknownEvidenceNeverRemovesOrReportsRetired(t *testing.T) { - for _, failure := range []string{"supervisor", "stop", "live", "escaped", "missing", "restart", "boot", "lost-remove-ack"} { - t.Run(failure, func(t *testing.T) { - f := newFixture(t) - f.enroll() - f.fail = failure - switch failure { - case "missing": - f.unit = nil - case "restart": - f.unit.State.StartedAt = "start-2" - case "boot": - writeTestFile(t, filepath.Join(f.c.procRoot, "sys/kernel/random/boot_id"), "boot-2") - } - r, err := f.c.Retire(context.Background(), testID) - if err == nil || r.State == "retired" { - t.Fatal("uncertain retirement reported success", r, err) - } - persisted, err := f.c.load(testID) - if err != nil || persisted.State == "retired" { - t.Fatal("uncertainty lost", err) - } - if failure != "lost-remove-ack" && f.removals != 0 { - t.Fatal("removed without proof") - } - if failure == "lost-remove-ack" { - fresh := *f.c - r, err = fresh.Retire(context.Background(), testID) - if err == nil || r.State == "retired" { - t.Fatal("absence manufactured success") - } - } - }) - } -} - -func TestReconcileStoppedTargetAfterInterruptedController(t *testing.T) { - f := newFixture(t) - r := f.enroll() - r.State = "stopping" - if err := f.c.save(r); err != nil { - t.Fatal(err) - } - if _, err := f.run(context.Background(), "container", "stop"); err != nil { - t.Fatal(err) - } - fresh := *f.c - result, err := fresh.Retire(context.Background(), testID) - if err != nil || result.State != "retired" || f.stops != 1 { - t.Fatal(result, err) - } -} - -func TestConcurrentRetirementUsesOneDestructiveAttempt(t *testing.T) { - f := newFixture(t) - f.enroll() - var wg sync.WaitGroup - for range 4 { - wg.Add(1) - go func() { - defer wg.Done() - fresh := *f.c - r, err := fresh.Retire(context.Background(), testID) - if err != nil || r.State != "retired" { - t.Error(r, err) - } - }() - } - wg.Wait() - if f.stops != 1 || f.removals != 1 { - t.Fatal("duplicate destructive attempts") - } -} - -func TestTargetLockHonorsCancellation(t *testing.T) { - f := newFixture(t) - unlock, err := f.c.lock(context.Background(), testID) - if err != nil { - t.Fatal(err) - } - defer unlock() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) - defer cancel() - if _, err := f.c.lock(ctx, testID); !errors.Is(err, context.DeadlineExceeded) { - t.Fatal(err) - } -} - -func TestEnrollmentRejectsUnqualifiedAuthorityAndProfile(t *testing.T) { - for _, bad := range []string{"short-id", "wrong-owner", "privileged", "host-pid", "network", "capability", "security", "restart", "workspace", "state-mount", "socket-mount", "relative", "stopped"} { - t.Run(bad, func(t *testing.T) { - f := newFixture(t) - id, owner, workspace := testID, "owner-1", f.workspace - switch bad { - case "short-id": - id = "aaaa" - case "wrong-owner": - owner = "someone-else" - case "privileged": - f.unit.HostConfig.Privileged = true - case "host-pid": - f.unit.HostConfig.PidMode = "host" - case "network": - f.unit.HostConfig.NetworkMode = "bridge" - case "capability": - f.unit.HostConfig.CapAdd = []string{"SYS_ADMIN"} - case "security": - f.unit.HostConfig.SecurityOpt = append(f.unit.HostConfig.SecurityOpt, "seccomp=unconfined") - case "restart": - f.unit.HostConfig.RestartPolicy.Name = "always" - case "workspace": - f.unit.Mounts = nil - case "state-mount": - f.unit.Mounts[0].Source = filepath.Dir(f.c.root) - case "socket-mount": - f.unit.Mounts[0].Type = "volume" - case "relative": - workspace = "workspace" - case "stopped": - f.unit.State.Running = false - } - if _, err := f.c.Enroll(context.Background(), id, owner, workspace); err == nil { - t.Fatal("accepted unqualified enrollment") - } - if f.stops+f.removals != 0 { - t.Fatal("enrollment mutated supervisor") - } - }) - } -} - -func TestUntrustedStateIsRejected(t *testing.T) { - f := newFixture(t) - f.enroll() - if err := os.Chmod(f.c.recordPath(testID), 0644); err != nil { - t.Fatal(err) - } - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("accepted exposed authority") - } - if f.stops+f.removals != 0 { - t.Fatal("mutated supervisor before authorization") - } -} - -func TestBindingCannotBeOverwrittenOrAppliedToAnotherContainer(t *testing.T) { - f := newFixture(t) - f.enroll() - if _, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace); err == nil { - t.Fatal("binding overwritten") - } - if _, err := f.c.Retire(context.Background(), strings.Repeat("b", 64)); err == nil { - t.Fatal("another target authorized") - } - f.unit.ID = strings.Repeat("b", 64) - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("supervisor target substitution accepted") - } - if f.stops+f.removals != 0 { - t.Fatal("wrong target mutated") - } -} - -func TestSymlinkedStateAndChangedProfileAreRejected(t *testing.T) { - f := newFixture(t) - f.enroll() - path := f.c.recordPath(testID) - if err := os.Rename(path, path+".original"); err != nil { - t.Fatal(err) - } - if err := os.Symlink(path+".original", path); err != nil { - t.Fatal(err) - } - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("symlinked authority accepted") - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Rename(path+".original", path); err != nil { - t.Fatal(err) - } - f.unit.HostConfig.Privileged = true - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("changed profile accepted") - } - if f.stops+f.removals != 0 { - t.Fatal("mutated unqualified placement") - } -} - -func TestRootAndCanonicalSupervisorSocketAreNeverExposed(t *testing.T) { - f := newFixture(t) - if !inside("/", f.c.root) || !inside("/", f.c.socketPath) { - t.Fatal("filesystem root hides protected descendants") - } - f.workspace = filepath.Dir(f.c.socketPath) - f.unit.Mounts[0].Source = f.workspace - alias := filepath.Join(filepath.Dir(f.c.root), "socket-alias") - if err := os.Symlink(f.c.socketPath, alias); err != nil { - t.Fatal(err) - } - f.c.socketPath = alias - if _, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace); err == nil || !strings.Contains(err.Error(), "supervisor socket") { - t.Fatal("canonical socket exposed", err) - } - if f.stops+f.removals != 0 { - t.Fatal("unqualified supervisor mutated") - } -} - -func TestPublishedReceiptMustCompleteDirectorySyncOnRecovery(t *testing.T) { - f := newFixture(t) - f.enroll() - f.c.syncDir = func(path string) error { - r, err := f.c.load(testID) - if err != nil { - return err - } - if r.State == "retired" { - return errors.New("injected directory sync failure after rename") - } - return syncDirectory(path) - } - if r, err := f.c.Retire(context.Background(), testID); err == nil || r.State == "retired" { - t.Fatal("reported success before durable receipt", r, err) - } - published, err := f.c.load(testID) - if err != nil || published.State != "retired" { - t.Fatal("failure did not exercise published rename", published, err) - } - fresh := *f.c - fresh.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("receipt recovery must not mutate supervisor") - return nil, nil - } - if _, err := fresh.Retire(context.Background(), testID); err == nil { - t.Fatal("recovery skipped durability barrier") - } - synced := false - fresh.syncDir = func(path string) error { synced = true; return syncDirectory(path) } - recovered, err := fresh.Retire(context.Background(), testID) - if err != nil || !synced || !reflect.DeepEqual(recovered, published) { - t.Fatal("durable receipt recovery failed", recovered, err) - } - if f.stops != 1 || f.removals != 1 { - t.Fatal("repeated destructive action") - } -} diff --git a/apps/daemon/internal/placement/controller_other.go b/apps/daemon/internal/placement/controller_other.go deleted file mode 100644 index 2bd82f666..000000000 --- a/apps/daemon/internal/placement/controller_other.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !linux - -package placement - -import ( - "context" - "errors" -) - -// Controller requires the qualified local Linux supervisor profile. -type Controller struct{} - -// New rejects unqualified hosts before any supervisor operation. -func New() (*Controller, error) { - return nil, errors.New("placement retirement requires local Linux/Docker with cgroup v2") -} - -func (*Controller) enroll(context.Context, string, string, string, string) (*Receipt, error) { - return nil, errors.New("unsupported placement host") -} - -func (*Controller) retirePlacement(context.Context, string, string) (*Receipt, error) { - return nil, errors.New("unsupported placement host") -} diff --git a/apps/daemon/internal/placement/docker_linux.go b/apps/daemon/internal/placement/docker_linux.go deleted file mode 100644 index 1eb115f18..000000000 --- a/apps/daemon/internal/placement/docker_linux.go +++ /dev/null @@ -1,164 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "syscall" -) - -const localDockerSocket = "/var/run/docker.sock" - -// Do not inherit a remote Docker context while observing local /proc and cgroups. -func runDocker(ctx context.Context, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, "docker", append([]string{"--host", "unix://" + localDockerSocket}, args...)...) - for _, v := range os.Environ() { - if !strings.HasPrefix(v, "DOCKER_") { - cmd.Env = append(cmd.Env, v) - } - } - out, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("local Docker %s failed: %w", args[0], err) - } - return out, nil -} - -type container struct { - ID string `json:"Id"` - Created string - Image string - RestartCount int - State struct { - Running bool - Pid int - StartedAt string - Paused bool - Restarting bool - } - Config struct { - Labels map[string]string - User string - } - HostConfig struct { - Privileged bool - PidMode string - IpcMode string - CgroupnsMode string - NetworkMode string - CapAdd []string - CapDrop []string - SecurityOpt []string - Devices []json.RawMessage - DeviceRequests []json.RawMessage - DeviceCgroupRules []string - VolumesFrom []string - RestartPolicy struct{ Name string } - } - Mounts []struct { - Type, Source, Destination, Propagation string - RW bool - } -} - -func (c *Controller) inspect(ctx context.Context, id string) (*container, error) { - out, err := c.run(ctx, "container", "inspect", id) - if err != nil { - return nil, err - } - var units []container - if err := json.Unmarshal(out, &units); err != nil { - return nil, err - } - if len(units) != 1 || units[0].ID != id { - return nil, errors.New("supervisor returned wrong target") - } - return &units[0], nil -} - -func contains(values []string, value string) bool { - for _, v := range values { - if v == value { - return true - } - } - return false -} - -func inside(parent, path string) bool { - relative, err := filepath.Rel(parent, path) - return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator)) -} - -func (c *Controller) validateProfile(u *container, owner, workspace string) error { - h := u.HostConfig - uid, err := strconv.Atoi(strings.Split(u.Config.User, ":")[0]) - if err != nil || uid <= 0 || u.Config.Labels[BindingLabel] != owner || u.State.Paused || u.State.Restarting || - h.Privileged || h.PidMode != "" || h.IpcMode != "private" || h.CgroupnsMode != "private" || - h.NetworkMode != "none" || len(h.CapAdd) != 0 || !contains(h.CapDrop, "ALL") || - (len(h.SecurityOpt) != 1 || !contains(h.SecurityOpt, "no-new-privileges")) || len(h.Devices)+len(h.DeviceRequests)+len(h.DeviceCgroupRules)+len(h.VolumesFrom) != 0 || - h.RestartPolicy.Name != "no" { - return errors.New("placement is outside qualified unprivileged local Docker profile") - } - if !filepath.IsAbs(workspace) || filepath.Clean(workspace) != workspace { - return errors.New("workspace must be canonical and absolute") - } - resolved, err := filepath.EvalSymlinks(workspace) - if err != nil || resolved != workspace { - return errors.New("workspace must exist without symlink aliases") - } - info, err := os.Stat(workspace) - if err != nil || !info.IsDir() { - return errors.New("workspace must be a directory") - } - var fs syscall.Statfs_t - if err := syscall.Statfs(workspace, &fs); err != nil { - return err - } - switch uint64(fs.Type) { - case 0xef53, 0x58465342, 0x9123683e, 0x01021994: - default: - return errors.New("unqualified workspace filesystem") - } - socket, err := filepath.EvalSymlinks(c.socketPath) - if err != nil { - return fmt.Errorf("cannot resolve supervisor socket: %w", err) - } - mounts, err := c.hostMounts(workspace) - if err != nil { - return err - } - found := false - for _, m := range u.Mounts { - canonical, err := filepath.EvalSymlinks(m.Source) - if err != nil || canonical != m.Source || inside(m.Source, c.root) || inside(c.root, m.Source) || inside(m.Source, socket) { - return errors.New("mount aliases or exposes controller state or supervisor socket") - } - if err := unaliasedSource(m.Source, mounts); err != nil { - return err - } - if m.Type != "bind" || (m.Propagation != "rprivate" && m.Propagation != "") { - return errors.New("only private bind mounts are qualified") - } - if m.Source == workspace && m.RW && !found { - found = true - continue - } - info, err := os.Stat(m.Source) - if err != nil || m.RW || !info.Mode().IsRegular() { - return errors.New("additional mounts must be read-only regular files") - } - } - if !found { - return errors.New("missing exact retained workspace bind") - } - return nil -} diff --git a/apps/daemon/internal/placement/environment.go b/apps/daemon/internal/placement/environment.go deleted file mode 100644 index 1be6113bf..000000000 --- a/apps/daemon/internal/placement/environment.go +++ /dev/null @@ -1,38 +0,0 @@ -package placement - -import ( - "context" - "errors" - "github.com/google/uuid" -) - -func validEnvironment(id string) bool { - parsed, err := uuid.Parse(id) - return err == nil && parsed != uuid.Nil && parsed.String() == id -} - -// Enroll records an unscoped operator placement without Environment authority. -func (c *Controller) Enroll(ctx context.Context, id, owner, workspace string) (*Receipt, error) { - return c.enroll(ctx, id, owner, workspace, "") -} - -// EnrollEnvironment records an immutable operator-confirmed Environment association. -func (c *Controller) EnrollEnvironment(ctx context.Context, id, owner, workspace, environment string) (*Receipt, error) { - if !validEnvironment(environment) { - return nil, errors.New("invalid placement Environment ID") - } - return c.enroll(ctx, id, owner, workspace, environment) -} - -// Retire reconciles only unscoped operator enrollment. -func (c *Controller) Retire(ctx context.Context, id string) (*Receipt, error) { - return c.retirePlacement(ctx, id, "") -} - -// RetireEnvironment requires the same Environment before any supervisor access. -func (c *Controller) RetireEnvironment(ctx context.Context, id, environment string) (*Receipt, error) { - if !validEnvironment(environment) { - return nil, errors.New("invalid placement Environment ID") - } - return c.retirePlacement(ctx, id, environment) -} diff --git a/apps/daemon/internal/placement/environment_linux_test.go b/apps/daemon/internal/placement/environment_linux_test.go deleted file mode 100644 index 4c5998268..000000000 --- a/apps/daemon/internal/placement/environment_linux_test.go +++ /dev/null @@ -1,198 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "os" - "reflect" - "sync" - "testing" -) - -const testEnvironment = "3fb4bdd9-d8c7-4f12-810c-9da932e06bc4" -const otherEnvironment = "384ff427-c83f-4484-80a7-58aa95662f97" - -func (f *fixture) enrollEnvironment() *Receipt { - f.t.Helper() - r, err := f.c.EnrollEnvironment(context.Background(), testID, "owner-1", f.workspace, testEnvironment) - if err != nil { - f.t.Fatal(err) - } - return r -} - -func TestEnvironmentScopeRejectsBeforeSupervisorAccess(t *testing.T) { - for _, state := range []string{"enrolled", "retired"} { - t.Run(state, func(t *testing.T) { - f := newFixture(t) - f.enrollEnvironment() - if state == "retired" { - if _, err := f.c.RetireEnvironment(t.Context(), testID, testEnvironment); err != nil { - t.Fatal(err) - } - } - f.c.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("scope rejection accessed supervisor") - return nil, nil - } - if _, err := f.c.Retire(t.Context(), testID); err == nil { - t.Fatal("scope omitted") - } - for _, id := range []string{otherEnvironment, "", "not-a-uuid", "00000000-0000-0000-0000-000000000000"} { - if _, err := f.c.RetireEnvironment(t.Context(), testID, id); err == nil { - t.Fatal("invalid scope accepted", id) - } - } - }) - } -} - -func TestEnvironmentEnrollmentIsImmutableAndSeparateFromLegacy(t *testing.T) { - f := newFixture(t) - r := f.enrollEnvironment() - if r.Version != 2 || r.EnvironmentID != testEnvironment { - t.Fatal("scope not versioned", r) - } - if _, err := f.c.EnrollEnvironment(t.Context(), testID, "owner-1", f.workspace, otherEnvironment); err == nil { - t.Fatal("scope reassigned") - } - if _, err := f.c.Enroll(t.Context(), testID, "owner-1", f.workspace); err == nil { - t.Fatal("scope downgraded") - } - retained, err := f.c.load(testID) - if err != nil || !reflect.DeepEqual(r, retained) { - t.Fatal("enrollment changed", err) - } - legacy := newFixture(t) - old := legacy.enroll() - if old.Version != 1 || old.EnvironmentID != "" { - t.Fatal("legacy enrollment changed") - } - if _, err := legacy.c.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil { - t.Fatal("legacy record gained scope") - } - if legacy.stops+legacy.removals != 0 { - t.Fatal("legacy target mutated") - } - if _, err := legacy.c.Retire(t.Context(), testID); err != nil { - t.Fatal(err) - } -} - -func TestEnvironmentReceiptVersionRejectsMissingOrDowngradedScope(t *testing.T) { - for _, mode := range []string{"missing", "malformed", "downgraded", "future"} { - t.Run(mode, func(t *testing.T) { - f := newFixture(t) - r := f.enrollEnvironment() - switch mode { - case "missing": - r.EnvironmentID = "" - case "malformed": - r.EnvironmentID = "invalid" - case "downgraded": - r.Version = 1 - case "future": - r.Version = 3 - } - data, err := json.Marshal(r) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(f.c.recordPath(testID), data, 0600); err != nil { - t.Fatal(err) - } - f.c.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("invalid receipt accessed supervisor") - return nil, nil - } - if _, err := f.c.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil { - t.Fatal("invalid scoped receipt accepted") - } - if _, err := f.c.Retire(t.Context(), testID); err == nil { - t.Fatal("invalid scoped receipt accepted by legacy path") - } - }) - } -} - -func TestEnvironmentRetirementConcurrentRecovery(t *testing.T) { - f := newFixture(t) - f.enrollEnvironment() - var wg sync.WaitGroup - results := make(chan *Receipt, 4) - for range 4 { - wg.Add(1) - go func() { - defer wg.Done() - fresh := *f.c - r, err := fresh.RetireEnvironment(t.Context(), testID, testEnvironment) - if err != nil { - t.Error(err) - return - } - results <- r - }() - } - wg.Wait() - close(results) - var expected *Receipt - for r := range results { - if expected == nil { - expected = r - } - if !reflect.DeepEqual(expected, r) { - t.Fatal("concurrent receipt changed") - } - } - if expected == nil || expected.State != "retired" || expected.EnvironmentID != testEnvironment || f.stops != 1 || f.removals != 1 { - t.Fatal("retirement not unique", expected) - } - fresh := *f.c - fresh.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("recovered receipt touched supervisor") - return nil, nil - } - again, err := fresh.RetireEnvironment(t.Context(), testID, testEnvironment) - if err != nil || !reflect.DeepEqual(expected, again) { - t.Fatal("fresh recovery differs", err) - } -} - -func TestEnvironmentUnknownAndChangedTargetRemainUnresolved(t *testing.T) { - for _, failure := range []string{"lost-remove-ack", "restart"} { - t.Run(failure, func(t *testing.T) { - f := newFixture(t) - f.enrollEnvironment() - f.fail = failure - if failure == "restart" { - f.unit.State.StartedAt = "replacement" - } - if r, err := f.c.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil || r.State == "retired" { - t.Fatal("uncertainty lost", r, err) - } - fresh := *f.c - if r, err := fresh.RetireEnvironment(t.Context(), testID, testEnvironment); err == nil || r.State == "retired" { - t.Fatal("retry fabricated retirement", r, err) - } - if _, err := fresh.Retire(t.Context(), testID); err == nil { - t.Fatal("unscoped retry bypassed unknown scope") - } - }) - } -} - -func TestInvalidEnvironmentEnrollmentDoesNotTouchSupervisor(t *testing.T) { - f := newFixture(t) - f.c.run = func(context.Context, ...string) ([]byte, error) { - t.Fatal("invalid scope touched supervisor") - return nil, errors.New("unexpected") - } - for _, id := range []string{"", "3FB4BDD9-D8C7-4F12-810C-9DA932E06BC4", "00000000-0000-0000-0000-000000000000"} { - if _, err := f.c.EnrollEnvironment(t.Context(), testID, "owner-1", f.workspace, id); err == nil { - t.Fatal("invalid scope enrolled") - } - } -} diff --git a/apps/daemon/internal/placement/mounts_linux.go b/apps/daemon/internal/placement/mounts_linux.go deleted file mode 100644 index bb32822bf..000000000 --- a/apps/daemon/internal/placement/mounts_linux.go +++ /dev/null @@ -1,69 +0,0 @@ -//go:build linux - -package placement - -import ( - "errors" - "os" - "path/filepath" - "strings" -) - -type hostMount struct { - device, root, point string -} - -// The initial profile excludes host mount aliases rather than trying to resolve -// arbitrary backing-path graphs. Mount administration remains a trusted host act. -func (c *Controller) hostMounts(workspace string) ([]hostMount, error) { - data, err := os.ReadFile(filepath.Join(c.procRoot, "self/mountinfo")) - if err != nil { - return nil, err - } - decode := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`) - var mounts []hostMount - for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { - fields := strings.Fields(line) - if len(fields) < 10 { - return nil, errors.New("incomplete host mount evidence") - } - mount := hostMount{device: fields[2], root: decode.Replace(fields[3]), point: decode.Replace(fields[4])} - if !filepath.IsAbs(mount.point) || filepath.Clean(mount.point) != mount.point { - return nil, errors.New("invalid host mount evidence") - } - if mount.point != workspace && inside(workspace, mount.point) { - return nil, errors.New("nested workspace mounts are unqualified") - } - mounts = append(mounts, mount) - } - return mounts, nil -} - -func unaliasedSource(source string, mounts []hostMount) error { - selected := -1 - for i, mount := range mounts { - if !inside(mount.point, source) { - continue - } - if selected == -1 || len(mount.point) > len(mounts[selected].point) { - selected = i - } - } - if selected == -1 || mounts[selected].root != "/" { - return errors.New("mount source lacks whole-filesystem evidence; host aliases and subvolume roots are unqualified") - } - count := 0 - for _, mount := range mounts { - if mount.device == mounts[selected].device { - count++ - } - // A stacked mount point is ambiguous even if it uses another device. - if mount.point == mounts[selected].point && mount.device != mounts[selected].device { - return errors.New("stacked source mounts are unqualified") - } - } - if count != 1 { - return errors.New("multiple host mounts of the source filesystem are unqualified") - } - return nil -} diff --git a/apps/daemon/internal/placement/mounts_linux_test.go b/apps/daemon/internal/placement/mounts_linux_test.go deleted file mode 100644 index f50089fdb..000000000 --- a/apps/daemon/internal/placement/mounts_linux_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "fmt" - "path/filepath" - "testing" -) - -func TestAmbiguousHostMountsCannotAuthorizeEnrollment(t *testing.T) { - for _, scenario := range []string{"directory-bind", "whole-filesystem-bind", "nested-mount", "stacked-mount", "missing-evidence"} { - t.Run(scenario, func(t *testing.T) { - f := newFixture(t) - info := "1 0 8:2 / / rw - ext4 /dev/test rw\n" - switch scenario { - case "directory-bind": - info += fmt.Sprintf("2 1 8:2 /home/operator %s rw - ext4 /dev/test rw\n", f.workspace) - case "whole-filesystem-bind": - info += fmt.Sprintf("2 1 8:2 / %s rw - ext4 /dev/test rw\n", f.workspace) - case "nested-mount": - info += fmt.Sprintf("2 1 0:8 / %s/secret rw - tmpfs tmpfs rw\n", f.workspace) - case "stacked-mount": - info += fmt.Sprintf("2 1 0:8 / %s rw - tmpfs tmpfs rw\n3 1 0:9 / %s rw - tmpfs tmpfs rw\n", f.workspace, f.workspace) - case "missing-evidence": - info = "" - } - writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), info) - if _, err := f.c.Enroll(context.Background(), testID, "owner-1", f.workspace); err == nil { - t.Fatal("ambiguous host mount authorized") - } - if f.stops+f.removals != 0 { - t.Fatal("unqualified supervisor mutated") - } - }) - } -} - -func TestNewHostAliasIsRejectedBeforeRetirement(t *testing.T) { - f := newFixture(t) - f.enroll() - writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), "1 0 8:2 / / rw - ext4 /dev/test rw\n2 1 8:2 /home/operator /mnt/alias rw - ext4 /dev/test rw\n") - if _, err := f.c.Retire(context.Background(), testID); err == nil { - t.Fatal("changed host mount topology accepted") - } - if f.stops+f.removals != 0 { - t.Fatal("supervisor mutated before mount qualification") - } -} - -func TestUnrelatedNamespaceMountDoesNotInvalidateStorageEvidence(t *testing.T) { - f := newFixture(t) - writeTestFile(t, filepath.Join(f.c.procRoot, "self/mountinfo"), "1 0 8:2 / / rw - ext4 /dev/test rw\n2 1 0:4 net:[12345] /run/docker/netns/example rw - nsfs nsfs rw\n") - f.enroll() -} diff --git a/apps/daemon/internal/placement/observe_linux.go b/apps/daemon/internal/placement/observe_linux.go deleted file mode 100644 index 90401a26f..000000000 --- a/apps/daemon/internal/placement/observe_linux.go +++ /dev/null @@ -1,123 +0,0 @@ -//go:build linux - -package placement - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" -) - -func (c *Controller) process(pid int) (Process, bool, error) { - if pid <= 0 { - return Process{}, false, errors.New("missing process identity") - } - data, err := os.ReadFile(filepath.Join(c.procRoot, strconv.Itoa(pid), "stat")) - if err != nil { - return Process{}, false, err - } - end := strings.LastIndexByte(string(data), ')') - if end < 0 { - return Process{}, false, errors.New("malformed process identity") - } - fields := strings.Fields(string(data[end+1:])) - if len(fields) < 20 { - return Process{}, false, errors.New("incomplete process identity") - } - return Process{PID: pid, Start: fields[19]}, fields[0] != "Z" && fields[0] != "X", nil -} - -func (c *Controller) group(pid int, id string) (string, error) { - data, err := os.ReadFile(filepath.Join(c.procRoot, strconv.Itoa(pid), "cgroup")) - if err != nil { - return "", err - } - for _, line := range strings.Split(string(data), "\n") { - if !strings.HasPrefix(line, "0::/") { - continue - } - path := strings.TrimPrefix(line, "0::") - base := filepath.Base(path) - if filepath.Clean(path) != path || (base != id && base != "docker-"+id+".scope") { - return "", errors.New("cgroup does not identify the exact container") - } - return path, nil - } - return "", errors.New("placement requires cgroup v2") -} - -func (c *Controller) groupPath(group string) (string, error) { - if !filepath.IsAbs(group) || filepath.Clean(group) != group || group == "/" { - return "", errors.New("invalid saved cgroup") - } - return filepath.Join(c.cgroupRoot, group), nil -} - -func (c *Controller) members(group string) ([]Process, error) { - path, err := c.groupPath(group) - if err != nil { - return nil, err - } - var members []Process - err = filepath.WalkDir(path, func(p string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.Name() != "cgroup.procs" { - return nil - } - data, err := os.ReadFile(p) - if err != nil { - return err - } - for _, value := range strings.Fields(string(data)) { - pid, err := strconv.Atoi(value) - if err != nil { - return err - } - identity, _, err := c.process(pid) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return err - } - members = append(members, identity) - } - return nil - }) - return members, err -} - -func (c *Controller) observeRetired(r *Receipt) error { - path, err := c.groupPath(r.Target.Cgroup) - if err != nil { - return err - } - data, err := os.ReadFile(filepath.Join(path, "cgroup.events")) - if errors.Is(err, os.ErrNotExist) { - if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) { - return errors.New("cgroup evidence unavailable") - } - } else if err != nil { - return err - } else if !strings.Contains("\n"+string(data), "\npopulated 0\n") { - return errors.New("placement cgroup remains populated") - } - for _, old := range append(append([]Process{}, r.Members...), r.Target.Init) { - current, live, err := c.process(old.PID) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return err - } - if current == old && live { - return fmt.Errorf("old placement process %d remains live", old.PID) - } - } - return nil -} diff --git a/apps/daemon/internal/placement/state_linux.go b/apps/daemon/internal/placement/state_linux.go deleted file mode 100644 index 31b72aa9a..000000000 --- a/apps/daemon/internal/placement/state_linux.go +++ /dev/null @@ -1,161 +0,0 @@ -//go:build linux - -package placement - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "syscall" - "time" -) - -func (c *Controller) recordPath(id string) string { return filepath.Join(c.root, id+".json") } - -func privateFile(f *os.File) error { - info, err := f.Stat() - if err != nil { - return err - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 { - return errors.New("placement state must be a private, owned regular file") - } - return nil -} - -func secureDirectory(path string) error { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return errors.New("state directory must be canonical and absolute") - } - if path != "/" { - if err := secureDirectory(filepath.Dir(path)); err != nil { - return err - } - } - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - if err := os.Mkdir(path, 0700); err != nil && !errors.Is(err, os.ErrExist) { - return err - } - parent, syncErr := os.Open(filepath.Dir(path)) - if syncErr != nil { - return syncErr - } - syncErr = parent.Sync() - closeErr := parent.Close() - if syncErr != nil { - return syncErr - } - if closeErr != nil { - return closeErr - } - info, err = os.Lstat(path) - } - if err != nil { - return err - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.IsDir() || info.Mode().Perm()&0022 != 0 || (st.Uid != 0 && st.Uid != uint32(os.Getuid())) { - return fmt.Errorf("untrusted placement state directory: %s", path) - } - return nil -} - -func (c *Controller) lock(ctx context.Context, id string) (func(), error) { - if !fullID.MatchString(id) { - return nil, errors.New("require full immutable container ID") - } - if err := secureDirectory(c.root); err != nil { - return nil, err - } - info, err := os.Stat(c.root) - if err != nil || info.Mode().Perm() != 0700 { - return nil, errors.New("placement state directory must have mode 0700") - } - f, err := os.OpenFile(filepath.Join(c.root, id+".lock"), os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600) - if err != nil { - return nil, err - } - if err := privateFile(f); err != nil { - f.Close() - return nil, err - } - for { - err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err == nil { - break - } - if err != syscall.EWOULDBLOCK { - f.Close() - return nil, err - } - select { - case <-ctx.Done(): - f.Close() - return nil, ctx.Err() - case <-time.After(20 * time.Millisecond): - } - } - return func() { _ = f.Close() }, nil -} - -func (c *Controller) load(id string) (*Receipt, error) { - f, err := os.OpenFile(c.recordPath(id), os.O_RDONLY|syscall.O_NOFOLLOW, 0) - if err != nil { - return nil, err - } - defer f.Close() - if err := privateFile(f); err != nil { - return nil, err - } - var r Receipt - if err := json.NewDecoder(f).Decode(&r); err != nil { - return nil, err - } - validScope := (r.Version == 1 && r.EnvironmentID == "") || (r.Version == 2 && validEnvironment(r.EnvironmentID)) - if !validScope || r.Target.Container != id || !ownerID.MatchString(r.Owner) || - (r.State != "enrolled" && r.State != "stopping" && r.State != "retired") || - (r.State == "retired") != (r.RetiredAt != nil) { - return nil, errors.New("invalid placement receipt") - } - return &r, nil -} - -func (c *Controller) save(r *Receipt) error { - data, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err - } - f, err := os.CreateTemp(c.root, ".receipt-*") - if err != nil { - return err - } - defer os.Remove(f.Name()) - if _, err = f.Write(append(data, '\n')); err == nil { - err = f.Sync() - } - closeErr := f.Close() - if err != nil { - return err - } - if closeErr != nil { - return closeErr - } - if err := os.Rename(f.Name(), c.recordPath(r.Target.Container)); err != nil { - return err - } - return c.syncDir(c.root) -} - -func syncDirectory(path string) error { - dir, err := os.Open(path) - if err != nil { - return err - } - defer dir.Close() - return dir.Sync() -} diff --git a/apps/daemon/internal/placement/types.go b/apps/daemon/internal/placement/types.go deleted file mode 100644 index 23079dca4..000000000 --- a/apps/daemon/internal/placement/types.go +++ /dev/null @@ -1,41 +0,0 @@ -// Package placement owns explicit operator-managed execution placement retirement. -// It does not authorize Core dispatch, release harnesses or replay native work. -package placement - -import "time" - -// BindingLabel marks a container explicitly created for operator enrollment. -const BindingLabel = "io.oac.placement" - -// Receipt is durable local evidence for one exact placement incarnation. -// Only State == "retired" reports qualified settlement; other states are unknown. -type Receipt struct { - EnvironmentID string `json:"environment_id,omitempty"` - Version int `json:"version"` - State string `json:"state"` - Owner string `json:"owner"` - Target Target `json:"target"` - Members []Process `json:"members"` - RequestedAt time.Time `json:"requested_at"` - RetiredAt *time.Time `json:"retired_at,omitempty"` -} - -// Target binds local supervisor, immutable container and observed incarnation. -type Target struct { - HostBootID string `json:"host_boot_id"` - Supervisor string `json:"supervisor"` - Container string `json:"container"` - Created string `json:"created"` - Started string `json:"started"` - Restarts int `json:"restarts"` - Image string `json:"image"` - Workspace string `json:"workspace"` - Cgroup string `json:"cgroup"` - Init Process `json:"init"` -} - -// Process includes Linux start ticks to distinguish a reused PID. -type Process struct { - PID int `json:"pid"` - Start string `json:"start"` -} diff --git a/apps/daemon/internal/transport/bootstrap.go b/apps/daemon/internal/transport/bootstrap.go index b684fee5d..b770d4a47 100644 --- a/apps/daemon/internal/transport/bootstrap.go +++ b/apps/daemon/internal/transport/bootstrap.go @@ -111,35 +111,3 @@ func joinURL(base, path string) (string, error) { u.Path = u.Path + path return u.String(), nil } - -// DeriveWSURL turns a Bootstrap response's ws_url into the absolute URL -// to dial. Empty ws_url (dev mode without a separate public hostname) -// is derived from serverBase by swapping http→ws / https→wss. Non- -// absolute ws_url is rejected — the server-side handler is the only -// component that knows the externally-reachable host. -func DeriveWSURL(boot BootstrapResponse, serverBase string) (string, error) { - if abs := strings.TrimSpace(boot.WSURL); abs != "" { - u, err := url.Parse(abs) - if err != nil { - return "", fmt.Errorf("transport: parse ws_url %q: %w", abs, err) - } - if u.Scheme != "ws" && u.Scheme != "wss" { - return "", fmt.Errorf("transport: ws_url %q must use ws:// or wss://", abs) - } - return abs, nil - } - u, err := url.Parse(strings.TrimRight(serverBase, "/")) - if err != nil { - return "", fmt.Errorf("transport: parse serverBase: %w", err) - } - switch u.Scheme { - case "https": - u.Scheme = "wss" - case "http": - u.Scheme = "ws" - default: - return "", fmt.Errorf("transport: serverBase scheme %q must be http or https", u.Scheme) - } - u.Path = strings.TrimRight(u.Path, "/") + "/agent-daemon/ws" - return u.String(), nil -} diff --git a/apps/daemon/internal/transport/bootstrap_test.go b/apps/daemon/internal/transport/bootstrap_test.go index 1b8467f4f..58440d46d 100644 --- a/apps/daemon/internal/transport/bootstrap_test.go +++ b/apps/daemon/internal/transport/bootstrap_test.go @@ -116,46 +116,3 @@ func TestBootstrapRejectsEmptyInputs(t *testing.T) { }) } } - -func TestDeriveWSURLPrefersAbsolute(t *testing.T) { - got, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: "wss://prod/agent-daemon/ws"}, "https://anything") - if err != nil { - t.Fatalf("DeriveWSURL: %v", err) - } - if got != "wss://prod/agent-daemon/ws" { - t.Errorf("got %q, want wss://prod/agent-daemon/ws", got) - } -} - -func TestDeriveWSURLFallsBackToServerBase(t *testing.T) { - cases := []struct{ base, want string }{ - {"https://core.example.com", "wss://core.example.com/agent-daemon/ws"}, - {"http://localhost:3000", "ws://localhost:3000/agent-daemon/ws"}, - {"http://localhost:3000/", "ws://localhost:3000/agent-daemon/ws"}, - {"https://core.example.com/api", "wss://core.example.com/api/agent-daemon/ws"}, - } - for _, tc := range cases { - got, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: ""}, tc.base) - if err != nil { - t.Errorf("DeriveWSURL(%q): %v", tc.base, err) - continue - } - if got != tc.want { - t.Errorf("DeriveWSURL(%q) = %q, want %q", tc.base, got, tc.want) - } - } -} - -func TestDeriveWSURLRejectsRelativeWSURL(t *testing.T) { - _, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: "/agent-daemon/ws"}, "https://x") - if err == nil { - t.Fatal("DeriveWSURL accepted relative ws_url") - } -} - -func TestDeriveWSURLRejectsBadScheme(t *testing.T) { - _, err := transport.DeriveWSURL(transport.BootstrapResponse{WSURL: ""}, "ftp://example") - if err == nil { - t.Fatal("DeriveWSURL accepted ftp scheme") - } -} diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index b984dd8ac..b20e99c10 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -2,7 +2,7 @@ title: "Environments and Templates" --- -An Environment is the execution resource of a Session: the machine, workspace and prepared capabilities that a Harness runs in. A Session creates its Environment through its `environment` configuration; there is no standalone create call. An Environment Template is reusable preparation configuration that a Session resolves when it is created. This contract covers both resources, the two placements, input admission, capability preparation, Skills, Plugins and MCP connection origins. +An Environment is the execution resource of a Session: the machine, workspace and prepared capabilities that a Harness acts on. A Session creates its Environment through its `environment` configuration; there is no standalone create call. An Environment Template is reusable preparation configuration that a Session resolves when it is created. This contract covers both resources, the two placements, input admission, capability preparation, Skills, Plugins and MCP connection origins. Related owners: @@ -10,7 +10,7 @@ Related owners: - [Executor credentials](./environment-executor-credentials.md): enrollment, the installation grant and connection status of a `self_hosted` machine. - [Sandbox deployment](./sandbox-deployment.md): which Sandbox Provider (E2B, Docker or microsandbox) hosts `openai_hosted` Environments. - [Core–Runtime protocol](../../docs/runtime-protocol.md): the `runtime_prepare` transfer and every other wire message. -- [Runtime and outer isolation](../../docs/concepts.md#runtime-and-outer-isolation): the daemon runs tools with its launching user's permissions; isolation comes from the outer Environment. +- [Runtime and outer isolation](../../docs/concepts.md#runtime-and-outer-isolation): tools run on the Environment's machine with the permissions of Sandbox I/O's account; isolation comes from the outer Environment. ## Resources and states @@ -43,19 +43,20 @@ A managed outer Environment must exclude broader application credentials and oth ## Placements -Both placements run the same Runtime: the daemon, the selected Harness, native tools and the workspace run together on one machine. They differ only in who owns that machine. +Both placements run the same way. The selected Harness runs on the agent host, in a [view](./harness-onboarding.md#run-in-an-agent-host-view) of the Environment's machine; Sandbox I/O on that machine serves its files, processes and network over the [Link](../../docs/sandbox-link-protocol.md), so the workspace and every tool the Harness runs stay on the machine. The placements differ only in who owns the machine. | Object | Responsibility | | --- | --- | | Session and Environment | Durable ownership, configuration, pending interaction and connection observations (Core) | | Provider allocation | Compute and filesystem lifetime: Core's Sandbox Provider for `openai_hosted`, the application for `self_hosted` | -| Device and daemon connection | Authenticated Runtime identity and the replaceable dispatch transport | -| Harness process and native session | The native model and tool loop, its execution state and native history | +| Runtime connection | The agent host's authenticated Runtime identity and the replaceable dispatch transport | +| Harness process and native session | The native model and tool loop on the agent host, its execution state and native history | +| Sandbox I/O | `oac-sandbox-io` on the machine, which serves the Environment's Link resource: started by the [Sandbox Provider](../../docs/sandbox-provider.md#oac-sandbox-io) for `openai_hosted` and by `oac-daemon start` for `self_hosted` ([Sandbox bootstrap](../../docs/sandbox-bootstrap.md#responsibilities-and-readiness)) | | Enrollment | The executor key with which a `self_hosted` machine serves the Environment's [Link](../../docs/sandbox-link-protocol.md) resource | ### Hosted (`openai_hosted`) -The deployment's configured Sandbox Provider (E2B, Docker or microsandbox, see [sandbox deployment](./sandbox-deployment.md)) hosts the Environment. Every Harness whose declaration supports `local_environment` runs there ([Declare support](./harness-onboarding.md#declare-support)). +The deployment's configured Sandbox Provider (E2B, Docker or microsandbox, see [sandbox deployment](./sandbox-deployment.md)) hosts the Environment. Every Harness whose declaration supports `local_environment` can work in it ([Declare support](./harness-onboarding.md#declare-support)). - Session creation, with or without initial input, commits the Session, Environment and retry identity before the Worker provisions compute. A creation interrupted before bootstrap is recovered without repeating the Provider's Create. - Provisioning needs no caller action; the Session stays idle until a Turn starts. @@ -66,18 +67,18 @@ The deployment's configured Sandbox Provider (E2B, Docker or microsandbox, see [ ### Self-hosted (`self_hosted`) -The application owns the machine. It creates the Session with a clean absolute `workspace_directory` and optional absolute local `capability_directories`. Core returns the Environment ID, the `remote_url` and an install command in `x_agents_core.installation`; running that command on the machine installs the daemon and enrolls it ([self-hosted guide](../../docs/getting-started/self-hosted.md), [executor credentials](./environment-executor-credentials.md)). +The application owns the machine. It creates the Session with a clean absolute `workspace_directory` and optional absolute local `capability_directories`. Core returns the Environment ID, the `remote_url` and an install command in `x_agents_core.installation`; running that command on the machine installs `oac-daemon`, whose `start` enrolls the machine and runs Sandbox I/O ([self-hosted guide](../../docs/getting-started/self-hosted.md), [executor credentials](./environment-executor-credentials.md)). -- `remote_url` is the daemon WebSocket URL derived from Core's public URL, never from request headers or a daemon address. It names Core's private daemon transport. +- `remote_url` is Core's daemon WebSocket URL, derived from Core's public URL, never from request headers or a daemon address. The machine derives Core's origin from it to enroll, and Sandbox I/O serves the Link URL that enrollment returns. - Enrollment records the executor key that serves the Environment's Link resource; the first key keeps it. It creates no allocation and binds no Session: the Session runs on the deployment's agent host ([Session assignments](../../docs/runtime-protocol.md#session-assignments)). -- The Session's workspace must equal the `/workspace` alias or the exact canonical directory the Runtime is bound to. Naming a path grants no access to it. +- The Session's workspace is the machine's `/workspace`: the agent host fails the preparation of an execution whose `workspace_directory` names another path. Naming a path grants no access to it. - Session reads, lists and events return the `self_hosted` output with the Environment ID, workspace and capability directories, never private configuration. `capability_directories` lists the caller's selections; the Runtime's installation locations stay private. -- Compute, workspace and files stay the application's. Deleting the Session or revoking the credential denies further access but does not stop native processes; the machine owner stops and cleans up. -- The workspace must survive a daemon restart. Losing it never authorizes silent replacement or replay. +- Compute, workspace and files stay the application's. Deleting the Session or revoking the credential denies further access; the machine owner stops what still runs on the machine and cleans up. +- The workspace must survive a restart of `oac-daemon start` or Sandbox I/O. Losing it never authorizes silent replacement or replay. ### Ownership rules -- Keep Environment identity, ownership, configuration and lifecycle in Core, separate from Provider compute, device identity, daemon sockets and native sessions. Keep mutable connection state out of immutable configuration; a replacement owner fences stale observations. +- Keep Environment identity, ownership, configuration and lifecycle in Core, separate from Provider compute, device identity, Runtime connections and native sessions. Keep mutable connection state out of immutable configuration; a replacement owner fences stale observations. - Callers, devices and Environment connections use distinct credentials. The relay authenticates a `self_hosted` machine's Serve with the enrolled executor key. Connection observations keep generation and revision fencing. Registration and connection do not establish readiness. - Rotation, revocation, Session deletion and loss of ownership deny further access; they do not promise that native effects stop at once. - Native history stays on the bound Runtime. Preserve it, or demonstrably restore it, across compute replacement; never silently move a bound Session or replay unknown work. @@ -159,7 +160,7 @@ Closing an Executor, cancelling a Turn or losing the transport keeps the install ### Preparation order -Core freezes resource versions, metadata and source selections at Session creation. Initialization then runs in this order, each step over `runtime_prepare` with the same daemon: +Core freezes resource versions, metadata and source selections at Session creation. Initialization then runs in this order, each step over `runtime_prepare` with the same agent host: 1. initial files and tool configuration; 2. Skill and Plugin bundle import; @@ -180,41 +181,40 @@ The runner uses only neutral Environment and Session identity and a Runtime peer Changing `environment` to `{"type":"openai_hosted"}` reuses the same preparation input. Resource resolution, Project authorization, concrete Skill versions, encrypted file contents and confidential tool variables freeze at Session creation. Retries and reconnects reuse those snapshots; new Sessions resolve new versions. A `self_hosted` Environment never needs an allocation record. -**Readiness.** Transport `connected` is a connection observation, not readiness. Execution and live file access wait for initialization; then the native preparation owner validates the installed snapshot and Harness before admitting a Turn. File reads keep their own readiness and authorization and do not require capability or native readiness. Deployment model credentials are never sent to application-owned machines. +**Readiness.** Transport `connected` is a connection observation, not readiness. Execution and live file access wait for initialization; then the native preparation owner validates the installed snapshot and Harness before admitting a Turn. File reads keep their own readiness and authorization and do not require capability or native readiness. Deployment model credentials never reach the Environment's machine; the agent host keeps them in the Session's [credential gateway](./model-execution.md#credential-gateway). -**Transfer.** Initial files, configure, npm, Python and setup operations, inert Skill and Plugin archives and finalization selections travel as typed `runtime_prepare` operations with canonical Session and Environment identities; the [protocol](../../docs/runtime-protocol.md#preparation-and-execution-order) owns chunking and receipts. Files and setup working directories use logical `/workspace` addresses; the Runtime chooses executables and physical destinations, and Core supplies no executable or host-platform field. Source selections accept portable absolute Unix, Windows drive and UNC paths; Core never resolves them on its own host, and the daemon applies its local path and access checks. +**Transfer.** Initial files, configure, npm, Python and setup operations, inert Skill and Plugin archives and finalization selections travel as typed `runtime_prepare` operations with canonical Session and Environment identities; the [protocol](../../docs/runtime-protocol.md#preparation-and-execution-order) owns chunking and receipts. Files and setup working directories use logical `/workspace` addresses; the Runtime chooses executables and physical destinations, and Core supplies no executable or host-platform field. Core checks only the spelling of source selections and never resolves them on its own host; the agent host reads each as a clean absolute path on the Environment's machine. ### Installed snapshot -Both origins use the common Runtime parser and an `installed.json` manifest on Linux, macOS and Windows. The Runtime operator chooses the capability root ([installer options](../../docs/getting-started/self-hosted.md#options-for-automation)); Core and transfer requests cannot. +Both placements use the common parser and an `installed.json` manifest in the capability root, `/environment/initialization/capabilities` on the Environment's machine. Core and transfer requests cannot choose another root. - The manifest binds the Session and Environment to the ordered source-selection digest. The preparation owner verifies or creates it before native execution, also for an empty selection. -- After the setup commands, the initializer snapshots the declared workspace-contained capability directories into Runtime storage. Directory bytes are read after setup, not at Session creation. -- A filesystem lock prevents concurrent installation. A private completion record keeps only the operator's installation root, so a deleted snapshot is never mistaken for a first preparation or captured again. +- After the setup commands, finalization copies the declared capability directories into the capability root. Directory bytes are read after setup, not at Session creation. +- The Session's Environment owner runs one operation at a time, so installations never overlap. A private completion record keeps only the capability root, so a deleted snapshot is never mistaken for a first preparation or captured again. - Missing, partial, conflicting or foreign snapshots fail without deleting data, repairing or replaying. - Reconnecting and replacement Executors load the installed contents without rereading sources. Source edits reach only a new Session. - Recursive references to the snapshot and directory entries that escape it are rejected. -- Read-only snapshot modes are integrity hints, not protection from the launching user. +- Read-only snapshot modes are integrity hints, not protection from tools on the machine. Executor admission validates only the frozen descriptor. The preparation owner makes the capabilities ready before calling the native factory; adapters receive only the resolved Runtime-owned Skill paths and MCP declarations. A reused Executor keeps its original configuration. ### System dependencies and Runtime directories -The daemon runs as its launching account and never uses sudo or raises its permissions. Only user-directory dependencies install during preparation. +Preparation runs on the Environment's machine as Sandbox I/O's account and never uses sudo or raises its permissions. Only user-directory dependencies install during preparation. - System dependencies must be preinstalled in the managed image or by the owner of a self-hosted machine. A missing executable or library fails the operation that needs it. - `packages.system` is rejected in Templates and inline configuration, including a null or empty list (400, param `packages.system`). Package responses still carry the official required `system: []`. -- npm installs into a local prefix and Python/pip into a local target under the Runtime package directory; Node/npm and Python/pip must already be installed. Their dependencies are visible to native tools in every working directory. -- Setup commands run with Bash; on Windows, Git Bash is required and no other shell substitutes. The default working directory is `/workspace`. -- On Windows, npm installation and stdio MCP commands named `npm` or `npx` (including their `.cmd` shims) run through npm's JavaScript entry point with Node, without an extra shell. +- npm installs into the prefix `/environment/packages/npm` and pip into the target `/environment/packages/python`; Node/npm and Python/pip must already be installed. The [tool environment](#explicit-local-tool-environment) puts their commands on `PATH` and the Python packages on `PYTHONPATH`, so native tools see them in every working directory. +- Setup commands run with Bash, without profile or rc files. The default working directory is `/workspace`. -Initialization and package directories default to `initialization` and `packages` under the Runtime home (`OAC_RUNTIME_HOME`) and can be set with `OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; packaged Linux images use `/environment/initialization` and `/environment/packages`. These are resource paths, never Environment-source or operating-system switches in Core. +The machine's layout is fixed: the workspace is `/workspace`, initialization records and the tool environment live in `/environment/initialization`, and packages in `/environment/packages`. Managed Providers create the initialization and package directories for Sandbox I/O's account. These are resource paths, never Environment-source or operating-system switches in Core. -Every command uses the launching user's permissions and the host network. Process ownership waits for exit and I/O settlement. Command output is discarded; a confirmed failure keeps only a bounded integer exit status. +Every command uses the machine's network. Process ownership waits for exit and I/O settlement. Command output is discarded; a confirmed failure keeps only a bounded integer exit status. ### Explicit local tool environment -The installer's `--tool-env-file` (`OAC_RUNTIME_TOOL_ENV_FILE`) supplies the Runtime operator's base tool variables. Preparation copies these values into its private initialization snapshot, and the Session's `env` keys override them. The Runtime never rewrites the source file or inherits unrelated ambient credentials. Setup, capability resolution and Harness execution read the same prepared snapshot. Reconnecting keeps that snapshot even if the operator edits the file; a new Session reads the current file. A Harness profile may reference the Runtime-owned file but must not persist copies of its values. A missing or invalid configured file fails preparation. +The configure step, the first initialization step, freezes the tool environment in `/environment/initialization/tool-env.json`: the Session's `env` values, with the npm and Python package commands ahead of `PATH`, the sandbox image's unless `env` sets one, and the Python packages ahead of `PYTHONPATH`. A Session without a configure step gets the same environment frozen from no values. Package and setup steps run with it over the sandbox's base environment, capability resolution reads MCP values from it, and processes that the Harness runs on the machine get it from the agent host's process broker ([Environment](./harness-onboarding.md#environment)). The Harness itself never receives it, and nothing inherits ambient credentials. Later Executors and reconnects read the same frozen file; once initialization has run, a missing or invalid one fails preparation. ### Initialization state and failure @@ -238,7 +238,7 @@ On failure, one transaction marks the Environment failed and records `agent.sess | Harness not installed on the Runtime | `Failed to prepare environment: the selected Harness is unavailable. Install the supported Harness version on the Runtime and create a new Session.` | | Anything else: timeouts, unknown effects, missing or malformed receipts, Plugin installation, snapshot finalization, bootstrap rejection, Core restart | `Failed to provision environment: initialization did not complete` | -Every initialization operation returns a typed `rejected`, `failed` or `unknown` outcome, and the daemon confirms process exit and I/O settlement first. Core composes the reason from a fixed label and integers, so commands, env values, package names, paths and process output never reach the reason, events, logs or responses. The failed step is not retried and later steps do not run. Confidential env and setup snapshots are encrypted separately from ordinary metadata. Initial files use the atomic replacing writer and anchored workspace paths on every platform; Files API creation keeps its own no-overwrite rule. +Every initialization operation returns a typed `rejected`, `failed` or `unknown` outcome, and the agent host confirms process exit and I/O settlement first. Core composes the reason from a fixed label and integers, so commands, env values, package names, paths and process output never reach the reason, events, logs or responses. The failed step is not retried and later steps do not run. Confidential env and setup snapshots are encrypted separately from ordinary metadata. Initial files use the atomic replacing writer and anchored workspace paths; Files API creation keeps its own no-overwrite rule. ## Templates @@ -308,7 +308,7 @@ Env values are readable by Agent code but never appear in public metadata or ini | Referenced file | 50 MiB | | Session or Template request body | 16 MiB | -Paths must be canonical, distinct and inside the logical workspace; the Runtime anchors each write to its bound workspace. This is API path scope, not a restriction on native tools running as the same user. Template metadata shows inline files as type, path and size and references as type, path and `file_id`; each Session gets fresh file IDs and sizes for both. File data stays out of ordinary configuration, responses, events and command arguments. A Template keeps references; each Session authorizes and freezes its own encrypted source bytes, so later source deletion cannot change them. +Paths must be canonical, distinct and inside the logical workspace; the agent host anchors each write to the machine's `/workspace`. This is API path scope, not a restriction on native tools running as the same user. Template metadata shows inline files as type, path and size and references as type, path and `file_id`; each Session gets fresh file IDs and sizes for both. File data stays out of ordinary configuration, responses, events and command arguments. A Template keeps references; each Session authorizes and freezes its own encrypted source bytes, so later source deletion cannot change them. ### Skills @@ -333,7 +333,7 @@ An inline Skill carries `name`, `description` and a base64 ZIP `source` (`media_ A Plugin is an inline ZIP with type, name and description whose single archive root contains `.codex-plugin/plugin.json`. The manifest's `skills` names Skill directories; the whole package layout is kept. Public Plugin metadata shows only type, name and description. -`openai_hosted` and Template `capability_directories` accept clean absolute paths inside `/workspace`, which initial files and setup can populate. `self_hosted` capability directories are absolute local paths on the machine. Directory-discovered Skills never appear as `skills` or `plugins` entries. Missing directories, duplicate Skill names, unsupported manifests and non-regular files fail initialization. +`openai_hosted` and Template `capability_directories` accept clean absolute paths inside `/workspace`, which initial files and setup can populate. `self_hosted` capability directories are absolute paths on the machine. Directory-discovered Skills never appear as `skills` or `plugins` entries. Missing directories, duplicate Skill names, unsupported manifests and non-regular files fail initialization. | Archive and installation limit | Value | | --- | --- | @@ -356,9 +356,9 @@ Inline and referenced Skills use the same confidential snapshot and installer. T A Plugin declares MCP servers with `mcpServers: "./.mcp.json"` in `.codex-plugin/plugin.json`, or through a root `.mcp.json` when the path is omitted. The file holds `mcpServers` keyed by server name. Selecting a Plugin root as a capability directory activates its MCP declarations; selecting a parent directory discovers Skills without activating nested MCP servers. -The shared parser accepts HTTP `url`, `bearer_token_env_var` and literal `http_headers`, and stdio `command`, `args`, selected `env_vars` and a package-relative `cwd`. Public `env_http_headers` is unsupported. The Runtime re-parses the frozen installed packages and resolves selected values only from the initialized env; a missing value fails instead of falling back to a model or daemon variable. +The shared parser accepts HTTP `url`, `bearer_token_env_var` and literal `http_headers`, and stdio `command`, `args`, selected `env_vars` and a package-relative `cwd`. Public `env_http_headers` is unsupported. The Runtime re-parses the frozen installed packages and resolves selected values only from the initialized env; a missing value fails instead of falling back to a model or agent-host variable. The agent host fails a Plugin whose server declares literal `http_headers`, or is a stdio server with `env_vars`, before installing it ([Session assignments](../../docs/runtime-protocol.md#session-assignments)). -A stdio server starts through the daemon's stdio helper, which resolves the installed declaration and launches the command with the Harness's permissions. On Unix the helper replaces itself with the server; on Windows it forwards stdio inside the owned process tree. Initialized values override the declaration's variables. Process groups and Windows Jobs own cancellation and descendant cleanup, not isolation. +A stdio server runs on the machine under its alias ([Stdio MCP](./harness-onboarding.md#stdio-mcp)), with the tool environment over the sandbox's base environment and nothing from the Harness. Process scopes own cancellation and descendant cleanup, not isolation. Environment MCP needs enabled network. Duplicate server identities are rejected. Claude rejects literal headers because the pinned client expands them again and forwards custom headers across origins, and MiniMax Code rejects them too. MiniMax ACP HTTP declarations stay in session-local native memory; tokens never enter native configuration files or process arguments. Required initialization and tool allowlists cannot be set through the Plugin manifest. diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index c626ed289..eb0963e62 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -30,16 +30,16 @@ Runtime: Executor preparation, reuse, idle expiry, recovery | Adapter | Native configuration, resources, API calls, event translation and restrictions | `apps/daemon/internal/agent/` | | Harness | Native model and tool loop and history | Pinned SDK or executable | | Declaration | The Harness's support, against which Core and the Runtime admit each selection | `internal/harnessconfig/` | -| Registration | Adapter declarations, installed factories and the installation's narrowed support | `apps/daemon/internal/agent//declaration.go`; static list in `apps/daemon/internal/cli/agent_discovery.go` | +| Registration | Adapter declarations, installed views and the installation's narrowed support | `apps/daemon/internal/agent//declaration.go`; static list in `apps/daemon/internal/cli/agent_host_linux.go` | -An Environment supplies execution resources. Managed E2B, Docker and microsandbox machines and application-owned machines differ in provisioning and connection; the connected Runtime uses this same contract. The daemon runs on Linux, macOS and Windows, managed Providers are Linux-only, and each adapter qualifies its own platforms ([self-hosted platforms](../../docs/getting-started/self-hosted.md#platforms)). Native factories receive capabilities only after the Runtime has loaded the bound installed snapshot ([capability preparation](./environments.md#runtime-capability-preparation)). Model providers supply model communication settings, not Turn scheduling or native process ownership. +An Environment supplies execution resources. Managed E2B, Docker and microsandbox machines and application-owned machines differ in provisioning and connection; each serves the sandbox to the Linux agent host, which runs every Harness in a [view](#run-in-an-agent-host-view) of it under this same contract. Native factories receive capabilities only after the Runtime has loaded the bound installed snapshot ([capability preparation](./environments.md#runtime-capability-preparation)). Model providers supply model communication settings, not Turn scheduling or native process ownership. ## Steps 1. **Pin the native source.** Record the upstream package version and source revision and document the native entry point next to the adapter. -2. **Implement the adapter** in `apps/daemon/internal/agent/`: an `ExecutorFactory`, an `Executor` and a `Turn` ([required interfaces](#required-adapter-interfaces), [lifetimes](#executor-and-turn-lifetimes)). Reuse the shared process, credential, configuration and local workspace helpers. -3. **Declare its support and register it.** Declare the support in `internal/harnessconfig/` with one catalog entry ([declare support](#declare-support)), then declare the kind in the adapter and add it to the Runtime’s static list in `apps/daemon/internal/cli/agent_discovery.go` ([register the adapter](#register-the-adapter)). -4. **Package native prerequisites.** Add a Runtime image under `services/core/deploy/` and, optionally, [native installer participation](#native-installer-participation). +2. **Implement the adapter** in `apps/daemon/internal/agent/`: a view whose `ViewExecutorFactory` prepares an `Executor`, and a `Turn` ([required interfaces](#required-adapter-interfaces), [lifetimes](#executor-and-turn-lifetimes), [view](#run-in-an-agent-host-view)). Reuse the shared process, credential and configuration helpers. +3. **Declare its support and register it.** Declare the support in `internal/harnessconfig/` with one catalog entry ([declare support](#declare-support)), then declare the kind in the adapter and add it to the agent host's static list in `apps/daemon/internal/cli/agent_host_linux.go` ([register the adapter](#register-the-adapter)). +4. **Package native prerequisites.** Supply the adapter's installation and add the Harness to the agent-host image ([native installer participation](#native-installer-participation)). 5. **Enable and select the engine** with the `core.harnesses` setting and [Harness selection](./model-execution.md#harness-selection). 6. **Qualify it** ([qualify the adapter](#qualify-the-adapter)) and record each native difference in the [coverage ledger](./index.md). @@ -57,15 +57,15 @@ Implement the mandatory text lifecycle and handle every extension explicitly. Qu ## Required adapter interfaces -[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/harness.go) is the interface entry point. The required lifecycle is `ExecutorFactory`, `Executor`, `Turn` and `TurnSettlement`. `Turn` is one interface: `Cancel`, `CancellationOutcome`, `SteerWithReceipt`, `SubmitFunctionResult` and `AwaitSettlement`. Required methods perform their native obligations; returning Unsupported is not an implementation of cancellation, receipts, settlement or cleanup. An operation the adapter does not support returns Unsupported, and the capability declaration, not the method, decides whether the Runtime calls it. All use the neutral protocol types. +[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/harness.go) is the interface entry point. The required lifecycle is `ViewExecutorFactory`, `Executor`, `Turn` and `TurnSettlement`. `Turn` is one interface: `Cancel`, `CancellationOutcome`, `SteerWithReceipt`, `SubmitFunctionResult` and `AwaitSettlement`. Required methods perform their native obligations; returning Unsupported is not an implementation of cancellation, receipts, settlement or cleanup. An operation the adapter does not support returns Unsupported, and the capability declaration, not the method, decides whether the Runtime calls it. All use the neutral protocol types. -Both factories, `ExecutorFactory` and a view's `ViewExecutorFactory`, take one `agent.PrepareRequest`: the Session's configuration as `execution_prepare` carries it, the model configuration that the Registry prepared once from the kind's declaration (`Prepared`), the Session's native state key (`StateKey`), and the Environment's workspace and installed Capabilities (`WorkspaceRoot`, `CapabilityRoot`, `Skills`, `MCP`), which its owner fills. The adapter takes its model, provider and native parameters only from `Prepared` and never parses `model` or `model_provider` itself. A Turn's Run ID and input arrive in `Executor.StartTurn`. +The view's `ViewExecutorFactory` takes one `agent.PrepareRequest`: the Session's configuration as `execution_prepare` carries it, the model configuration that the Registry prepared once from the kind's declaration (`Prepared`), the Session's native state key (`StateKey`), and the Environment's workspace and installed Capabilities (`WorkspaceRoot`, `CapabilityRoot`, `Skills`, `MCP`), which its owner fills. The adapter takes its model, provider and native parameters only from `Prepared` and never parses `model` or `model_provider` itself. A Turn's Run ID and input arrive in `Executor.StartTurn`. For example, the Codex adapter keeps its app-server and thread, the Claude adapter one streaming Query, and the MiniMax adapter its ACP connection and native session. All expose the same Executor and Turn contract. Native callbacks and resources stay inside the adapter; the Runtime owns admission, idle expiry and replacement. Cancellation targets the exact Turn through `Turn.Cancel`, and the adapter supplies native completion evidence to the Runtime. | Interface or contract | Required handling | Obligation | | --- | --- | --- | -| `ExecutorFactory`, `Executor.StartTurn`, `Executor.Close` | Real implementation | Prepare without model input; keep ownership of failed or uncertain resources; confirm cleanup | +| `ViewExecutorFactory`, `Executor.StartTurn`, `Executor.Close` | Real implementation | Prepare without model input; keep ownership of failed or uncertain resources; confirm cleanup | | `Turn.Cancel`, `CancellationOutcome`, `AwaitSettlement` | Real implementation | Cancel the exact Turn, keep observed results and confirm settlement independently of cancellation requests | | `Turn.SteerWithReceipt` | Real implementation | Distinguish a complete write from the native application receipt; keep retry identity | | `Turn.SubmitFunctionResult` | Real implementation or Unsupported | Match native call and result identity and acknowledge application | @@ -83,9 +83,9 @@ func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayl The reason is a fixed safe string, never submitted content, a credential or raw native diagnostics. Unsupported guarantees no native side effect and is not a successful empty operation. Installation unavailability, unknown call IDs, native failures and uncertain outcomes keep their own errors and ownership. A nil `Turn` still means that no input was submitted and the output stays with the caller; never use it as an Unsupported marker. -The wire request carries no working directory. The Runtime checks `local_environment.workspace_directory` against its binding and gives the Harness its bound workspace directory in `PrepareRequest.WorkspaceRoot`; run the native Harness there. +The wire request carries no working directory. The Environment owner checks `local_environment.workspace_directory` against the Environment's workspace and gives the Harness that directory in `PrepareRequest.WorkspaceRoot`; run the native Harness there. -Workspace reads, writes, output export and read-only preparation belong to the Session's [Environment owner](../../docs/runtime-protocol.md#session-assignments), not the adapter. An adapter implements none of them. Its declaration's `LocalEnvironment` and `EnvironmentNone` state what its Executors run, and `agent.Registry.Register` composes them once with what the Runtime's owner serves (`agent.EnvironmentSupport`), keeping each only where the owner serves it. The composed `LocalEnvironment` also admits the owner's workspace reads, read-only preparation and output export. One declaration holds for every Executor of the install, including its [view](#run-in-an-agent-host-view). +Workspace reads, writes, output export and read-only preparation belong to the Session's [Environment owner](../../docs/runtime-protocol.md#session-assignments), not the adapter. An adapter implements none of them. Its declaration's `LocalEnvironment` and `EnvironmentNone` state what its Executors run, and `agent.Registry.Register` composes them once with what the Runtime's owner serves (`agent.EnvironmentSupport`), keeping each only where the owner serves it. The composed `LocalEnvironment` also admits the owner's workspace reads, read-only preparation and output export. One declaration holds for every Executor of the install. The declaration states the public combinations the Harness supports and the heartbeat narrows it to the installation; neither replaces schema validation or Project authorization. Native behavior tests must agree with the declarations. An advertised operation that returns Unsupported is a contract violation, never success or grounds for replay. @@ -134,7 +134,6 @@ MCP, public functions, deferred function discovery, structured output, image inp - Structured output: consume `ExecutionControls.OutputFormat` and publish confirmed native output through the Message contract ([execution tools](./execution-tools.md#structured-output)). Declare `Binary64OutputSchema` when the native SDK reads JSON numbers as binary64. - Images: declare `MessageImages` and `FunctionResultImages`, and whether function results admit image URLs and images in a failed result ([message input](./message-content.md)). -- Workspace placements additionally need verified preparation, workspace reads and output export and the dedicated Runtime binding with the shared Files helpers. Enable a placement only after its lifecycle behavior is demonstrated. ### MCP origin and native limits @@ -148,15 +147,15 @@ A Harness that supports the Subagent reads implements the [neutral observation c ## Register the adapter -Registration is static and requires a build. Export one `agent.Declaration` from `apps/daemon/internal/agent//declaration.go`, then add it to `harnessDeclarations` in [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go). The declaration contains the kind with the capabilities of the shared model `Configuration`'s declaration, that `Configuration` and a `Discover` function. Discovery receives the profile and diagnostic writers, owns native configuration and availability checks, and returns the installed `agent.Runtime` with its descriptor, Executor factory and view declaration. Return nil when the adapter is not configured; return an unavailable descriptor without an Executor factory or view when configured prerequisites fail. Keep version gates and factory-selection conditions inside the adapter; they only clear support. +Registration is static and requires a build. Export one `agent.Declaration` from `apps/daemon/internal/agent//declaration.go`, then add it to `harnessDeclarations` in [`cli/agent_host_linux.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_host_linux.go). The declaration contains the kind with the capabilities of the shared model `Configuration`'s declaration, that `Configuration` and a `Discover` function. Discovery receives the diagnostic writers, owns native configuration and availability checks, and returns the installed `agent.Runtime` with its descriptor and view declaration. Return nil when the adapter is not configured; return an unavailable descriptor without a view when configured prerequisites fail. Keep version gates and view-selection conditions inside the adapter; they only clear support. -[`cli/agent_registration.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_registration.go) iterates the discovered runtimes and calls `Registry.Register` from `agent/harness.go`. It verifies that discovery retained the declared kind and registers the Runtime in this order: +[`cli/agent_host_linux.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_host_linux.go) registers each discovered Runtime that has a view with `RegisterKind` and `RegisterView`. The agent host then registers each such kind for dispatch through `Registry.Register`, which composes the declaration with the Environments it serves, and installs its own Executor factory with `RegisterExecutor`; that factory builds the Session's view and calls the view's `ViewExecutorFactory`. | Order | Method | Registers | | --- | --- | --- | | 1 | `RegisterKind(proto.SupportedAgentKind, harnessconfig.Configuration)` | Kind, availability, version, `AgentKindCapabilities` and the model configuration, whose declaration it narrows to those capabilities; it panics on a widening. It resets the other registrations, so call it first. | -| 2 | `RegisterExecutor(kind, agent.ExecutorFactory)` | The Executor and Turn lifecycle used for execution. Its factory runs only for a request whose selection the narrowed declaration admits and whose model configuration prepares, and receives it with `Prepared` set. | -| 3 | `RegisterView(kind, agent.View)` | Optional: the agent-host view declaration from `Runtime.View`. It panics with `ErrInvalidView` when `View.Validate` fails. Its Executor factory receives the request that the agent host's `RegisterExecutor` prepared and enforces the [gateway rule](#endpoints-and-proxy). | +| 2 | `RegisterView(kind, agent.View)` | The view declaration from `Runtime.View`. It panics with `ErrInvalidView` when `View.Validate` fails. Its Executor factory receives the request that the agent host's Executor factory prepared and enforces the [gateway rule](#endpoints-and-proxy). | +| 3 | `RegisterExecutor(kind, agent.ExecutorFactory)` | The agent host's Executor factory, which dispatch calls. It runs only for a request whose selection the narrowed declaration admits and whose model configuration prepares, and receives it with `Prepared` set. | `Runtime.View` declares how the Harness runs in an agent-host Session view, described in [Run in an agent-host view](#run-in-an-agent-host-view). Every adapter sets it explicitly; `View: nil` means the agent host rejects the kind, and `Registry.ResolveView` returns an error wrapping `ErrUnsupportedOperation`. `TestPublicHarnessContractDeclarations` requires the field in each declaration. @@ -266,7 +265,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 the Environment's installed Skills and [stdio MCP](#stdio-mcp). The Environment owner fills `PrepareRequest.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. The agent host 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 its view runs, 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 `PrepareRequest.Skills` and `CapabilityRoot` as sandbox paths, and the adapter hands them to its Harness; only the Harness reads them, through the view, and the adapter opens none of them on the agent host. The agent host rejects a stdio binding that needs a credential with `ErrViewHandoff`. ### Environment none @@ -288,7 +287,7 @@ The agent host derives the process broker's table from the declaration: `/.oac/b ### Endpoints and proxy -Before it calls the factory, the agent host points the request's model provider, `model_provider` and `Prepared.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. +Before it calls the factory, the agent host points the request's model provider, `model_provider` and `Prepared.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 into the Harness's native configuration and never sees a real credential. The Registry checks each view request once, before the factory, and rejects it with `ErrViewHandoff` when its prepared model provider 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. diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 46aa26767..eb2dc9ea7 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,10 +1,10 @@ --- title: "环境与模板" source: contracts/agents-api/environments.md -source_hash: 2862ddcde357d25ab32c400dca1d7fa3812ea8967c2b40092941075034eb726d +source_hash: 39c9fc5bd3c6010ef018072f81aa9eb8e91f85b37de196d4c45a654c440af05c --- -Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment Template 是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 +Environment 是 Session 的执行资源,包括 Harness 所操作的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment Template 是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 相关职责归属: @@ -12,7 +12,7 @@ Environment 是 Session 的执行资源,包括 Harness 运行所在的机器 - [Executor credentials](environment-executor-credentials.md):`self_hosted` 机器的注册、安装授权和连接状态。 - [Sandbox deployment](sandbox-deployment.md):托管 `openai_hosted` Environment 的 Sandbox Provider(E2B、Docker 或 microsandbox)。 - [Core–Runtime protocol](../../../docs/zh/runtime-protocol.md):`runtime_prepare` 传输及所有其他线上消息。 -- [Runtime and outer isolation](../../../docs/zh/concepts.md#runtime-and-outer-isolation):daemon 使用启动用户的权限运行工具;隔离由外层 Environment 提供。 +- [Runtime and outer isolation](../../../docs/zh/concepts.md#runtime-and-outer-isolation):工具在 Environment 的机器上以 Sandbox I/O 账户的权限运行;隔离由外层 Environment 提供。 ## 资源与状态 {#resources-and-states} @@ -45,19 +45,20 @@ Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会 ## 放置方式 {#placements} -两种放置方式运行相同的 Runtime:daemon、所选 Harness、原生工具和工作区共同在一台机器上运行。二者唯一的差异是由谁拥有该机器。 +两种放置方式的运行方式相同。所选 Harness 运行在 agent host 上,处于 Environment 机器的[视图](harness-onboarding.md#run-in-an-agent-host-view)中;该机器上的 Sandbox I/O 通过 [Link](../../../docs/zh/sandbox-link-protocol.md) 提供其文件、进程和网络,因此工作区以及 Harness 运行的每个工具都留在该机器上。二者唯一的差异是由谁拥有该机器。 | 对象 | 责任 | | --- | --- | | Session 与 Environment | 持久所有权、配置、待处理交互和连接观察(Core) | | Provider 分配 | 计算资源和文件系统的生命周期:`openai_hosted` 使用 Core 的 Sandbox Provider,`self_hosted` 使用应用程序 | -| 设备与 daemon 连接 | 经认证的 Runtime 身份和可替换的分派传输 | -| Harness 进程与原生会话 | 原生模型和工具循环、其执行状态及原生历史 | +| Runtime 连接 | agent host 经认证的 Runtime 身份和可替换的分派传输 | +| Harness 进程与原生会话 | agent host 上的原生模型和工具循环、其执行状态及原生历史 | +| Sandbox I/O | 机器上的 `oac-sandbox-io`,为该 Environment 的 Link resource 提供服务:`openai_hosted` 由 [Sandbox Provider](../../../docs/zh/sandbox-provider.md#oac-sandbox-io) 启动,`self_hosted` 由 `oac-daemon start` 启动([Sandbox bootstrap](../../../docs/zh/sandbox-bootstrap.md#responsibilities-and-readiness)) | | 注册 | `self_hosted` 机器用来 Serve 该 Environment 的 [Link](../../../docs/zh/sandbox-link-protocol.md) resource 的 executor key | ### 托管(`openai_hosted`) {#hosted-openai-hosted} -部署中配置的 Sandbox Provider(E2B、Docker 或 microsandbox,请参阅 [sandbox deployment](sandbox-deployment.md))承载 Environment。声明支持 `local_environment` 的每个 Harness 都可在其中运行([声明支持](harness-onboarding.md#declare-support))。 +部署中配置的 Sandbox Provider(E2B、Docker 或 microsandbox,请参阅 [sandbox deployment](sandbox-deployment.md))承载 Environment。声明支持 `local_environment` 的每个 Harness 都可在其中工作([声明支持](harness-onboarding.md#declare-support))。 - Session 创建时,无论是否包含初始输入,都会在 Worker 配置计算资源之前提交 Session、Environment 和重试身份。若创建在 bootstrap 前中断,可恢复时不会重复执行 Provider 的 Create。 - 置备无需调用方执行任何操作;在 Turn 启动之前,Session 会保持空闲。 @@ -68,18 +69,18 @@ Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会 ### 自托管(`self_hosted`) {#self-hosted-self-hosted} -应用程序拥有机器。它使用干净的绝对路径 `workspace_directory` 和可选的绝对本地 `capability_directories` 创建 Session。Core 会返回 Environment ID、`remote_url` 以及 `x_agents_core.installation` 中的一条安装命令;在该机器上运行此命令会安装 daemon 并为其注册([self-hosted guide](../../../docs/zh/getting-started/self-hosted.md)、[executor credentials](environment-executor-credentials.md))。 +应用程序拥有机器。它使用干净的绝对路径 `workspace_directory` 和可选的绝对本地 `capability_directories` 创建 Session。Core 会返回 Environment ID、`remote_url` 以及 `x_agents_core.installation` 中的一条安装命令;在该机器上运行此命令会安装 `oac-daemon`,其 `start` 会注册该机器并运行 Sandbox I/O([self-hosted guide](../../../docs/zh/getting-started/self-hosted.md)、[executor credentials](environment-executor-credentials.md))。 -- `remote_url` 是根据 Core 的公共 URL 推导出的 daemon WebSocket URL,绝不根据请求头或 daemon 地址生成。它指定 Core 的私有 daemon 传输通道。 +- `remote_url` 是根据 Core 的公共 URL 推导出的 Core daemon WebSocket URL,绝不根据请求头或 daemon 地址生成。机器从中推导 Core 的 origin 以进行注册,Sandbox I/O 则为注册返回的 Link URL 提供服务。 - 注册记录为该 Environment 的 Link resource 提供服务的 executor key;最先注册的 key 保有它。注册不会创建任何分配,也不绑定 Session:Session 运行在部署的 agent host 上([Session 分配](../../../docs/zh/runtime-protocol.md#session-assignments))。 -- Session 的工作区必须等于 `/workspace` 别名,或等于 Runtime 绑定到的精确规范目录。指定某个路径并不会授予对它的访问权限。 +- Session 的工作区是机器上的 `/workspace`:若执行的 `workspace_directory` 指向其他路径,agent host 会使其准备失败。指定某个路径并不会授予对它的访问权限。 - Session 读取、列表和事件会返回带有 Environment ID、工作区及能力目录的 `self_hosted` 输出,但绝不返回私有配置。`capability_directories` 列出调用方选择的内容;Runtime 的安装位置保持私有。 -- 计算资源、工作区和文件仍归应用程序所有。删除 Session 或撤销凭据会拒绝后续访问,但不会停止原生进程;机器所有者负责停止和清理。 -- 工作区必须能在 daemon 重启后继续存在。丢失它绝不授权进行静默替换或重播。 +- 计算资源、工作区和文件仍归应用程序所有。删除 Session 或撤销凭据会拒绝后续访问;机器所有者负责停止机器上仍在运行的内容并进行清理。 +- 工作区必须能在 `oac-daemon start` 或 Sandbox I/O 重启后继续存在。丢失它绝不授权进行静默替换或重播。 ### 所有权规则 {#ownership-rules} -- 将 Environment 身份、所有权、配置和生命周期保留在 Core 中,并使其与 Provider 计算资源、设备身份、daemon 套接字和原生会话相分离。将可变连接状态排除在不可变配置之外;替换后的所有者会使过期观察值失效。 +- 将 Environment 身份、所有权、配置和生命周期保留在 Core 中,并使其与 Provider 计算资源、设备身份、Runtime 连接和原生会话相分离。将可变连接状态排除在不可变配置之外;替换后的所有者会使过期观察值失效。 - 调用方、设备和 Environment 连接使用彼此不同的凭据。relay 使用已注册的 executor key 认证 `self_hosted` 机器的 Serve。连接观察保留 generation 和 revision 栅栏。注册和连接都不表示已就绪。 - 轮换、撤销、Session 删除和所有权丧失都会拒绝后续访问;但它们不保证原生效果会立即停止。 - 原生历史保留在绑定的 Runtime 上。替换计算资源时必须保留或以可证明的方式恢复原生历史;绝不能静默移动已绑定的 Session 或重播未知工作。 @@ -161,7 +162,7 @@ Worker 在每个 tick 中最多处理 32 个到期预留,处理顺序是在检 ### 准备顺序 {#preparation-order} -Core 会在 Session 创建时冻结资源版本、元数据和源选择。随后初始化按以下顺序运行,每一步都通过 `runtime_prepare` 使用同一个 daemon: +Core 会在 Session 创建时冻结资源版本、元数据和源选择。随后初始化按以下顺序运行,每一步都通过 `runtime_prepare` 使用同一个 agent host: 1. 初始文件和工具配置; 2. Skill 和 Plugin bundle 导入; @@ -182,41 +183,40 @@ Core 会在 Session 创建时冻结资源版本、元数据和源选择。随后 将 `environment` 改为 `{"type":"openai_hosted"}` 会复用相同的准备输入。资源解析、Project 授权、具体 Skill 版本、加密文件内容和机密工具变量都会在 Session 创建时冻结。重试和重连会复用这些快照;新 Session 会解析新版本。`self_hosted` Environment 从不需要分配记录。 -**就绪性。** 传输 `connected` 是一种连接观察,而不代表就绪。执行和实时文件访问都要等待初始化;随后,原生准备所有者会在接纳 Turn 前验证已安装的快照和 Harness。文件读取保留自身的就绪性和授权,不要求能力或原生就绪。部署模型凭据绝不发送到应用程序所有的机器。 +**就绪性。** 传输 `connected` 是一种连接观察,而不代表就绪。执行和实时文件访问都要等待初始化;随后,原生准备所有者会在接纳 Turn 前验证已安装的快照和 Harness。文件读取保留自身的就绪性和授权,不要求能力或原生就绪。部署模型凭据绝不会到达 Environment 的机器;agent host 将其保存在 Session 的[凭据网关](model-execution.md#credential-gateway)中。 -**传输。** 初始文件、configure、npm、Python 和设置操作、惰性的 Skill 和 Plugin 归档以及最终确定选择,都会作为带有类型的 `runtime_prepare` 操作传输,并携带规范的 Session 和 Environment 身份;[protocol](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 负责分块和回执。文件及设置工作目录使用逻辑 `/workspace` 地址;Runtime 负责选择可执行文件和物理目标位置,Core 不提供任何可执行文件或主机平台字段。源选择接受可移植的 Unix 绝对路径、Windows 驱动器路径和 UNC 路径;Core 绝不会在自己的主机上解析这些路径,而 daemon 会应用其本地路径和访问检查。 +**传输。** 初始文件、configure、npm、Python 和设置操作、惰性的 Skill 和 Plugin 归档以及最终确定选择,都会作为带有类型的 `runtime_prepare` 操作传输,并携带规范的 Session 和 Environment 身份;[protocol](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 负责分块和回执。文件及设置工作目录使用逻辑 `/workspace` 地址;Runtime 负责选择可执行文件和物理目标位置,Core 不提供任何可执行文件或主机平台字段。Core 只检查源选择的拼写,绝不会在自己的主机上解析它们;agent host 将每个源选择作为 Environment 机器上的干净绝对路径读取。 ### 已安装快照 {#installed-snapshot} -两种来源都使用通用 Runtime 解析器,以及适用于 Linux、macOS 和 Windows 的 `installed.json` 清单。Runtime 操作者负责选择能力根目录([installer options](../../../docs/zh/getting-started/self-hosted.md#options-for-automation));Core 和传输请求无法选择。 +两种放置方式都使用通用解析器,以及能力根目录中的 `installed.json` 清单;能力根目录是 Environment 机器上的 `/environment/initialization/capabilities`。Core 和传输请求无法选择其他根目录。 - 清单会将 Session 和 Environment 绑定到有序的源选择摘要。准备所有者会在原生执行之前验证或创建该清单,即使选择为空也是如此。 -- 设置命令运行后,初始化器会将声明的、位于工作区内的能力目录快照到 Runtime 存储中。目录字节是在设置之后读取,而不是在 Session 创建时读取。 -- 文件系统锁可防止并发安装。私有完成记录仅保留操作者的安装根目录,因此已删除的快照绝不会被误认为首次准备,也不会再次被捕获。 +- 设置命令运行后,最终确定步骤会将声明的能力目录复制到能力根目录中。目录字节是在设置之后读取,而不是在 Session 创建时读取。 +- Session 的 Environment 所有者一次只运行一个操作,因此安装绝不会重叠。私有完成记录仅保留能力根目录,因此已删除的快照绝不会被误认为首次准备,也不会再次被捕获。 - 快照缺失、不完整、冲突或属于外部来源时,会在不删除数据、修复或重播的情况下失败。 - 重连和替换 Executor 会加载已安装的内容,而不会重新读取源。对源的编辑只会影响新 Session。 - 对快照的递归引用,以及会逃逸出快照的目录项,都会被拒绝。 -- 只读快照模式只是完整性提示,不能防止启动用户进行修改。 +- 只读快照模式只是完整性提示,不能防止机器上的工具进行修改。 Executor 接纳仅验证已冻结的描述符。准备所有者会在调用原生工厂之前使能力就绪;适配器仅接收已解析的、由 Runtime 所有的 Skill 路径和 MCP 声明。复用的 Executor 会保留其原始配置。 ### 系统依赖与 Runtime 目录 {#system-dependencies-and-runtime-directories} -daemon 以启动它的账户身份运行,绝不使用 sudo 或提升权限。准备过程中只会安装用户目录中的依赖项。 +准备过程在 Environment 的机器上以 Sandbox I/O 的账户身份运行,绝不使用 sudo 或提升权限。准备过程中只会安装用户目录中的依赖项。 - 系统依赖必须预先安装在托管镜像中,或由自托管机器的所有者安装。缺少可执行文件或库时,需要该依赖的操作会失败。 - Templates 和内联配置都会拒绝 `packages.system`,包括 null 或空列表(400,param `packages.system`)。软件包响应仍会包含官方要求的 `system: []`。 -- npm 会安装到本地 prefix,Python/pip 会安装到 Runtime 软件包目录下的本地 target;Node/npm 和 Python/pip 必须已经安装。其依赖项可被每个工作目录中的原生工具看到。 -- 设置命令使用 Bash 运行;在 Windows 上必须使用 Git Bash,且不能由其他 shell 替代。默认工作目录为 `/workspace`。 -- 在 Windows 上,npm 安装以及名为 `npm` 或 `npx` 的 stdio MCP 命令(包括其 `.cmd` shim)会通过 Node 调用 npm 的 JavaScript 入口点运行,而不经过额外的 shell。 +- npm 安装到 prefix `/environment/packages/npm`,pip 安装到 target `/environment/packages/python`;Node/npm 和 Python/pip 必须已经安装。[工具环境](#explicit-local-tool-environment)会将它们的命令放到 `PATH` 中,并将 Python 软件包放到 `PYTHONPATH` 中,因此每个工作目录中的原生工具都能看到它们。 +- 设置命令使用 Bash 运行,不加载 profile 或 rc 文件。默认工作目录为 `/workspace`。 -初始化目录和软件包目录默认分别是 Runtime 主目录(`OAC_RUNTIME_HOME`)下的 `initialization` 和 `packages`,也可通过 `OAC_RUNTIME_INITIALIZATION_DIRECTORY` 和 `OAC_RUNTIME_PACKAGE_DIRECTORY` 设置;打包的 Linux 镜像使用 `/environment/initialization` 和 `/environment/packages`。这些是资源路径,在 Core 中绝不是 Environment 源或操作系统开关。 +机器的布局是固定的:工作区为 `/workspace`,初始化记录和工具环境位于 `/environment/initialization`,软件包位于 `/environment/packages`。托管 Provider 会为 Sandbox I/O 的账户创建初始化目录和软件包目录。这些是资源路径,在 Core 中绝不是 Environment 源或操作系统开关。 -每条命令都使用启动用户的权限和主机网络。进程所有权会等待退出及 I/O 结算完成。命令输出会被丢弃;确认失败时只保留一个有界整数退出状态。 +每条命令都使用机器的网络。进程所有权会等待退出及 I/O 结算完成。命令输出会被丢弃;确认失败时只保留一个有界整数退出状态。 ### 显式本地工具环境 {#explicit-local-tool-environment} -安装器的 `--tool-env-file`(`OAC_RUNTIME_TOOL_ENV_FILE`)提供 Runtime 操作者的基础工具变量。准备过程会将这些值复制到其私有初始化快照中,并由 Session 的 `env` 键覆盖。Runtime 绝不会重写源文件,也不会继承无关的环境凭据。设置、能力解析和 Harness 执行都会读取同一份已准备快照。即使操作者编辑了文件,重连仍会保留该快照;新 Session 会读取当前文件。Harness profile 可以引用由 Runtime 所有的文件,但不得持久保存其值的副本。配置的文件缺失或无效时,准备过程会失败。 +configure 步骤是第一个初始化步骤,它会将工具环境冻结到 `/environment/initialization/tool-env.json` 中:内容为 Session 的 `env` 值,npm 和 Python 软件包命令位于 `PATH` 之前(除非 `env` 设置了 `PATH`,否则为 sandbox 镜像的 `PATH`),Python 软件包位于 `PYTHONPATH` 之前。没有 configure 步骤的 Session 会得到由空值冻结的同样环境。软件包和设置步骤在 sandbox 基础环境之上使用它运行,能力解析从中读取 MCP 值,Harness 在机器上运行的进程则从 agent host 的进程代理获得它([Environment](harness-onboarding.md#environment))。Harness 本身绝不会收到它,也不会继承任何环境中已有的凭据。后续 Executor 和重连读取同一个已冻结文件;初始化运行后,该文件缺失或无效时准备过程会失败。 ### 初始化状态与失败 {#initialization-state-and-failure} @@ -240,7 +240,7 @@ Environment 的初始化状态为 `pending`、`running`、`complete` 或 `failed | Runtime 上未安装 Harness | `Failed to prepare environment: the selected Harness is unavailable. Install the supported Harness version on the Runtime and create a new Session.` | | 其他情况:超时、未知效果、回执缺失或格式错误、Plugin 安装、快照最终确定、bootstrap 拒绝、Core 重启 | `Failed to provision environment: initialization did not complete` | -每个初始化操作都会返回有类型的 `rejected`、`failed` 或 `unknown` 结果,并且 daemon 会先确认进程退出和 I/O 结算完成。Core 使用固定标签和整数组成原因,因此命令、env 值、软件包名称、路径和进程输出绝不会进入原因、事件、日志或响应。失败步骤不会重试,后续步骤也不会运行。机密 env 和设置快照会与普通元数据分开加密。初始文件在所有平台上都使用原子替换写入器和工作区锚定路径;Files API 创建则保留其自己的不覆盖规则。 +每个初始化操作都会返回有类型的 `rejected`、`failed` 或 `unknown` 结果,并且 agent host 会先确认进程退出和 I/O 结算完成。Core 使用固定标签和整数组成原因,因此命令、env 值、软件包名称、路径和进程输出绝不会进入原因、事件、日志或响应。失败步骤不会重试,后续步骤也不会运行。机密 env 和设置快照会与普通元数据分开加密。初始文件使用原子替换写入器和工作区锚定路径;Files API 创建则保留其自己的不覆盖规则。 ## Templates {#templates} @@ -310,7 +310,7 @@ Agent 代码可以读取 env 值,但它们绝不会出现在公开元数据或 | 引用文件 | 50 MiB | | Session 或 Template 请求正文 | 16 MiB | -路径必须规范、互不相同且位于逻辑工作区内部;Runtime 会将每次写入锚定到其绑定的工作区。这是 API 路径范围,不是对以同一用户身份运行的原生工具的限制。Template 元数据会将内联文件显示为 type、path 和 size,将引用显示为 type、path 和 `file_id`;无论内联文件还是引用,每个 Session 都会获得全新的文件 ID 和大小。文件数据不会出现在普通配置、响应、事件或命令参数中。Template 会保留引用;每个 Session 会授权并冻结自己的加密源字节,因此之后删除源文件无法改变这些字节。 +路径必须规范、互不相同且位于逻辑工作区内部;agent host 会将每次写入锚定到机器上的 `/workspace`。这是 API 路径范围,不是对以同一用户身份运行的原生工具的限制。Template 元数据会将内联文件显示为 type、path 和 size,将引用显示为 type、path 和 `file_id`;无论内联文件还是引用,每个 Session 都会获得全新的文件 ID 和大小。文件数据不会出现在普通配置、响应、事件或命令参数中。Template 会保留引用;每个 Session 会授权并冻结自己的加密源字节,因此之后删除源文件无法改变这些字节。 ### Skills {#skills} @@ -335,7 +335,7 @@ template = client.beta.agents.environments.templates.create( Plugin 是带有 type、name 和 description 的内联 ZIP,其单个归档根目录包含 `.codex-plugin/plugin.json`。清单中的 `skills` 指定 Skill 目录;整个包布局都会保留。公开 Plugin 元数据只显示 type、name 和 description。 -`openai_hosted` 和 Template 的 `capability_directories` 接受 `/workspace` 内的干净绝对路径,初始文件和设置命令可以填充这些路径。`self_hosted` 能力目录是机器上的绝对本地路径。通过目录发现的 Skills 绝不会显示为 `skills` 或 `plugins` 条目。目录缺失、Skill 名称重复、清单不受支持以及存在非常规文件时,初始化会失败。 +`openai_hosted` 和 Template 的 `capability_directories` 接受 `/workspace` 内的干净绝对路径,初始文件和设置命令可以填充这些路径。`self_hosted` 能力目录是机器上的绝对路径。通过目录发现的 Skills 绝不会显示为 `skills` 或 `plugins` 条目。目录缺失、Skill 名称重复、清单不受支持以及存在非常规文件时,初始化会失败。 | 归档与安装限制 | 值 | | --- | --- | @@ -358,9 +358,9 @@ Skills 及其不可变版本是 Project 资源,独立于 Session 和原生安 Plugin 可以在 `.codex-plugin/plugin.json` 中通过 `mcpServers: "./.mcp.json"` 声明 MCP 服务器,也可以在省略路径时使用根目录下的 `.mcp.json`。该文件包含以服务器名称为键的 `mcpServers`。将 Plugin 根目录选为能力目录会激活其 MCP 声明;选择父目录则会发现 Skills,但不会激活嵌套 MCP 服务器。 -共享解析器接受 HTTP `url`、`bearer_token_env_var` 和字面量 `http_headers`,以及 stdio `command`、`args`、选定的 `env_vars` 和包相对路径 `cwd`。不支持公开的 `env_http_headers`。Runtime 会重新解析已冻结的已安装包,并且只从已初始化的 env 中解析选定值;缺少值时会失败,而不会回退到模型或 daemon 变量。 +共享解析器接受 HTTP `url`、`bearer_token_env_var` 和字面量 `http_headers`,以及 stdio `command`、`args`、选定的 `env_vars` 和包相对路径 `cwd`。不支持公开的 `env_http_headers`。Runtime 会重新解析已冻结的已安装包,并且只从已初始化的 env 中解析选定值;缺少值时会失败,而不会回退到模型或 agent-host 变量。若 Plugin 的服务器声明了字面量 `http_headers`,或是带有 `env_vars` 的 stdio 服务器,agent host 会在安装前使其失败([Session 分配](../../../docs/zh/runtime-protocol.md#session-assignments))。 -stdio 服务器通过 daemon 的 stdio helper 启动;该 helper 会解析已安装的声明,并以 Harness 的权限启动命令。在 Unix 上,helper 会将自身替换为服务器;在 Windows 上,它会在所属进程树内部转发 stdio。已初始化的值会覆盖声明中的变量。进程组和 Windows Jobs 负责取消及后代进程清理,而不负责隔离。 +stdio 服务器以其别名在机器上运行([Stdio MCP](harness-onboarding.md#stdio-mcp)),使用叠加在 sandbox 基础环境之上的工具环境,不带任何来自 Harness 的内容。进程作用域负责取消及后代进程清理,而不负责隔离。 Environment MCP 需要启用的网络。重复的服务器身份会被拒绝。Claude 会拒绝字面量标头,因为固定版本客户端会再次展开这些标头,并将自定义标头跨来源转发;MiniMax Code 也会拒绝字面量标头。MiniMax ACP HTTP 声明会保留在 Session 本地的原生内存中;令牌绝不会进入原生配置文件或进程参数。无法通过 Plugin 清单设置必需初始化和工具允许列表。 diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index ad293aaef..614ac4319 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: ea2a7a759e262188cab16966a558f866075544fb84724cd95ab4d5c78e96b6f3 +source_hash: 38e609d8e8154d6f3c99c03466160c1ed30a70ea65434f8c999e98478c50b256 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、支持声明和验收。 @@ -32,16 +32,16 @@ Runtime: Executor preparation, reuse, idle expiry, recovery | Adapter | 原生配置、资源、API 调用、事件转换和限制 | `apps/daemon/internal/agent/` | | Harness | 原生模型和工具循环以及历史记录 | 锁定版本的 SDK 或可执行文件 | | 声明 | Harness 的支持范围,Core 和 Runtime 据此准入每个选择 | `internal/harnessconfig/` | -| 注册 | 适配器声明、已安装工厂和该安装收窄后的支持 | `apps/daemon/internal/agent//declaration.go`;`apps/daemon/internal/cli/agent_discovery.go` 中的静态列表 | +| 注册 | 适配器声明、已安装视图和该安装收窄后的支持 | `apps/daemon/internal/agent//declaration.go`;`apps/daemon/internal/cli/agent_host_linux.go` 中的静态列表 | -Environment 提供执行资源。受管 E2B、Docker 和 microsandbox 机器以及应用自有机器在预配和连接方式上有所不同;已连接的 Runtime 使用同一契约。daemon 运行于 Linux、macOS 和 Windows,受管 Provider 仅支持 Linux,并且每个适配器自行认定其支持的平台([self-hosted platforms](../../../docs/zh/getting-started/self-hosted.md#platforms))。只有在 Runtime 加载绑定的已安装快照后,原生工厂才会收到能力([capability preparation](environments.md#runtime-capability-preparation))。模型 Provider 提供模型通信设置,而不负责 Turn 调度或原生进程所有权。 +Environment 提供执行资源。受管 E2B、Docker 和 microsandbox 机器以及应用自有机器在预配和连接方式上有所不同;它们都把沙箱提供给 Linux agent host,由 agent host 在沙箱的[视图](#run-in-an-agent-host-view)中按同一契约运行每个 Harness。只有在 Runtime 加载绑定的已安装快照后,原生工厂才会收到能力([capability preparation](environments.md#runtime-capability-preparation))。模型 Provider 提供模型通信设置,而不负责 Turn 调度或原生进程所有权。 ## 步骤 {#steps} 1. **锁定原生来源。** 记录上游包版本和源修订版本,并在适配器旁记录原生入口点。 -2. **实现适配器**,位置为 `apps/daemon/internal/agent/`:实现 `ExecutorFactory`、`Executor` 和 `Turn`([required interfaces](#required-adapter-interfaces)、[lifetimes](#executor-and-turn-lifetimes))。复用共享的进程、凭据、配置和本地工作区辅助函数。 -3. **声明支持并注册。** 在 `internal/harnessconfig/` 中声明支持并添加一个目录条目([declare support](#declare-support)),然后在适配器中声明 kind,并将其添加到 `apps/daemon/internal/cli/agent_discovery.go` 中 Runtime 的静态列表([register the adapter](#register-the-adapter))。 -4. **打包原生先决条件。** 在 `services/core/deploy/` 下添加 Runtime 镜像,并可选添加 [native installer participation](#native-installer-participation)。 +2. **实现适配器**,位置为 `apps/daemon/internal/agent/`:实现一个视图,其 `ViewExecutorFactory` 准备 `Executor`,以及 `Turn`([required interfaces](#required-adapter-interfaces)、[lifetimes](#executor-and-turn-lifetimes)、[view](#run-in-an-agent-host-view))。复用共享的进程、凭据和配置辅助函数。 +3. **声明支持并注册。** 在 `internal/harnessconfig/` 中声明支持并添加一个目录条目([declare support](#declare-support)),然后在适配器中声明 kind,并将其添加到 `apps/daemon/internal/cli/agent_host_linux.go` 中 agent host 的静态列表([register the adapter](#register-the-adapter))。 +4. **打包原生先决条件。** 提供适配器的安装描述,并将 Harness 加入 agent-host 镜像([native installer participation](#native-installer-participation))。 5. **启用并选择引擎**,通过 `core.harnesses` 设置和 [Harness selection](model-execution.md#harness-selection) 完成。 6. **认定其资格**([qualify the adapter](#qualify-the-adapter)),并将每项原生差异记录到[覆盖台账](index.md)。 @@ -59,15 +59,15 @@ Environment 提供执行资源。受管 E2B、Docker 和 microsandbox 机器以 ## 必需的适配器接口 {#required-adapter-interfaces} -[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/harness.go) 是接口入口。必需的生命周期包括 `ExecutorFactory`、`Executor`、`Turn` 和 `TurnSettlement`。`Turn` 是一个接口:`Cancel`、`CancellationOutcome`、`SteerWithReceipt`、`SubmitFunctionResult` 和 `AwaitSettlement`。必需方法必须履行其原生义务;返回 Unsupported 并不构成对取消、回执、结算或清理的实现。适配器不支持的操作返回 Unsupported,由能力声明而不是方法决定 Runtime 是否调用它。所有接口都使用中立协议类型。 +[`agent/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/harness.go) 是接口入口。必需的生命周期包括 `ViewExecutorFactory`、`Executor`、`Turn` 和 `TurnSettlement`。`Turn` 是一个接口:`Cancel`、`CancellationOutcome`、`SteerWithReceipt`、`SubmitFunctionResult` 和 `AwaitSettlement`。必需方法必须履行其原生义务;返回 Unsupported 并不构成对取消、回执、结算或清理的实现。适配器不支持的操作返回 Unsupported,由能力声明而不是方法决定 Runtime 是否调用它。所有接口都使用中立协议类型。 -两个工厂,`ExecutorFactory` 和视图的 `ViewExecutorFactory`,都接收一个 `agent.PrepareRequest`:`execution_prepare` 携带的 Session 配置、Registry 按 kind 的声明一次性准备好的模型配置(`Prepared`)、Session 的原生状态键(`StateKey`),以及由 Environment owner 填写的 Environment 工作区和已安装 Capabilities(`WorkspaceRoot`、`CapabilityRoot`、`Skills`、`MCP`)。适配器只从 `Prepared` 获取模型、提供商和原生参数,从不自行解析 `model` 或 `model_provider`。Turn 的 Run ID 和输入通过 `Executor.StartTurn` 传入。 +视图的 `ViewExecutorFactory` 接收一个 `agent.PrepareRequest`:`execution_prepare` 携带的 Session 配置、Registry 按 kind 的声明一次性准备好的模型配置(`Prepared`)、Session 的原生状态键(`StateKey`),以及由 Environment owner 填写的 Environment 工作区和已安装 Capabilities(`WorkspaceRoot`、`CapabilityRoot`、`Skills`、`MCP`)。适配器只从 `Prepared` 获取模型、提供商和原生参数,从不自行解析 `model` 或 `model_provider`。Turn 的 Run ID 和输入通过 `Executor.StartTurn` 传入。 例如,Codex 适配器保留其 app-server 和 thread,Claude 适配器保留一个流式 Query,MiniMax 适配器保留其 ACP 连接和原生 session。它们都公开相同的 Executor 和 Turn 契约。原生回调和资源保留在适配器内部;Runtime 负责准入、空闲过期和替换。取消通过 `Turn.Cancel` 精确定位到目标 Turn,适配器则向 Runtime 提供原生完成证据。 | 接口或契约 | 必需处理 | 义务 | | --- | --- | --- | -| `ExecutorFactory`、`Executor.StartTurn`、`Executor.Close` | 真实实现 | 在没有模型输入的情况下准备;保留失败或不确定资源的所有权;确认清理 | +| `ViewExecutorFactory`、`Executor.StartTurn`、`Executor.Close` | 真实实现 | 在没有模型输入的情况下准备;保留失败或不确定资源的所有权;确认清理 | | `Turn.Cancel`、`CancellationOutcome`、`AwaitSettlement` | 真实实现 | 取消精确的 Turn,保留已观察结果,并独立于取消请求确认结算 | | `Turn.SteerWithReceipt` | 真实实现 | 区分完整写入与原生应用回执;保留重试身份 | | `Turn.SubmitFunctionResult` | 真实实现或 Unsupported | 匹配原生调用和结果身份,并确认应用 | @@ -85,9 +85,9 @@ func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayl 原因必须是固定的安全字符串,绝不能是已提交内容、凭据或原始原生诊断信息。Unsupported 保证不会产生原生副作用,也不表示操作成功且为空。安装不可用、未知调用 ID、原生失败和不确定结果应保留各自的错误和所有权。nil `Turn` 仍表示没有提交任何输入,并且输出归调用方所有;绝不能将其用作 Unsupported 标记。 -线协议请求不携带工作目录。Runtime 将 `local_environment.workspace_directory` 与其绑定进行核对,并通过 `PrepareRequest.WorkspaceRoot` 向 Harness 提供其绑定的工作区目录;必须在该目录中运行原生 Harness。 +线协议请求不携带工作目录。Environment owner 将 `local_environment.workspace_directory` 与 Environment 的工作区进行核对,并通过 `PrepareRequest.WorkspaceRoot` 向 Harness 提供该目录;必须在该目录中运行原生 Harness。 -工作区读取、写入、输出导出和只读 preparation 属于 Session 的 [Environment owner](../../../docs/zh/runtime-protocol.md#session-assignments),不属于 adapter。adapter 不实现其中任何操作。其声明中的 `LocalEnvironment` 和 `EnvironmentNone` 表示其 Executor 能运行的内容,`agent.Registry.Register` 将二者与 Runtime 的 owner 所提供的内容(`agent.EnvironmentSupport`)组合一次,仅在 owner 提供时保留。组合后的 `LocalEnvironment` 同时准入 owner 的工作区读取、只读 preparation 和输出导出。一份声明适用于该安装的每个 Executor,包括其[视图](#run-in-an-agent-host-view)。 +工作区读取、写入、输出导出和只读 preparation 属于 Session 的 [Environment owner](../../../docs/zh/runtime-protocol.md#session-assignments),不属于 adapter。adapter 不实现其中任何操作。其声明中的 `LocalEnvironment` 和 `EnvironmentNone` 表示其 Executor 能运行的内容,`agent.Registry.Register` 将二者与 Runtime 的 owner 所提供的内容(`agent.EnvironmentSupport`)组合一次,仅在 owner 提供时保留。组合后的 `LocalEnvironment` 同时准入 owner 的工作区读取、只读 preparation 和输出导出。一份声明适用于该安装的每个 Executor。 声明说明 Harness 支持的公共组合,心跳将其收窄到该安装;二者都不能替代 schema 验证或 Project 授权。原生行为测试必须与声明一致。已宣称但返回 Unsupported 的操作属于契约违规,既不是成功,也不能作为重放的依据。 @@ -136,7 +136,6 @@ MCP、公共函数、延迟函数发现、结构化输出、图像输入、详 - 结构化输出:读取 `ExecutionControls.OutputFormat`,并通过 Message 契约发布已确认的原生输出([execution tools](execution-tools.md#structured-output))。原生 SDK 以 binary64 读取 JSON 数字时,声明 `Binary64OutputSchema`。 - 图像:声明 `MessageImages` 和 `FunctionResultImages`,以及函数结果是否准入图像 URL 和失败结果中的图像([message input](message-content.md))。 -- 工作区放置方式还需要经过验证的准备过程、工作区读取和输出导出,以及使用共享 Files 辅助函数的专用 Runtime 绑定。只有在展示其生命周期行为后才能启用放置方式。 ### MCP 来源和原生限制 {#mcp-origin-and-native-limits} @@ -150,15 +149,15 @@ MCP、公共函数、延迟函数发现、结构化输出、图像输入、详 ## 注册适配器 {#register-the-adapter} -注册是静态的,并且需要构建。从 `apps/daemon/internal/agent//declaration.go` 导出一个 `agent.Declaration`,然后将其添加到 [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go) 的 `harnessDeclarations` 中。声明包含 kind 及共享模型 `Configuration` 的声明中的能力、该 `Configuration` 和 `Discover` 函数。发现过程接收 profile 和诊断写入器,负责原生配置和可用性检查,并返回已安装的 `agent.Runtime` 及其描述符、Executor 工厂和视图声明。未配置适配器时返回 nil;已配置的前置条件失败时,返回不带 Executor 工厂和视图的不可用描述符。将版本门控和工厂选择条件保留在适配器内部;它们只能清除支持。 +注册是静态的,并且需要构建。从 `apps/daemon/internal/agent//declaration.go` 导出一个 `agent.Declaration`,然后将其添加到 [`cli/agent_host_linux.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_host_linux.go) 的 `harnessDeclarations` 中。声明包含 kind 及共享模型 `Configuration` 的声明中的能力、该 `Configuration` 和 `Discover` 函数。发现过程接收诊断写入器,负责原生配置和可用性检查,并返回已安装的 `agent.Runtime` 及其描述符和视图声明。未配置适配器时返回 nil;已配置的前置条件失败时,返回不带视图的不可用描述符。将版本门控和视图选择条件保留在适配器内部;它们只能清除支持。 -[`cli/agent_registration.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_registration.go) 遍历已发现的 Runtime,并调用 `agent/harness.go` 中的 `Registry.Register`。它验证发现过程是否保留了声明的 kind,并按以下顺序注册该 Runtime: +[`cli/agent_host_linux.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_host_linux.go) 以 `RegisterKind` 和 `RegisterView` 注册每个带视图的已发现 Runtime。随后 agent host 通过 `Registry.Register` 为 dispatch 注册每个这样的 kind,该方法把声明与 agent host 提供的 Environment 组合,再以 `RegisterExecutor` 安装 agent host 自己的 Executor 工厂;该工厂构建 Session 的视图,并调用视图的 `ViewExecutorFactory`。 | 顺序 | 方法 | 注册内容 | | --- | --- | --- | | 1 | `RegisterKind(proto.SupportedAgentKind, harnessconfig.Configuration)` | Kind、可用性、版本、`AgentKindCapabilities` 和模型配置;它将模型配置的声明收窄到这些能力,遇到扩大时 panic。它会重置其他注册项,因此必须首先调用。 | -| 2 | `RegisterExecutor(kind, agent.ExecutorFactory)` | 执行所用的 Executor 和 Turn 生命周期。其工厂只为收窄后的声明所准入、且模型配置能够准备的请求运行,并收到已设置 `Prepared` 的请求。 | -| 3 | `RegisterView(kind, agent.View)` | 可选:来自 `Runtime.View` 的 agent-host 视图声明。`View.Validate` 失败时以 `ErrInvalidView` panic。其 Executor 工厂接收 agent host 的 `RegisterExecutor` 已准备好的请求,并执行[网关规则](#endpoints-and-proxy)。 | +| 2 | `RegisterView(kind, agent.View)` | 来自 `Runtime.View` 的视图声明。`View.Validate` 失败时以 `ErrInvalidView` panic。其 Executor 工厂接收 agent host 的 Executor 工厂已准备好的请求,并执行[网关规则](#endpoints-and-proxy)。 | +| 3 | `RegisterExecutor(kind, agent.ExecutorFactory)` | 供 dispatch 调用的 agent host Executor 工厂。它只为收窄后的声明所准入、且模型配置能够准备的请求运行,并收到已设置 `Prepared` 的请求。 | `Runtime.View` 声明 Harness 如何在 agent-host Session 视图中运行,详见[在 agent-host 视图中运行](#run-in-an-agent-host-view)。每个适配器都显式设置它;`View: nil` 表示 agent host 拒绝该 kind,`Registry.ResolveView` 返回包装 `ErrUnsupportedOperation` 的错误。`TestPublicHarnessContractDeclarations` 要求每个声明都包含该字段。 @@ -268,7 +267,7 @@ agent host 在沙箱之外、在每个 Session 一个的视图中运行 Harness ### 能力 {#capabilities} -视图运行该 kind 的声明所准入的每个请求,因此 adapter 只声明其本地 Executor 和视图都能运行的内容,dispatch 按该声明检查每个请求。agent host 提供本地 Environment 和 environment none,且每个视图都运行 Environment 已安装的 Skills 和 [stdio MCP](#stdio-mcp)。Environment owner 以沙箱路径填写 `PrepareRequest.Skills` 和 `CapabilityRoot`,adapter 像本地 Executor 那样把它们交给 Harness;只有 Harness 通过视图读取它们,adapter 不在 agent host 上打开其中任何路径。agent host 以 `ErrViewHandoff` 拒绝需要凭据的 stdio 绑定。 +视图运行该 kind 的声明所准入的每个请求,因此 adapter 只声明其视图能运行的内容,dispatch 按该声明检查每个请求。agent host 提供本地 Environment 和 environment none,且每个视图都运行 Environment 已安装的 Skills 和 [stdio MCP](#stdio-mcp)。Environment owner 以沙箱路径填写 `PrepareRequest.Skills` 和 `CapabilityRoot`,adapter 把它们交给 Harness;只有 Harness 通过视图读取它们,adapter 不在 agent host 上打开其中任何路径。agent host 以 `ErrViewHandoff` 拒绝需要凭据的 stdio 绑定。 ### Environment none {#environment-none} @@ -290,7 +289,7 @@ agent host 根据声明推导进程 broker 的映射表:`/.oac/bin/` 在 ### 端点与代理 {#endpoints-and-proxy} -调用工厂之前,agent host 将请求的模型提供商,即 `model_provider` 和 `Prepared.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 一样渲染提供商和绑定,从不接触真实凭据。 +调用工厂之前,agent host 将请求的模型提供商,即 `model_provider` 和 `Prepared.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 端点,或 stdio 绑定不是其别名。 diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 05350b58c..2af64a037 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -4,7 +4,7 @@ title: "Core\u2013Runtime protocol" This protocol connects Core to a Runtime daemon after the daemon has its machine credential. It defines the meaning and order of the messages on the daemon connection. The wire types, limits and validators live once in [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/agentdaemon/proto); Core's [gateway](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/runtimegateway) and the reference Runtime's [dispatcher](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/dispatch) both use them, so there is no second payload schema to keep in sync. The HTTP routes that issue credentials and open the connection are in the [machine connection API](../contracts/agents-api/machine-api.md). -Hosted and self-hosted Runtimes use the same protocol. A Harness joins through the [Harness adapter contract](../contracts/agents-api/harness-onboarding.md), which owns the Executor and Turn lifecycle obligations behind the Runtime registry. +Sessions of hosted and self-hosted Environments use the same protocol and the same Runtime, the agent host. A Harness joins through the [Harness adapter contract](../contracts/agents-api/harness-onboarding.md), which owns the Executor and Turn lifecycle obligations behind the Runtime registry. ## Ownership and connection @@ -57,7 +57,7 @@ The `execution_prepare` configuration carries the Session's model configuration | `execution_controls` | Always: the resolved text verbosity (default `medium`), an explicit programmatic-tool-calling disable and any `json_schema` output format. Native option names belong to the adapter | | `observe_subagent_identities`, `disable_subagents` | From the Agent's `multi_agent.enabled` | | `disable_execution_environment` | For an Environment of type `none` | -| `local_environment` | For `openai_hosted` and `self_hosted`, with the exact Environment binding. The request carries no working directory; the Runtime checks `workspace_directory` against its binding. It carries no network policy, because Core admits only an [enabled network](../contracts/agents-api/environments.md#restricted-network) | +| `local_environment` | For `openai_hosted` and `self_hosted`, with the exact Environment binding. The request carries no working directory; the Environment owner checks `workspace_directory` against the Environment's workspace. It carries no network policy, because Core admits only an [enabled network](../contracts/agents-api/environments.md#restricted-network) | | `require_existing_native_session` | When a native Session must be recovered | An execution configuration requires exactly one of `local_environment` and `disable_execution_environment`; `execution_prepare` rejects neither or both with `unsupported_configuration`. @@ -112,7 +112,7 @@ Every Session frame carries the assignment: `execution_prepare`, `execution_star Before a Session's first operation on a connection, including Environment initialization and file work without a Turn, Core sends `assignment_bind` with the Session's Environment ID and waits for `assignment_status` `bound`. When the Runtime is an agent host and the Environment has a live [Link](./sandbox-link-protocol.md) resource, the bind also carries `resource`, that resource as the [bootstrap input](./sandbox-bootstrap.md#launch-input) names it, and `attach_grant`, the base64 grant with which the agent host opens services on that resource generation under this assignment and epoch. When the Environment has no live resource, Core sends the agent host no bind, and the operation that needed the bind fails. The grant is secret. Core sends neither field to any other Runtime. A bind with only one of them, or with a resource of another Environment, fails with `invalid_request`. A repeated bind of the same assignment and epoch with the same Environment, resource and grant is `bound` again; one with anything else fails with `assignment_conflict`. A bind of the bound assignment at a higher epoch supersedes the earlier epoch, with any resource or grant: the Runtime fences and cleans up the earlier epoch's work as a release does, keeping the home, then binds the new epoch and replies `bound`. A Session's Environment never changes, so such a bind that names another Environment fails with `assignment_conflict` before anything is fenced. A bind of another assignment, or of a released assignment at a higher epoch, fails with `assignment_conflict`. Unfinished cleanup replies `failed` with `cleanup_unconfirmed`, and a retry at the same epoch repeats it; a release or a later bind in the meantime fails it with `assignment_stale`. The Runtime admits a Session frame only under the assignment it bound: an older epoch, or a released one, fails with `assignment_stale`; another assignment, Session or Environment fails with `assignment_conflict`. A started Run's frames, including its cancellation receipt, stay admissible under the assignment that started it until the release. A repeated function result or decision whose receipt the Runtime already recorded is answered only under the assignment that applied it; another fails with `assignment_conflict`. -A Session's first bind resolves its Environment owner, which holds the Environment's resources and performs every effect on them; it does not change afterwards. The owner checks each `execution_prepare` configuration against the Environment, including the read-only profile, and fills the installed capabilities before an Executor starts. It applies `runtime_prepare`, lists directories for `workspace_read`, writes files for `workspace_write` and exports outputs for `workspace_export`. The Runtime's dispatcher keeps admission, transfer framing and fencing, and never substitutes another implementation. A self-hosted Runtime's owner is its bound local workspace, which outlives each assignment; the Runtime rejects the bind of any other Session with `assignment_conflict`. An agent host's owner works in the Session's sandbox through File and Process on a [Link](./sandbox-link-protocol.md) attachment of its own, opened under the bind's grant on first use. It lasts from the Session's first bind until its home is removed and outlives the Session's Executors and connections. Quiescing its Environment, releasing the assignment or superseding its epoch closes its attachment; a bind that supersedes the epoch takes the owner over once that attachment is closed. It runs each setup step as the Process operation whose ID is the `runtime_prepare` envelope ID. A `workspace_write` or `runtime_prepare` that cannot reach the sandbox before any effect ends `rejected` with `resource_unavailable`. It fails a Plugin whose MCP server declares literal `http_headers`, or is a stdio server with `env_vars`, before staging any of it. A file mutation or setup step whose outcome it cannot observe quarantines the owner until the home is removed: it sends no further mutation, and every later `workspace_write` and `runtime_prepare` ends `unknown`. A Session without an owner supports none of these operations, and the Runtime rejects each with its typed code: `unsupported_read_preparation` for a read-only preparation, `invalid_configuration` for an Executor configuration with a `local_environment`, `runtime_preparation_unsupported` for `runtime_prepare`, `write_unsupported` for `workspace_write`, and `read_unsupported` for `workspace_read` and `workspace_export`. +A Session's first bind resolves its Environment owner, which holds the Environment's resources and performs every effect on them; it does not change afterwards. The owner checks each `execution_prepare` configuration against the Environment, including the read-only profile, and fills the installed capabilities before an Executor starts. It applies `runtime_prepare`, lists directories for `workspace_read`, writes files for `workspace_write` and exports outputs for `workspace_export`. The Runtime's dispatcher keeps admission, transfer framing and fencing, and never substitutes another implementation. An agent host's owner works in the Session's sandbox through File and Process on a [Link](./sandbox-link-protocol.md) attachment of its own, opened under the bind's grant on first use. It lasts from the Session's first bind until its home is removed and outlives the Session's Executors and connections. Quiescing its Environment, releasing the assignment or superseding its epoch closes its attachment; a bind that supersedes the epoch takes the owner over once that attachment is closed. It runs each setup step as the Process operation whose ID is the `runtime_prepare` envelope ID. A `workspace_write` or `runtime_prepare` that cannot reach the sandbox before any effect ends `rejected` with `resource_unavailable`. It fails a Plugin whose MCP server declares literal `http_headers`, or is a stdio server with `env_vars`, before staging any of it. A file mutation or setup step whose outcome it cannot observe quarantines the owner until the home is removed: it sends no further mutation, and every later `workspace_write` and `runtime_prepare` ends `unknown`. A Session without an owner supports none of these operations, and the Runtime rejects each with its typed code: `unsupported_read_preparation` for a read-only preparation, `invalid_configuration` for an Executor configuration with a `local_environment`, `runtime_preparation_unsupported` for `runtime_prepare`, `write_unsupported` for `workspace_write`, and `read_unsupported` for `workspace_read` and `workspace_export`. Core records a release and advances the epoch before it sends anything, which withdraws the assignment's attach grant; it then has the relay revoke the Environment's Link resource at its current generation, so the attachments opened under the grant close before the Runtime receives the release. Deleting a Session releases its assignment with `remove_home: true`; releasing its Environment sends `false`. A deletion never revokes a shared Runtime credential. `assignment_release` fences the assignment at once. The Runtime then stops the Session's work: a transfer still receiving its body, or committed but not yet applied, ends with `assignment_stale`; it releases read-only preparations and waits until every workspace read, write, export and Runtime preparation has sent its result. It closes the Session's Executors, then releases what its Environment owner holds and, when asked, removes the native home; only then does it reply `released` or `home_removed`. Unfinished cleanup replies `failed` with `cleanup_unconfirmed`, and a retry at the same epoch repeats it. A Runtime that declares `home_removal` unsupported answers `remove_home: true` with `unsupported_operation`, and Core asks it only to release. Core records the release as applied from a matching `released` or `home_removed`, or at once when no Runtime is left to act on it: a release to a Runtime without authority is settled when recorded, and revoking a Runtime settles its releases. Core resends every unacknowledged release to a Runtime when it connects; a release that fails backs off, and the release due longest goes first, so failing releases cannot delay the rest. `environment_quiesce` applies to the named Environment only: it fails with `resource_busy` while one of the Environment's Sessions has work in progress; otherwise the Runtime closes the Environment's Executors, has its owners release what they hold and replies `environment_quiesced`. Until the matching `environment_resume`, which carries the assignment that quiesced it, the Runtime admits for that Environment only the releases of its Sessions and answers any other of its frames, including a bind, with `protocol_error` `resource_unavailable`; Sessions of other Environments keep running. A resume of an Environment that the connection has not quiesced, as after the Runtime restarts, succeeds and changes nothing; one that names another suspension fails with `not_suspended`. @@ -202,7 +202,7 @@ Request payloads are bounded at 8 KiB and correlation IDs at 128 bytes before ad Core runs an idle directory read on the Worker's Session scheduling reservation and targets the exact Run during active execution. It keeps the reservation through the bounded read and release, returns data only after a confirmed close (an incomplete read or uncertain cleanup returns unavailable without data), releases the reservation before delivering the result, and revokes the scoped read credential on completion or failure. The Runtime keeps uncertain cleanup ownership and capacity. The [Environment Files contract](../contracts/agents-api/environment-files.md) owns public authorization, paths and pagination. -`workspace_write` transfers a complete bounded body in acknowledged 64 KiB frames before the native writer runs, verifies the declared digest and runs no model. The private transfer bound is 50 MiB, separate from the public 5 MiB decoded inline bound that the API checks before any Runtime work. The Runtime excludes the Session's execution while it receives or applies a write; a malformed, incomplete or expired transfer never reaches the installer. An exact commit or rejection receipt settles the write. A missing or ambiguous receipt leaves the uncertainty with the [Environment owner](#session-assignments): observer cancellation and local process exit cannot prove that nothing changed. Before public admission Core durably reserves the write under the Session lock and blocks successor mutations across restarts until exact settlement; the request is never replayed. On every platform the owner lists directories, creates files and exports outputs in the daemon, with no external helper or staging directory. +`workspace_write` transfers a complete bounded body in acknowledged 64 KiB frames before the native writer runs, verifies the declared digest and runs no model. The private transfer bound is 50 MiB, separate from the public 5 MiB decoded inline bound that the API checks before any Runtime work. The Runtime excludes the Session's execution while it receives or applies a write; a malformed, incomplete or expired transfer never reaches the installer. An exact commit or rejection receipt settles the write. A missing or ambiguous receipt leaves the uncertainty with the [Environment owner](#session-assignments): observer cancellation and local process exit cannot prove that nothing changed. Before public admission Core durably reserves the write under the Session lock and blocks successor mutations across restarts until exact settlement; the request is never replayed. The agent host's owner lists directories, creates files and exports outputs over [File access](./file-access-protocol.md) on its Link attachment. ## MCP connection authority diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 1af0d4cdd..bafbdc176 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,12 +1,12 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: f3da984e5c04ed245950af6bbd51f110f66e8e0ec01f74e9458c21aefa84dc01 +source_hash: 5c72353535e1a5adc69cedde20408a38dfcfb2a4256c6335f9f9e381fc71b0ed --- 此协议在 Runtime daemon 获取机器凭据后连接 Core 与 daemon,定义 daemon 连接上消息的含义和顺序。wire 类型、限制和验证器仅在 [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/agentdaemon/proto) 中定义一次;Core 的 [gateway](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/runtimegateway) 与参考 Runtime 的 [dispatcher](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/dispatch) 都使用它们,因此无需同步第二套 payload schema。签发凭据和打开连接的 HTTP 路由见[机器连接 API](../../contracts/agents-api/zh/machine-api.md)。 -托管和自托管 Runtime 使用同一协议。Harness 通过 [Harness adapter 契约](../../contracts/agents-api/zh/harness-onboarding.md)接入,该契约负责 Runtime registry 后的 Executor 和 Turn 生命周期义务。 +托管和自托管 Environment 的 Session 使用同一协议和同一 Runtime,即 agent host。Harness 通过 [Harness adapter 契约](../../contracts/agents-api/zh/harness-onboarding.md)接入,该契约负责 Runtime registry 后的 Executor 和 Turn 生命周期义务。 ## 所有权与连接 {#ownership-and-connection} @@ -59,7 +59,7 @@ heartbeat 只能收窄 Harness 的静态声明。某个 kind 没有内置声明 | `execution_controls` | 始终设置:解析后的 text verbosity(默认 `medium`)、明确禁用 programmatic tool calling,以及任何 `json_schema` 输出格式。原生选项名称由 adapter 负责 | | `observe_subagent_identities`, `disable_subagents` | 根据 Agent 的 `multi_agent.enabled` 设置 | | `disable_execution_environment` | Environment 类型为 `none` 时设置 | -| `local_environment` | 为 `openai_hosted` 和 `self_hosted` 设置,包含精确的 Environment 绑定。请求不携带 working directory;Runtime 按自身绑定检查 `workspace_directory`。请求不携带网络策略,因为 Core 只准入[启用的网络](../../contracts/agents-api/zh/environments.md#restricted-network) | +| `local_environment` | 为 `openai_hosted` 和 `self_hosted` 设置,包含精确的 Environment 绑定。请求不携带 working directory;Environment owner 按 Environment 的工作区检查 `workspace_directory`。请求不携带网络策略,因为 Core 只准入[启用的网络](../../contracts/agents-api/zh/environments.md#restricted-network) | | `require_existing_native_session` | 需要恢复原生 Session 时设置 | 执行配置必须且只能包含 `local_environment` 和 `disable_execution_environment` 之一;两者都缺失或同时存在时,`execution_prepare` 以 `unsupported_configuration` 拒绝。 @@ -114,7 +114,7 @@ Usage frame 和最终 usage snapshot 都携带当前执行的累计测量,替 在一条连接上执行 Session 的第一个操作之前,包括没有 Turn 的 Environment 初始化和文件操作,Core 发送带 Session 的 Environment ID 的 `assignment_bind`,并等待 `assignment_status` `bound`。当 Runtime 是 agent host 且 Environment 有存活的 [Link](./sandbox-link-protocol.md) resource 时,绑定还携带 `resource` 和 `attach_grant`:前者是该 resource,形式与[引导输入](./sandbox-bootstrap.md#launch-input)中的相同;后者是 base64 编码的 grant,agent host 凭它在此分配和 epoch 下打开该 resource generation 上的服务。若 Environment 没有存活的 resource,Core 不向 agent host 发送绑定,需要该绑定的操作失败。grant 是机密。Core 不向其他任何 Runtime 发送这两个字段。只带其中一个字段、或带其他 Environment 的 resource 的绑定以 `invalid_request` 失败。以相同的 epoch、Environment、resource 和 grant 重复绑定同一分配仍得到 `bound`;其他字段不同的同 epoch 绑定以 `assignment_conflict` 失败。以更高 epoch 绑定已绑定的分配会取代较早的 epoch,resource 和 grant 均可不同:Runtime 像释放那样 fence 并清理较早 epoch 的工作,但保留 home,然后绑定新 epoch 并回复 `bound`。Session 的 Environment 从不改变,因此这样的绑定若指定其他 Environment,会在任何 fence 之前以 `assignment_conflict` 失败。其他分配的绑定,或以更高 epoch 绑定已释放的分配,以 `assignment_conflict` 失败。清理未完成时回复 `failed` 和 `cleanup_unconfirmed`,以同一 epoch 重试会重复清理;期间到达的释放或更晚的绑定使其以 `assignment_stale` 失败。Runtime 只在其已绑定的分配下准入 Session frame:较旧的 epoch 或已释放的分配以 `assignment_stale` 失败;其他分配、Session 或 Environment 以 `assignment_conflict` 失败。已启动 Run 的 frame,包括其取消回执,在释放前仍可在启动它的分配下准入。Runtime 已记录回执的重复函数结果或决策只在应用它的分配下得到回答;其他分配以 `assignment_conflict` 失败。 -Session 的第一次绑定确定其 Environment owner,此后不再改变;owner 持有 Environment 的资源,并执行对这些资源的每个作用。owner 根据 Environment 检查每个 `execution_prepare` 配置(包括只读 profile),并在 Executor 启动前填入已安装的能力。它应用 `runtime_prepare`,为 `workspace_read` 列举目录,为 `workspace_write` 写入文件,为 `workspace_export` 导出输出。Runtime 的 dispatcher 保留准入、传输分帧和 fencing,从不替换为其他实现。self-hosted Runtime 的 owner 是其绑定的本地工作区,该工作区比每个分配存续得更久;Runtime 以 `assignment_conflict` 拒绝任何其他 Session 的绑定。agent host 的 owner 通过自己的一个 [Link](./sandbox-link-protocol.md) attachment,用 File 和 Process 在 Session 的沙箱中工作;该 attachment 在首次使用时凭绑定的 grant 打开。owner 从 Session 的第一次绑定存续到其 home 被删除,比 Session 的 Executor 和连接存续得更久。其 Environment 被 quiesce、分配被释放或其 epoch 被取代时关闭其 attachment;取代该 epoch 的绑定在该 attachment 关闭后接管 owner。它把每个 setup 步骤作为 Process 操作运行,操作 ID 即 `runtime_prepare` 的 envelope ID。在产生任何作用前无法连到沙箱的 `workspace_write` 或 `runtime_prepare` 以 `rejected` 和 `resource_unavailable` 结束。若 Plugin 的 MCP server 声明了字面量 `http_headers`,或是带 `env_vars` 的 stdio server,owner 会在暂存其任何内容之前使其失败。无法观察到结果的文件变更或 setup 步骤会隔离 owner,直到 home 被删除:它不再发送任何变更,之后每个 `workspace_write` 和 `runtime_prepare` 都以 `unknown` 结束。没有 owner 的 Session 不支持上述任何操作,Runtime 以各自的类型化错误码拒绝:只读 preparation 为 `unsupported_read_preparation`;带 `local_environment` 的 Executor 配置为 `invalid_configuration`;`runtime_prepare` 为 `runtime_preparation_unsupported`;`workspace_write` 为 `write_unsupported`;`workspace_read` 和 `workspace_export` 为 `read_unsupported`。 +Session 的第一次绑定确定其 Environment owner,此后不再改变;owner 持有 Environment 的资源,并执行对这些资源的每个作用。owner 根据 Environment 检查每个 `execution_prepare` 配置(包括只读 profile),并在 Executor 启动前填入已安装的能力。它应用 `runtime_prepare`,为 `workspace_read` 列举目录,为 `workspace_write` 写入文件,为 `workspace_export` 导出输出。Runtime 的 dispatcher 保留准入、传输分帧和 fencing,从不替换为其他实现。agent host 的 owner 通过自己的一个 [Link](./sandbox-link-protocol.md) attachment,用 File 和 Process 在 Session 的沙箱中工作;该 attachment 在首次使用时凭绑定的 grant 打开。owner 从 Session 的第一次绑定存续到其 home 被删除,比 Session 的 Executor 和连接存续得更久。其 Environment 被 quiesce、分配被释放或其 epoch 被取代时关闭其 attachment;取代该 epoch 的绑定在该 attachment 关闭后接管 owner。它把每个 setup 步骤作为 Process 操作运行,操作 ID 即 `runtime_prepare` 的 envelope ID。在产生任何作用前无法连到沙箱的 `workspace_write` 或 `runtime_prepare` 以 `rejected` 和 `resource_unavailable` 结束。若 Plugin 的 MCP server 声明了字面量 `http_headers`,或是带 `env_vars` 的 stdio server,owner 会在暂存其任何内容之前使其失败。无法观察到结果的文件变更或 setup 步骤会隔离 owner,直到 home 被删除:它不再发送任何变更,之后每个 `workspace_write` 和 `runtime_prepare` 都以 `unknown` 结束。没有 owner 的 Session 不支持上述任何操作,Runtime 以各自的类型化错误码拒绝:只读 preparation 为 `unsupported_read_preparation`;带 `local_environment` 的 Executor 配置为 `invalid_configuration`;`runtime_prepare` 为 `runtime_preparation_unsupported`;`workspace_write` 为 `write_unsupported`;`workspace_read` 和 `workspace_export` 为 `read_unsupported`。 Core 先记录释放并推进 epoch,再发送任何消息;记录即撤回该分配的 attach grant。随后 Core 让 relay 吊销 Environment 的 Link resource 的当前 generation,使凭该 grant 打开的 attachment 在 Runtime 收到释放之前关闭。删除 Session 以 `remove_home: true` 释放其分配;释放其 Environment 发送 `false`。删除从不吊销共享的 Runtime 凭据。`assignment_release` 立即约束该分配。随后 Runtime 停止 Session 的工作:仍在接收内容、或已提交但尚未应用的传输以 `assignment_stale` 结束;它释放只读准备,并等待每个 workspace 读取、写入、导出和 Runtime 准备发送结果。它关闭 Session 的 Executor,随后释放其 Environment owner 持有的资源,并在要求时删除原生 home;此后才回复 `released` 或 `home_removed`。未完成的清理回复 `failed` 和 `cleanup_unconfirmed`,同一 epoch 的重试会重复清理。声明 `home_removal` 不支持的 Runtime 以 `unsupported_operation` 回答 `remove_home: true`,Core 只要求它释放。Core 根据匹配的 `released` 或 `home_removed` 记录释放已应用;没有 Runtime 能处理该释放时立即记录:发给无授权 Runtime 的释放在记录时即结清,吊销 Runtime 会结清它的释放。Core 在 Runtime 连接时重发所有未确认的释放;失败的释放退避重试,等待最久的释放先发送,因此失败的释放不会拖延其他释放。`environment_quiesce` 只作用于指定的 Environment:该 Environment 的某个 Session 仍有进行中的工作时,它以 `resource_busy` 失败;否则 Runtime 关闭该 Environment 的 Executor,让其 owner 释放所持有的资源,并回复 `environment_quiesced`。在匹配的 `environment_resume`(携带使其 quiesce 的分配)到达之前,Runtime 对该 Environment 只准入其 Session 的释放,并以 `protocol_error` `resource_unavailable` 回答它的其他任何 frame(包括绑定);其他 Environment 的 Session 继续运行。对该连接未 quiesce 的 Environment 的 resume(例如 Runtime 重启之后)会成功且不改变任何状态;指定其他暂停的 resume 以 `not_suspended` 失败。 @@ -204,7 +204,7 @@ Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engin Core 在 Worker 的 Session 调度预约上运行空闲目录读取,活动执行时针对精确 Run。它在有限时 read 与 release 期间保留预约,仅在确认 close 后返回数据(不完整读取或不确定清理返回 unavailable,不包含数据),在交付结果前释放预约,并在完成或失败后撤销限定作用域的读取凭据。Runtime 保留不确定清理的所有权和容量。[Environment Files 契约](../../contracts/agents-api/zh/environment-files.md)负责公开授权、路径和分页。 -`workspace_write` 在原生 writer 运行前,通过已确认的 64 KiB frame 传输完整且有界的 body,验证声明的 digest,不运行模型。私有 transfer 限制为 50 MiB,与公开 API 在任何 Runtime 工作前检查的 5 MiB decoded inline 限制独立。Runtime 在接收或应用写入时排除该 Session 的执行;格式错误、不完整或到期的 transfer 不会到达 installer。精确的 commit 或拒绝回执结算该写入。缺失或有歧义的回执把不确定性留给 [Environment owner](#session-assignments):observer 取消和本地进程退出不能证明没有改变任何内容。公开准入前,Core 在 Session lock 下持久预约写入,跨重启阻止后继 mutation,直到精确结算;请求不重放。在各平台上,owner 都在 daemon 内列举目录、创建文件和导出输出,不使用外部 helper 或 staging directory。 +`workspace_write` 在原生 writer 运行前,通过已确认的 64 KiB frame 传输完整且有界的 body,验证声明的 digest,不运行模型。私有 transfer 限制为 50 MiB,与公开 API 在任何 Runtime 工作前检查的 5 MiB decoded inline 限制独立。Runtime 在接收或应用写入时排除该 Session 的执行;格式错误、不完整或到期的 transfer 不会到达 installer。精确的 commit 或拒绝回执结算该写入。缺失或有歧义的回执把不确定性留给 [Environment owner](#session-assignments):observer 取消和本地进程退出不能证明没有改变任何内容。公开准入前,Core 在 Session lock 下持久预约写入,跨重启阻止后继 mutation,直到精确结算;请求不重放。agent host 的 owner 在其 Link attachment 上通过 [File access](./file-access-protocol.md) 列举目录、创建文件和导出输出。 ## MCP 连接权限 {#mcp-connection-authority} diff --git a/internal/agentcapabilities/root.go b/internal/agentcapabilities/root_test.go similarity index 98% rename from internal/agentcapabilities/root.go rename to internal/agentcapabilities/root_test.go index 14170d84a..06d5874fe 100644 --- a/internal/agentcapabilities/root.go +++ b/internal/agentcapabilities/root_test.go @@ -13,8 +13,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) -// The rest of this file applies the installation rules to a local directory, -// as an os.Root. +// The tests apply the installation rules to a local directory, as an os.Root, +// as the agent host applies them to the sandbox over File. // DirectoryResolver opens a declared source after local authorization and path // checks. Finalize owns and closes each returned root; callers retain no handle. diff --git a/internal/agentnetwork/policy.go b/internal/agentnetwork/policy.go index fb94397bc..0019de173 100644 --- a/internal/agentnetwork/policy.go +++ b/internal/agentnetwork/policy.go @@ -88,8 +88,3 @@ func (p Policy) Narrows(template Policy) bool { } return true } - -// Equal compares validated effective authority, independent of public list order. -func (p Policy) Equal(other Policy) bool { - return p.Access == other.Access && p.Validate() == nil && other.Validate() == nil && slices.Equal(p.Hosts(), other.Hosts()) -} diff --git a/internal/agentnetwork/policy_test.go b/internal/agentnetwork/policy_test.go index 581d34e2c..fb093e004 100644 --- a/internal/agentnetwork/policy_test.go +++ b/internal/agentnetwork/policy_test.go @@ -46,15 +46,11 @@ func TestTemplateNetworkOverrideCannotBroaden(t *testing.T) { } } -func TestPolicyIdentityPreservesPublicInput(t *testing.T) { +func TestPolicyHostsPreservePublicInput(t *testing.T) { input := []string{"B.example.com", "a.example.com", "B.example.com"} before := append([]string(nil), input...) - policy := Policy{Access: "restricted", AllowedDomains: input} - other := Policy{Access: "restricted", AllowedDomains: []string{"a.example.com", "b.example.com"}} - if !policy.Equal(other) || !reflect.DeepEqual(input, before) { - t.Fatal("effective comparison changed caller input or list order affected authority") - } - if policy.Equal(Policy{Access: "restricted", AllowedDomains: []string{"example.com"}}) { - t.Fatal("parent hostname must not grant subdomain authority") + hosts := Policy{Access: "restricted", AllowedDomains: input}.Hosts() + if !reflect.DeepEqual(hosts, []string{"a.example.com", "b.example.com"}) || !reflect.DeepEqual(input, before) { + t.Fatalf("the hosts are %q from %q: list order or case affected authority, or the input changed", hosts, input) } } diff --git a/internal/harnessconfig/builtin/selection_test.go b/internal/harnessconfig/builtin/selection_test.go index 391a6ddf5..7189b53a1 100644 --- a/internal/harnessconfig/builtin/selection_test.go +++ b/internal/harnessconfig/builtin/selection_test.go @@ -47,7 +47,7 @@ func TestSelectionsAgainstEachDeclaration(t *testing.T) { {"local environment", proto.Selection{Environment: "local"}, nil, true}, {"installed capabilities", proto.Selection{Environment: "local", InstalledCapabilities: true}, nil, true}, {"multi-agent", proto.Selection{Environment: "none", MultiAgent: true}, nil, true}, - {"native session recovery", proto.Selection{Environment: "none", NativeSessionRecovery: true}, map[string]string{"mcode": ""}, true}, + {"native session recovery", proto.Selection{Environment: "none", NativeSessionRecovery: true}, map[string]string{"claude_sdk": "", "mcode": ""}, true}, {"function tools", proto.Selection{Environment: "none", Functions: true}, map[string]string{"mcode": tool}, true}, {"tool search", search, map[string]string{"codex": tool, "mcode": tool}, true}, {"json_schema output", proto.Selection{Environment: "none", OutputSchema: schema}, map[string]string{"codex": format, "mcode": format}, true}, diff --git a/internal/harnessconfig/claudesdk/configuration.go b/internal/harnessconfig/claudesdk/configuration.go index cbe4aa464..5dfac4043 100644 --- a/internal/harnessconfig/claudesdk/configuration.go +++ b/internal/harnessconfig/claudesdk/configuration.go @@ -14,7 +14,7 @@ func Configuration() harnessconfig.Configuration { {Protocol: "anthropic"}, }, Declaration: proto.Declaration{ Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: s, NativeSessionRecovery: s, EnvironmentNone: s, LocalEnvironment: s, + SubagentObservations: s, NativeSessionRecovery: u, EnvironmentNone: s, LocalEnvironment: s, TextVerbosity: u, StructuredOutput: s, ToolSearch: s, MessageImages: s, FunctionResultImages: s, FunctionTools: s, MCPHTTPTools: s, MCPHTTPRequired: s, MCPHTTPBearerAuth: s, }, diff --git a/internal/runtimefs/runtimefs.go b/internal/runtimefs/runtimefs.go index 093369431..7a3c30b53 100644 --- a/internal/runtimefs/runtimefs.go +++ b/internal/runtimefs/runtimefs.go @@ -129,20 +129,3 @@ func OpenPrivate(root *os.Root, name string, flags int) (*os.File, error) { } func ensurePrivateDir(path string) error { return os.MkdirAll(path, 0700) } - -func MkdirPrivate(root *os.Root, name string) error { - if !validName(name) { - return ErrUnsafe - } - if err := root.Mkdir(name, 0700); err != nil && !errors.Is(err, os.ErrExist) { - return err - } - info, err := root.Stat(name) - if err != nil { - return err - } - if !info.IsDir() { - return ErrUnsafe - } - return nil -} diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 9ec7a49f3..95b154d9c 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -20,15 +20,15 @@ The package test compiles TypeScript before running its tests. The Make target a ### Bridge protocol -`packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK and native message translation. The Go `claudesdk.NewExecutorFactory` uses the shared owned process runner and emits the daemon's delta, error and Done frames. The SDK owns the model loop. Its narrow stdio protocol carries Executor preparation and identified Turn starts, text deltas, function calls/results/receipts, active input/receipts, usage snapshots and terminal result/error plus settlement; native translation stays inside the adapter. The private `native_model_options` input contains only the native options compiled by Go after shared Harness validation. The bridge checks object/field structure and maps those fields explicitly to SDK options; enum membership, budget ranges and thinking combinations belong solely to the Go adapter declaration. The public `harness_config` object does not cross this private boundary. +`packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK and native message translation. The Go adapter's agent-host view Executor runs the bridge through the view's Launch and emits the daemon's delta, error and Done frames. The SDK owns the model loop. Its narrow stdio protocol carries Executor preparation and identified Turn starts, text deltas, function calls/results/receipts, active input/receipts, usage snapshots and terminal result/error plus settlement; native translation stays inside the adapter. The private `native_model_options` input contains only the native options compiled by Go after shared Harness validation. The bridge checks object/field structure and maps those fields explicitly to SDK options; enum membership, budget ranges and thinking combinations belong solely to the Go adapter declaration. The public `harness_config` object does not cross this private boundary. It emits the neutral `output_message` start/completion snapshots and tags every delta with the native Messages API message ID, not the SDK event UUID. Text blocks in one native message share that identity. The SDK's per-block assistant snapshots replace draft block text; only native `message_stop` completes the message, without replaying its text as another delta. Thinking/tool-only messages produce no text Items; interrupted messages retain their streamed partial text. No phase is inferred from the final result. Turn-owned native work and output draining precede reuse. Executor close releases the SDK Query and native process. The private `turn_settled` frame requires `confirmed` independently of `reusable`: confirmed native Turn/cancellation settlement, confirmed resource cleanup, and reuse eligibility are separate facts. Unknown or nonempty interrupt receipts and unsettled input/function/tool work remain unconfirmed even after successful teardown. Go rejects cancellation and AwaitSettlement when native confirmation is missing or false, or its own receipt ledger remains unsettled. A confirmed Turn may be non-reusable after cleanup; that state alone does not turn a verified cancellation into an error. Confirmed native cancellation may settle unanswered function calls after result admission closes and callbacks drain. A submitted function result still requires its native application receipt, including when the MCP request aborts. ### Workspace execution -`claudesdk.Config.Workspace` is an operator binding for the selected workspace and native state. It enables native Bash/Read/Edit and admitted host functions in the SDK loop. Native tools run with the launching user's permissions on all platforms; the adapter adds no inner sandbox, protected-root deny policy or managed shell wrapper. Isolation belongs to the outer Environment ([Runtime and outer isolation](../../docs/concepts.md#runtime-and-outer-isolation)), which must exclude other tenants' and broader application credentials; an ordinary native install provides no such boundary, and directory selection is not tenant authorization. The adapter's tool callback authorizes unattended execution in native `default` permission mode. It does not use the CLI permission-bypass flag, which Claude rejects for root accounts. +The bridge runs in an agent-host view: Node, the bridge and the SDK's native Claude Code run from the view's closure, with the sandbox's workspace as the working directory. Everything else Claude Code runs, including native Bash, goes to the sandbox through the view's shims, and its model and MCP traffic goes to the Session's gateway. The workspace profile enables native Bash/Read/Edit and admitted host functions in the SDK loop. The adapter adds no inner sandbox, protected-root deny policy or managed shell wrapper. Isolation belongs to the view and the outer Environment ([Runtime and outer isolation](../../docs/concepts.md#runtime-and-outer-isolation)). The adapter's tool callback authorizes unattended execution in native `default` permission mode. It does not use the CLI permission-bypass flag, which Claude rejects for root accounts. -`Config.Env` selects readiness and native process variables. Explicit tool env is applied to tool execution, but same-user tools can still read credentials or history from local files. Workspace hooks keep their event, identity and lifecycle responsibilities; they are not security enforcement. Process groups and Windows Jobs provide cancellation and descendant cleanup, not isolation. Supported MCP and subagent combinations require their own qualification. The `none` profile keeps its tool inventory. Packaged `workspace_tools` establishes bridge support, not outer host isolation or public API admission. The dedicated Runtime composes public preparation, placement quotas, command Items and Files ownership. Real-provider acceptance verifies effects, cancellation and same-history continuation for the actual platform and outer deployment. +The Harness environment is closed: the Session home's native directories, the gateway proxy, the selected provider and fixed native flags. Nothing from the agent host's environment reaches it. `Config.Env` adds only to the runtime check's environment. Workspace hooks keep their event, identity and lifecycle responsibilities; they are not security enforcement. Supported MCP and subagent combinations require their own qualification. With environment `none`, the bridge runs without a workspace in the Session home's work directory and keeps its tool inventory. Packaged `workspace_tools` establishes bridge support, not outer host isolation or public API admission. Real-provider acceptance verifies effects, cancellation and same-history continuation for the actual platform and outer deployment. ### Executor preparation and Turns @@ -36,7 +36,7 @@ The private bridge accepts `executor_prepare` without model input. It freezes va Each Turn ends with a result/error and `turn_settled`, independently of process exit. The outer input iterator remains open for later Turns. `turn_cancel` invokes the native interrupt control for that exact Turn. Unconfirmed input, native child work or queue state invalidates the Executor and requires close before replacement. EOF, owner signals and invalid control input close owned resources. Preparation may write native metadata and perform startup traffic; readiness does not prove provider authentication, complete sandbox health or placement authorization. -`claudesdk.NewExecutorFactory` binds this bridge to `agent.Executor`. Runtime execution uses the Executor registry for both none and workspace configurations. Its owner context spans all Turns; a Turn's caller cannot replace fixed resources. A failed preparation returns its Executor when cleanup remains unconfirmed. Installed runtime checks are cached by package/file identity, while capability and request validation still run for each Executor configuration. +The view Executor factory binds this bridge to `agent.Executor` for both environment `none` and workspace Sessions. Its owner context spans all Turns; a Turn's caller cannot replace fixed resources. A failed preparation returns its Executor when cleanup remains unconfirmed. Installed runtime checks are cached by package/file identity, while capability and request validation still run for each Executor configuration. ### Command observations @@ -44,9 +44,9 @@ Private workspace execution requires the packaged `workspace_command_observation ### HTTP MCP -The private adapter accepts typed anonymous HTTP and static-bearer HTTPS MCP declarations on the trusted `environment:none` harness host. The packaged readiness report must include `mcp_http_tools`; discovery advertises that feature only when present, and execution rechecks the installed bundle before dispatching an MCP request. An unchanged SDK version alone cannot qualify an older bridge. Authenticated private requests also require the packaged `mcp_http_bearer_auth` feature at discovery and dispatch. The daemon generates a separate environment reference for each server and launch; only those references enter the bridge request and native SDK configuration. The native HTTP client expands them from its owned process environment. Literal bearers must never enter SDK MCP headers because that configuration enters argv. Readiness probes receive no per-request bearer environment. Token validation is shared with the Codex adapter; credential storage remains an opaque-string contract. Public MCP admission, Vault credential selection and their failure rules belong to [public MCP connection origin](../../contracts/agents-api/environments.md#public-mcp-connection-origin) and [credential selection](../../contracts/agents-api/vaults.md#credential-selection-in-a-session). +The bridge receives each HTTP MCP server as the Session gateway's credential-free endpoint and each installed stdio server as its view alias, which runs in the sandbox without arguments. The gateway adds any bearer credential or header, so no credential enters the bridge request, the native SDK configuration or the Harness environment. The packaged readiness report must include `mcp_http_tools` and `workspace_mcp_http`; discovery advertises MCP only when both are present, and execution rechecks the installed bundle before dispatching an MCP request. An unchanged SDK version alone cannot qualify an older bridge. Discovery advertises authenticated MCP only with the packaged `mcp_http_bearer_auth` feature. Token validation is shared with the Codex adapter; credential storage remains an opaque-string contract. Public MCP admission, Vault credential selection and their failure rules belong to [public MCP connection origin](../../contracts/agents-api/environments.md#public-mcp-connection-origin) and [credential selection](../../contracts/agents-api/vaults.md#credential-selection-in-a-session). -MCP queries use the SDK's main-thread Agent definition to restrict model-visible tools, in addition to empty built-ins, strict MCP configuration, empty setting sources and default-deny permissions. Permission allowlists alone do not restrict the native model inventory. Null selects all tools from a declared server; an empty list selects none. Host functions compose with those selections. Native server status supplies original tool identities; map their normalized native aliases while preserving the original names in observations. Native status deduplicates aliases, so it does not prove a complete original server inventory. A native PreToolUse hook waits for inventory verification before admitting root calls and denies unverified, mismatched or cancelled calls. The native Agent restriction controls model-visible tools; inventory verification is not a barrier before the model request. Anonymous HTTP declarations explicitly set an empty Authorization header to disable native OAuth and automatic credential injection. Preserve that header; do not erase native history or credentials to enforce this boundary. Servers that reject a blank Authorization header, normalized name collisions and inventory changes during a query require separate validation; this profile covers static inventories. Private SDK status/control objects can contain expanded authentication headers. Read only connection and tool identity fields; never retain, log or publish raw status/configuration or control responses. Diagnostic projections must whitelist safe fields; filtering actual model or tool output does not fix a leak. The adapter profile requires connected servers, reserves the `functions` label, accepts alphanumeric/underscore/hyphen server labels and alphanumeric/underscore/hyphen/dot selected tool names, and excludes remote environments. Required startup is separately qualified by `mcp_http_required`. All HTTP MCP queries use native SDK startup and an empty input iterator to confirm initialization hooks. Required declarations additionally check connected server status before the initial prompt is released exactly once. Pending, failed, missing or ambiguous required status rejects before input; native startup timeouts are retained without an adapter retry loop. Normal system/init still verifies Session identity and the complete inventory before input readiness/tool authority. Optional servers keep their inventory checks without a pre-input connection requirement. A Runtime must advertise the concrete required-initialization capability; there is no fallback to another execution path. +MCP queries use the SDK's main-thread Agent definition to restrict model-visible tools, in addition to empty built-ins, strict MCP configuration, empty setting sources and default-deny permissions. Permission allowlists alone do not restrict the native model inventory. Null selects all tools from a declared server; an empty list selects none. Host functions compose with those selections. Native server status supplies original tool identities; map their normalized native aliases while preserving the original names in observations. Native status deduplicates aliases, so it does not prove a complete original server inventory. A native PreToolUse hook waits for inventory verification before admitting root calls and denies unverified, mismatched or cancelled calls. The native Agent restriction controls model-visible tools; inventory verification is not a barrier before the model request. Every HTTP declaration explicitly sets an empty Authorization header to disable native OAuth and automatic credential injection. Preserve that header; do not erase native history or credentials to enforce this boundary. Servers that reject a blank Authorization header, normalized name collisions and inventory changes during a query require separate validation; this profile covers static inventories. From private SDK status/control objects, read only connection and tool identity fields; never retain, log or publish raw status/configuration or control responses. Diagnostic projections must whitelist safe fields; filtering actual model or tool output does not fix a leak. The adapter profile requires connected servers, reserves the `functions` label, accepts alphanumeric/underscore/hyphen server labels and alphanumeric/underscore/hyphen/dot selected tool names, and excludes remote environments. Required startup is separately qualified by `mcp_http_required`. All HTTP MCP queries use native SDK startup and an empty input iterator to confirm initialization hooks. Required declarations additionally check connected server status before the initial prompt is released exactly once. Pending, failed, missing or ambiguous required status rejects before input; native startup timeouts are retained without an adapter retry loop. Normal system/init still verifies Session identity and the complete inventory before input readiness/tool authority. Optional servers keep their inventory checks without a pre-input connection requirement. A Runtime must advertise the concrete required-initialization capability; there is no fallback to another execution path. Root assistant tool calls and live root user results produce the neutral MCP observations. Correlate actual Session/call identities; exclude replay, synthetic and subagent work and keep host function receipts separate. Preserve the exact native `tool_use_result` when one result is unambiguous, otherwise the per-call result content. Native errors remain observed native errors. The SDK can replace annotated MCP content with rendered structuredContent and flatten MCP errors; these observations do not claim original MCP envelope fidelity or hosted output parity. Do not reconstruct lost fields or infer output from model prose. Unfinished observed calls become incomplete on shutdown, without claiming that remote tool effects were cancelled. Rich content, native truncation and asynchronous MCP task results remain unverified. @@ -56,17 +56,17 @@ Workspace deferred-function discovery uses native ToolSearch alongside the norma ### Registration and state -For unmanaged bootstrap, daemon `connect` optionally registers this adapter as `claude_sdk` when the operator sets `OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT` to the absolute packaged `dist/main.js`. `OAC_RUNTIME_CLAUDE_SDK_NODE` selects Node (default: `node` on PATH). Discovery resolves Node once and checks that exact configuration before connecting; the SDK's bounded runtime check is independent of CLI version probes. A ready SDK alone is sufficient to start the daemon. No configuration means no SDK probe or descriptor; failed readiness reports an unavailable descriptor without factories. Runtime checks establish local readiness, not provider authentication. Installed daemons use `start` and their verified installation manifest for adapter selection and activation; ambient activation variables cannot extend that selection. See [the native installation contract](../../deploy/README.md#native-daemon-installer). +Discovery registers this adapter as `claude_sdk` when `OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT` names the absolute packaged `dist/main.js`; `OAC_RUNTIME_CLAUDE_SDK_NODE` selects Node (default: `node` on PATH). The agent host sets both from its verified installation manifest. Discovery resolves Node once and checks that exact configuration; the SDK's bounded runtime check is independent of CLI version probes. No configuration means no SDK probe or descriptor; failed readiness reports an unavailable descriptor without a view. Runtime checks establish local readiness, not provider authentication. See [the native installation contract](../../deploy/README.md#native-daemon-installer). -SDK state lives under `paths.ProfileDir(profile)/runtime/claude-sdk`, independently of the replaceable runtime bundle. Both the entrypoint and managed state root must be absolute. Background re-execution inherits operator configuration; it does not persist provider credentials in credential profiles. It accepts no caller-supplied environment variables or business write authority. +Native state lives in the per-Session home the agent host provides, independently of the replaceable runtime bundle: `config` is `CLAUDE_CONFIG_DIR`, beside `home`, `tmp` and `xdg`, and the home persists across the Session's Executors. The bridge accepts no caller-supplied environment variables or business write authority. ### Descriptor and execution profile The SDK descriptor advertises the validated daemon subset, including durable Turns/input receipts, text observations, function tools, raw usage and restrictive execution controls. It does not advertise permissions, raw tool Items, general web-search control or text-verbosity levels. Router admission for `environment:none` uses the available engine capability, not an engine name. -Core owns public admission for Claude: [harness selection](../../contracts/agents-api/model-execution.md#harness-selection) chooses the engine for each Session; one [declaration](../../contracts/agents-api/harness-onboarding.md#declare-support) serves API admission, device selection and the final claim, and records Claude's medium-only verbosity; [function result images](../../contracts/agents-api/message-content.md#function-results) defines which placements accept image results; and [deployment defaults](../../contracts/agents-api/model-execution.md#deployment-defaults) define the model provider Core freezes for a Session. The adapter receives that provider in the request's typed `model_provider`, never in public Session configuration. The adapter alone selects the provider environment and removes credentials from native tool environments. +Core owns public admission for Claude: [harness selection](../../contracts/agents-api/model-execution.md#harness-selection) chooses the engine for each Session; one [declaration](../../contracts/agents-api/harness-onboarding.md#declare-support) serves API admission, device selection and the final claim, and records Claude's medium-only verbosity; [function result images](../../contracts/agents-api/message-content.md#function-results) defines which placements accept image results; and [deployment defaults](../../contracts/agents-api/model-execution.md#deployment-defaults) define the model provider Core freezes for a Session. The adapter receives that provider in the request's typed `model_provider`, never in public Session configuration. The adapter alone renders that provider into the closed Harness environment. -The `none` profile accepts only text, explicit model/system instructions, managed state, exact native resume and declared functions with ordered text or successful inline PNG/JPEG results, and the HTTP MCP subset described above. It rejects unsupported request fields and disables built-in tools and undeclared MCP discovery. `DisableExecutionEnvironment` and `DisableSubagents` are accepted assertions about the single-Agent restrictive profile. Omission does not enable built-in tools. Explicit Subagent observation enables only the native delegation tools described under [Subagents](#subagents). Single-Agent new and resumed queries use the SDK's empty built-in tool set, explicit function MCP configuration and allowlist, strict MCP configuration and empty user/project/local setting sources. Without HTTP MCP declarations, native initialization and real provider request inventories must contain only the declared host functions. Managed operator policy may further restrict execution; it must not widen the profile. The profile limits model tool access; it does not isolate native state files or filesystem access by an explicitly supplied host function. The private factory accepts typed execution controls only for disabled search and medium text verbosity. Search remains excluded by the native tool inventory; medium retains the SDK's default text generation, without adding instructions or changing caller input. The pinned SDK has no native verbosity-level option, so low and high verbosity and enabled search are unsupported. Missing/invalid fields in a supplied control block fail before native setup; omitting the block keeps the same restrictive profile. Use the SDK's history lookup before explicit resume; never fall back to a new Session. Native files remain device-affine under a caller-selected managed runtime directory. The launch configuration supplies trusted provider environment; the request cannot supply environment variables or business write authority. An omitted or empty `system_prompt` maps to empty SDK instructions only at this adapter boundary; a request without a model is rejected. +The `none` profile accepts only text, explicit model/system instructions, managed state, exact native resume and declared functions with ordered text or successful inline PNG/JPEG results, and the HTTP MCP subset described above. It rejects unsupported request fields and disables built-in tools and undeclared MCP discovery. `DisableExecutionEnvironment` and `DisableSubagents` are accepted assertions about the single-Agent restrictive profile. Omission does not enable built-in tools. Explicit Subagent observation enables only the native delegation tools described under [Subagents](#subagents). Single-Agent new and resumed queries use the SDK's empty built-in tool set, explicit function MCP configuration and allowlist, strict MCP configuration and empty user/project/local setting sources. Without HTTP MCP declarations, native initialization and real provider request inventories must contain only the declared host functions. Managed operator policy may further restrict execution; it must not widen the profile. The profile limits model tool access; it does not isolate native state files or filesystem access by an explicitly supplied host function. The private factory accepts typed execution controls only for disabled search and medium text verbosity. Search remains excluded by the native tool inventory; medium retains the SDK's default text generation, without adding instructions or changing caller input. The pinned SDK has no native verbosity-level option, so low and high verbosity and enabled search are unsupported. Missing/invalid fields in a supplied control block fail before native setup; omitting the block keeps the same restrictive profile. Use the SDK's history lookup before explicit resume; never fall back to a new Session. Native files remain in the Session home. The request's typed `model_provider` is the only provider environment; the request cannot supply other environment variables or business write authority. An omitted or empty `system_prompt` maps to empty SDK instructions only at this adapter boundary; a request without a model is rejected. ### Function server and results @@ -94,7 +94,7 @@ The first own native user record has a null `parentUuid`. Its timestamp supplies The native query owns child execution and cleanup. PreToolUse admission reserves each native Agent or idle-child SendMessage call before execution. Native `task_started` associates the call and child ID; completion releases that reservation. The frozen limit applies across the child tree, excludes the root, and defaults to six. Unknown call associations fail closed. SendMessage to a running child is rejected; only idle continuation is qualified. Native background execution, alternate agent types, worktree isolation and per-call model overrides are rejected. -Workspace children use native Bash with the same launching-user permissions as the parent, and the daemon and adapter add no filesystem, permission or network sandbox. A child's workspace tool calls are denied until native task admission has verified its identity. Workspace hooks keep their execution and event responsibilities but are not a private-file boundary: tools can access Runtime state that the host user can access. Isolation belongs to the outer Environment. Functions and MCP combined with Subagents are not qualified and are rejected explicitly ([subagents contract](../../contracts/agents-api/subagents.md)); functions and MCP without Subagents are unaffected. Claude on Windows requires Git Bash. +Workspace children use the parent's native Bash, which runs in the sandbox through the view's shims, and the adapter adds no filesystem, permission or network sandbox. A child's workspace tool calls are denied until native task admission has verified its identity. Workspace hooks keep their execution and event responsibilities but are not a private-file boundary. Isolation belongs to the view and the outer Environment. Functions and MCP combined with Subagents are not qualified and are rejected explicitly ([subagents contract](../../contracts/agents-api/subagents.md)); functions and MCP without Subagents are unaffected. Cancellation uses an adapter-owned effect receipt only after the query owner confirms native process exit, because the fixed native history can end at a tool call without a cancellation result or timestamp. Each receipt is linked into the native history directory atomically, without overwriting an earlier receipt, and records the child, own Turn, spawn call and confirmed effect time. Native records stay unchanged. Replay uses that same timestamp; it never takes a new cancellation time from an unfinished history. Child Items and the cancelled Turn precede the root cancellation event. Missing native exit confirmation or a missing receipt does not imply a terminal child state. @@ -104,6 +104,6 @@ Cancellation uses an adapter-owned effect receipt only after the query owner con The build validates source manifests with the repository-pinned pnpm frozen install and compiles into fresh managed staging, never exporting incremental checkout output. It then uses `pnpm deploy` from the adapter workspace with its dedicated frozen lock. The adapter has no workspace dependencies; keep that boundary explicit. Workspace injection is configured only in this adapter project for its frozen export; do not enable it in the Web/client workspace or replace export with a custom dependency copier. Export only compiled `dist` and production dependencies; retain their package metadata, lockfile and licenses. Check dependency links stay inside the export, pinned SDK/MCP/native versions, native `--version`, and bridge startup before publishing the archive. Startup with stdin EOF is an import check, not model execution acceptance. `make check-claude-sdk` includes this artifact check. -Extract the archive into a fresh managed runtime directory on a matching host and use its absolute `dist/main.js` as the private factory entrypoint. Validate relocation and real provider cancellation/continuation before accepting an artifact. Linux x64/glibc with Node22 is the currently exercised platform; other hosts require their own native acceptance. Do not reuse a bundle across platforms or libc variants. The native installer bundles Node and owns user-managed activation; release publication remains separate. Operator-configured discovery and registration remain available for unmanaged bootstrap as specified above. +Extract the archive into a fresh managed runtime directory on a matching host and use its absolute `dist/main.js` as the discovery entrypoint. Validate relocation and real provider cancellation/continuation before accepting an artifact. Linux x64/glibc with Node22 is the currently exercised platform; other hosts require their own native acceptance. Do not reuse a bundle across platforms or libc variants. The native installer bundles Node and owns user-managed activation; release publication remains separate. -The exported `dist/runtime_check.js` companion is the local readiness contract. It checks Node20+, installed SDK/MCP/native versions against the package manifest, contained dependency resolution, native startup, and the exact `dist/main.js` bridge with stdin EOF. It emits one versioned JSON report without calling a model or creating Session state. The artifact check reuses this companion and separately checks all exported links, the lockfile and source pins. `claudesdk.CheckRuntime` uses the same Node, entrypoint and environment as execution, with shared process-group ownership, bounded output and a 15-second deadline plus bounded cleanup. Both native and bridge probes have five-second limits. Return unavailable on failed or malformed probes; never forward native diagnostics or treat local readiness as provider authentication, public capability acceptance or filesystem isolation. The native installer reuses this readiness check after copying its release components. +The exported `dist/runtime_check.js` companion is the local readiness contract. It checks Node20+, installed SDK/MCP/native versions against the package manifest, contained dependency resolution, native startup, and the exact `dist/main.js` bridge with stdin EOF. It emits one versioned JSON report without calling a model or creating Session state. The artifact check reuses this companion and separately checks all exported links, the lockfile and source pins. `claudesdk.CheckRuntime` probes the Node and entrypoint that the view runs, with shared process-group ownership, bounded output and a 15-second deadline plus bounded cleanup. Both native and bridge probes have five-second limits. Return unavailable on failed or malformed probes; never forward native diagnostics or treat local readiness as provider authentication, public capability acceptance or filesystem isolation. The native installer reuses this readiness check after copying its release components. diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index 38cc0c410..7d924b8a7 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -6,7 +6,6 @@ export type HTTPServer = { server_url: string; allowed_tools: string[] | null; required?: boolean; - bearer_token_env_var?: string; }; export type ToolIdentity = { server: string; name: string }; @@ -20,10 +19,9 @@ export function parseHTTPServers(value: unknown): HTTPServer[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value)) throw new Error("invalid_request"); const labels = new Set(); - const references = new Set(); for (const server of value) { if (!server || typeof server !== "object" || - Object.keys(server).some(key => !["server_label", "server_url", "allowed_tools", "bearer_token_env_var", "required"].includes(key)) || + Object.keys(server).some(key => !["server_label", "server_url", "allowed_tools", "required"].includes(key)) || (server.required !== undefined && typeof server.required !== "boolean") || typeof server.server_label !== "string" || !server.server_label || labels.has(server.server_label) || typeof server.server_url !== "string" || @@ -34,12 +32,6 @@ export function parseHTTPServers(value: unknown): HTTPServer[] | undefined { const url = new URL(server.server_url); if (!["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password || server.server_url.includes("?") || server.server_url.includes("#")) throw new Error("invalid_request"); - if (server.bearer_token_env_var !== undefined) { - const reference = server.bearer_token_env_var; - if (url.protocol !== "https:" || typeof reference !== "string" || - !/^OAC_RUNTIME_MCP_BEARER_[A-Z2-7]{26,}$/.test(reference) || references.has(reference)) throw new Error("invalid_request"); - references.add(reference); - } labels.add(server.server_label); } return value; @@ -79,14 +71,12 @@ export class MCPProfile { for (const server of declarations) { const prefix = `mcp__${server.server_label}__`; if ("command" in server) { - this.servers[server.server_label] = { type: "stdio", command: server.command, args: [...(server.args ?? [])], env: {}, alwaysLoad: true }; + this.servers[server.server_label] = { type: "stdio", command: server.command, args: [], env: {}, alwaysLoad: true }; } else { - const reference = server.bearer_token_env_var; - if (reference && !process.env[reference]) throw new Error("missing MCP credential environment"); - // An explicit empty Authorization suppresses native OAuth and automatic auth. - // Keep bearer references literal: SDK server configuration enters native argv. + // An explicit empty Authorization suppresses native OAuth and automatic + // auth; the Session's gateway adds any credential. this.servers[server.server_label] = { type: "http", url: server.server_url, alwaysLoad: true, - headers: { Authorization: reference ? `Bearer \${${reference}}` : "" } }; + headers: { Authorization: "" } }; } if (server.allowed_tools === null) this.allowed.push(prefix + "*"); else if (!server.allowed_tools.length) this.denied.push(prefix + "*"); @@ -144,9 +134,5 @@ export class MCPProfile { permits(name: string): boolean { return this.admitted && this.identities.has(name); } - credentialReferences(): string[] { - return this.declarations.flatMap(server => "bearer_token_env_var" in server && server.bearer_token_env_var ? [server.bearer_token_env_var] : []); - } - close(): void { this.admitted = false; this.release(false); } } diff --git a/packages/claude-sdk-adapter/src/mcp_environment.ts b/packages/claude-sdk-adapter/src/mcp_environment.ts index eb5387d22..615411bd8 100644 --- a/packages/claude-sdk-adapter/src/mcp_environment.ts +++ b/packages/claude-sdk-adapter/src/mcp_environment.ts @@ -1,17 +1,15 @@ -import { isAbsolute, normalize, parse } from "node:path"; +import { isAbsolute, normalize } from "node:path"; import { parseHTTPServers, type HTTPServer } from "./mcp.js"; export type StdioServer = { server_label: string; command: string; - // Absent for an agent-host view's alias, which runs without arguments. - args?: string[]; allowed_tools: null; }; export type EnvironmentMCPServer = HTTPServer | StdioServer; -// The Runtime launcher resolves installed package identities and their commands. -// In an agent-host view, the process broker resolves the alias instead. +// A stdio server's command is its agent-host view alias, which runs without +// arguments; the process broker resolves the installed command behind it. export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value)) throw new Error("invalid_request"); @@ -19,13 +17,9 @@ export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | un for (const server of value) { if (!server || typeof server !== "object" || labels.has(server.server_label)) throw new Error("invalid_request"); if ("command" in server) { - if (Object.keys(server).some(key => !["server_label", "command", "args", "allowed_tools"].includes(key)) || + if (Object.keys(server).some(key => !["server_label", "command", "allowed_tools"].includes(key)) || typeof server.server_label !== "string" || !server.server_label || server.allowed_tools !== null || - typeof server.command !== "string" || !isAbsolute(server.command) || normalize(server.command) !== server.command || /[\x00-\x1f\x7f]/.test(server.command) || - ("args" in server && (!Array.isArray(server.args) || server.args.length !== 4 || server.args[0] !== "runtime-mcp-exec" || - typeof server.args[1] !== "string" || !isAbsolute(server.args[1]) || normalize(server.args[1]) !== server.args[1] || server.args[1] === parse(server.args[1]).root || - typeof server.args[2] !== "string" || !server.args[2] || /[\\]/.test(server.args[2]) || server.args[2].split("/").some((part: string) => !part || part === "." || part === "..") || - server.args[3] !== server.server_label || server.args.some((part: string) => /[\x00-\x1f\x7f]/.test(part))))) throw new Error("invalid_request"); + typeof server.command !== "string" || !isAbsolute(server.command) || normalize(server.command) !== server.command || /[\x00-\x1f\x7f]/.test(server.command)) throw new Error("invalid_request"); labels.add(server.server_label); } else { diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 06d1bfa61..03c7589c0 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -7,7 +7,6 @@ import { realpathSync, statSync } from "node:fs"; import { isAbsolute, parse, resolve } from "node:path"; export type Workspace = { - tool_env?: Record; home: string; state: string; scratch: string; @@ -44,13 +43,12 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const config = value as Record; - if (Object.keys(config).some(key => !["tool_env", "home", "state", "scratch", "env_names", "network_access", "allowed_domains", "skills", "mcp", "capability_root"].includes(key)) || + if (Object.keys(config).some(key => !["home", "state", "scratch", "env_names", "network_access", "allowed_domains", "skills", "mcp", "capability_root"].includes(key)) || (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled" && config.network_access !== "restricted") || !Array.isArray(config.env_names) || config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || new Set(config.env_names).size !== config.env_names.length) throw new Error("invalid_request"); if (config.network_access !== undefined && config.network_access !== "enabled") throw new Error("invalid_request"); - if (config.tool_env !== undefined && (!config.tool_env || typeof config.tool_env !== "object" || Array.isArray(config.tool_env) || Object.values(config.tool_env).some(value => typeof value !== "string"))) throw new Error("invalid_request"); const mcp = parseEnvironmentMCP(config.mcp); if (config.capability_root !== undefined) directory(config.capability_root, false); if (Array.isArray(config.skills) && config.skills.length && !config.capability_root) throw new Error("invalid_request"); @@ -77,19 +75,11 @@ export class WorkspaceProfile { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", }; - for (const [name, value] of Object.entries(config.tool_env ?? {})) { - // Initialization cannot redirect the native Session history lookup. - if (!["HOME", "USERPROFILE", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_PROJECT_DIR_NAME"].includes(name.toUpperCase())) env[name] = value; - } for (const name of config.env_names) { const value = process.env[name]; if (value === undefined) throw new Error("invalid_request"); env[name] = value; } - for (const reference of mcp?.credentialReferences() ?? []) { - if (!process.env[reference]) throw new Error("invalid_request"); - env[reference] = process.env[reference]!; - } const skills = workspaceSkills(config.skills ?? [], config.capability_root ?? ""); this.skillNames = skills?.names ?? []; const skillTools = skills ? ["Skill"] : []; diff --git a/packages/claude-sdk-adapter/tests/mcp.test.mjs b/packages/claude-sdk-adapter/tests/mcp.test.mjs index 249077f8a..e48b6c940 100644 --- a/packages/claude-sdk-adapter/tests/mcp.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp.test.mjs @@ -30,7 +30,10 @@ test("invalid remote declarations fail at the bridge boundary", () => { for (const value of [null, {}, [{ ...declaration(null), server_label: "" }], [{ ...declaration(null), server_url: "https://user:secret@example.invalid/mcp" }], [{ ...declaration(null), server_url: "https://example.invalid/mcp?" }], - [{ ...declaration(null), required: "true" }], [{ ...declaration(null), required: null }], [declaration(null), declaration([])]]) { + [{ ...declaration(null), required: "true" }], [{ ...declaration(null), required: null }], [declaration(null), declaration([])], + // The Session's gateway adds credentials; none crosses the bridge. + [{ ...declaration(null), bearer_token: "secret" }], [{ ...declaration(null), headers: { Authorization: "Bearer secret" } }], + [{ ...declaration(null), bearer_token_env_var: "OAC_RUNTIME_MCP_BEARER_" + "A".repeat(26) }]]) { assert.throws(() => parseStart(JSON.stringify({ ...start, mcp_http_servers: value }))); } assert.deepEqual(parseStart(JSON.stringify({ ...start, mcp_http_servers: [declaration(null), { ...declaration([]), server_label: "empty" }] })).mcp_http_servers, diff --git a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs deleted file mode 100644 index 1426b1913..000000000 --- a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { parseStart } from "../dist/adapter.js"; -import { MCPProfile } from "../dist/mcp.js"; - -const reference = "OAC_RUNTIME_MCP_BEARER_" + "A".repeat(26); -const server = { server_label: "private", server_url: "https://example.invalid/mcp", allowed_tools: ["echo.v1"], bearer_token_env_var: reference }; -const start = servers => ({ type: "start", input: [{ content: [{ type: "input_text", text: "hello" }] }], model: "fixture", system_prompt: "", cwd: "/tmp", mcp_http_servers: servers }); - -test("bearer references remain literal in native configuration and private status is not retained", t => { - const token = "fixture-private-bearer+/=="; - process.env[reference] = token; - t.after(() => { delete process.env[reference]; }); - const declarations = [server, { server_label: "anonymous", server_url: "http://example.invalid/mcp", allowed_tools: [] }]; - const parsed = parseStart(JSON.stringify(start(declarations))); - const profile = new MCPProfile(parsed.mcp_http_servers, []); - assert.equal(profile.servers.private.headers.Authorization, "Bearer ${" + reference + "}"); - assert.equal(profile.servers.anonymous.headers.Authorization, ""); - assert.equal(JSON.stringify(profile.servers).includes(token), false); - profile.verify(["mcp__private__echo_v1"], [ - { name: "private", status: "connected", tools: [{ name: "echo.v1" }], config: { ...profile.servers.private, headers: { Authorization: "Bearer " + token } } }, - { name: "anonymous", status: "connected", tools: [] }, - ], "session"); - assert.deepEqual([...profile.identities.values()], [{ server: "private", name: "echo.v1" }]); - assert.equal(JSON.stringify(profile).includes(token), false); - assert.deepEqual(parsed.mcp_http_servers, declarations); -}); - -test("untrusted header expressions, raw tokens and non-HTTPS authenticated declarations are rejected", () => { - for (const invalid of [ - { ...server, bearer_token: "fixture-secret" }, - { ...server, headers: { Authorization: "Bearer fixture-secret" } }, - { ...server, server_url: "http://example.invalid/mcp" }, - ...[null, "", "ANTHROPIC_AUTH_TOKEN", "OAC_RUNTIME_MCP_BEARER_", "${OPERATOR_TOKEN}", reference + ":-fallback"].map(bearer_token_env_var => ({ ...server, bearer_token_env_var })), - ]) assert.throws(() => parseStart(JSON.stringify(start([invalid])))); - assert.throws(() => parseStart(JSON.stringify(start([server, { ...server, server_label: "other" }])))); -}); - -test("missing credential environment fails before the native query", () => { - const missing = "OAC_RUNTIME_MCP_BEARER_" + "B".repeat(26); - delete process.env[missing]; - assert.throws(() => new MCPProfile([{ ...server, bearer_token_env_var: missing }], []), /missing MCP credential environment/); -}); diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index 382e01a16..d9ea9058c 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -8,8 +8,8 @@ import { parseEnvironmentMCP } from "../dist/mcp_environment.js"; import { immediateInput, parseStart } from "../dist/request.js"; import { WorkspaceProfile } from "../dist/workspace.js"; -const stdio = { server_label: "installed", command: process.execPath, allowed_tools: null, - args: ["runtime-mcp-exec", join(process.cwd(), "capabilities"), "plugins/installed", "installed"] }; +// An agent-host view's alias, which runs without arguments. +const stdio = { server_label: "installed", command: "/.oac/bin/oac-mcp-0", allowed_tools: null }; const native = "mcp__installed__echo_v1"; const statuses = [{ name: "installed", status: "connected", tools: [{ name: "echo.v1" }] }]; const baseline = ["Bash", "Read", "Edit"]; @@ -28,26 +28,17 @@ function fixture(t, declarations = [stdio]) { return { dirs, config, request }; } -test("installed MCP projection uses the common Runtime launcher", t => { +test("installed MCP projection runs the view alias without arguments", t => { const { request } = fixture(t); assert.deepEqual(parseStart(JSON.stringify(request)), request); assert.equal(immediateInput(request), undefined); assert.deepEqual(parseEnvironmentMCP([stdio]), [stdio]); - // An agent-host view's alias runs without arguments. - const alias = { server_label: "installed", command: "/.oac/bin/oac-mcp-0", allowed_tools: null }; - assert.deepEqual(parseEnvironmentMCP([alias]), [alias]); - assert.deepEqual(new MCPProfile([alias], []).servers.installed.args, []); - for (const value of [[stdio, stdio], [{ ...stdio, command: "relative" }], [{ ...stdio, command: "/bin/line\n" }], [{ ...stdio, env: { TOKEN: "secret" } }], - [{ ...stdio, args: ["-c", "untrusted"] }], [{ ...stdio, allowed_tools: ["*"] }], - [{ ...stdio, server_url: "https://example.invalid" }], [{ ...stdio, args: [...stdio.args.slice(0, 2), "../escape", "installed"] }]]) { + assert.deepEqual(new MCPProfile([stdio], []).servers.installed.args, []); + for (const value of [[stdio, stdio], [{ ...stdio, command: "relative" }], [{ ...stdio, command: "/bin/../escape" }], [{ ...stdio, command: "/bin/line\n" }], + [{ ...stdio, env: { TOKEN: "secret" } }], [{ ...stdio, args: ["-c", "untrusted"] }], [{ ...stdio, allowed_tools: ["*"] }], + [{ ...stdio, server_url: "https://example.invalid" }]]) { assert.throws(() => parseEnvironmentMCP(value), /invalid_request/); } - for (const index of [1]) { - for (const invalid of ["/", "relative", "/tmp/../escape", "/tmp/line\n"]) { - const args = [...stdio.args]; args[index] = invalid; - assert.throws(() => parseEnvironmentMCP([{ ...stdio, args }]), /invalid_request/); - } - } assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...request.workspace, network_access: "disabled" } })), /invalid_request/); // HTTP MCP needs no installed Capabilities. const { capability_root: _, ...uninstalled } = request.workspace; @@ -98,32 +89,15 @@ test("combined inventory rejects extra servers, tools and normalized identity co } }); -test("workspace bearer references reach native HTTP without leaking values into the request", t => { - const reference = "OAC_RUNTIME_MCP_BEARER_ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - const http = { server_label: "remote", server_url: "https://example.invalid/mcp", allowed_tools: null, bearer_token_env_var: reference }; - const { dirs, config } = fixture(t, [http]); - process.env[reference] = "selected-user-token"; - process.env.OAC_RUNTIME_MCP_BEARER_UNSELECTED = "other-token"; - const mcp = new MCPProfile([http], []); - const workspace = new WorkspaceProfile(dirs.work, config, [], mcp); - assert.equal(workspace.options.env[reference], "selected-user-token"); - assert.equal(workspace.options.env.OAC_RUNTIME_MCP_BEARER_UNSELECTED, "other-token"); - assert.deepEqual(mcp.servers.remote.headers, { Authorization: `Bearer \${${reference}}` }); - assert.equal(JSON.stringify(mcp.servers).includes("selected-user-token"), false); - delete process.env[reference]; - assert.throws(() => new WorkspaceProfile(dirs.work, config, [], mcp), /invalid_request/); -}); - -test("MCP identity validation preserves host functions and ordinary child environment", async t => { +test("MCP identity validation preserves host functions", async t => { const { dirs, config } = fixture(t); const functions = ["mcp__functions__lookup"]; const mcp = new MCPProfile([stdio], functions); - const workspace = new WorkspaceProfile(dirs.work, { ...config, tool_env: { USER_VALUE: "initialized" } }, functions, mcp); + const workspace = new WorkspaceProfile(dirs.work, config, functions, mcp); workspace.verify([...baseline, native, ...functions], [...statuses, { name: "functions", status: "connected" }], "session"); const signal = new AbortController().signal; const input = { hook_event_name: "PreToolUse", session_id: "session", tool_use_id: "call", tool_name: "Bash", tool_input: { command: "printf ok" } }; assert.deepEqual(await workspace.beforeTool(input, "call", { signal }), {}); - assert.equal(workspace.options.env.USER_VALUE, "initialized"); assert.equal((await workspace.canUseTool(functions[0], {}, { signal })).behavior, "allow"); mcp.close(); }); diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index b6cddded7..1a829889f 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -181,18 +181,6 @@ test("workspace functions retain native sandbox and exact tool authority", async assert.equal((await profile.canUseTool("mcp__functions__lookup", input, options)).behavior, "deny"); }); -test("initialized environment is ordinary child environment on every layout", t => { - const {dirs,config}=fixture(t); - { - const profile=new WorkspaceProfile(dirs.workspace,{...config,tool_env:{USER_VALUE:"initialized",HOME:"/wrong",CLAUDE_CONFIG_DIR:"/wrong",home:"/wrong"}}); - assert.equal(profile.options.env.USER_VALUE,"initialized"); - assert.equal(profile.options.env.HOME,dirs.home); - assert.equal(profile.options.env.CLAUDE_CONFIG_DIR,dirs.state); - assert.equal(profile.options.env.home,undefined); - assert.deepEqual(profile.options.sandbox,{enabled:false}); - } -}); - test("host tools can access paths outside the workspace", async t => { const {dirs,config}=fixture(t); const profile=new WorkspaceProfile(dirs.workspace,config); diff --git a/packages/mcode-harness/README.md b/packages/mcode-harness/README.md index 2d1806094..6136bf8c9 100644 --- a/packages/mcode-harness/README.md +++ b/packages/mcode-harness/README.md @@ -1,6 +1,6 @@ # MiniMax Code workspace bridge -This companion package lets the daemon run MiniMax Code with OpenAgentCore's workspace. MiniMax Code keeps its own ACP Session, model loop and history. The package supplies a trusted MCP server (`bridge.mjs`), which the daemon registers as `oac_workspace` (its MCP server info names it `oac-workspace`). It exposes six native MiniMax Code tools rooted at the Session's workspace: `workspace_read`, `workspace_write`, `workspace_edit`, `workspace_bash`, `workspace_grep` and `workspace_glob`. The tools run as the daemon's user with ordinary permissions; the package adds no inner sandbox. The [MiniMax Code Runtime](../../services/core/deploy/mcode/README.md) guide owns the Runtime image, configuration and qualified deployment. +This companion package lets the agent host run MiniMax Code with OpenAgentCore's workspace. MiniMax Code keeps its own ACP Session, model loop and history. The package supplies a trusted MCP server (`bridge.mjs`), which the daemon registers as `oac_workspace` (its MCP server info names it `oac-workspace`). It exposes six native MiniMax Code tools rooted at the Session's workspace: `workspace_read`, `workspace_write`, `workspace_edit`, `workspace_bash`, `workspace_grep` and `workspace_glob`. The bridge and its tools run beside the CLI in the Session's agent-host view, where Bash, `rg` and `git` run in the sandbox; the package adds no inner sandbox. The [MiniMax Code Runtime](../../services/core/deploy/mcode/README.md) guide owns the Runtime image, configuration and qualified deployment. One patch script (`patch-native.mjs`) makes four edits to the pinned native CLI source. The SQLite task-admission transaction enforces the daemon's Subagent concurrency limit before child work starts; foreground, background, nested and idle-child append admissions share that transaction, and terminal native tasks release capacity. ACP initialization reports `oac/subagents` metadata: its version, the applied workspace tool policy and the admission limit. The native tool catalog applies the `protected-mcp-v1` tool gate described under [Subagents and cancellation](#subagents-and-cancellation). Under the same policy, the CLI ignores the workspace's project `.mcp.json`, so the Session's MCP comes only from the daemon. No second model or scheduling loop is introduced. Hosted public execution is not qualified by this package alone. @@ -8,7 +8,7 @@ One patch script (`patch-native.mjs`) makes four edits to the pinned native CLI The native process, its ACP Session and the workspace tools share the Session's workspace as their working directory; native configuration, Skills and history stay in the private Session data directory. Builtin file tools are disabled, so project files are read and written through the bridge's tools. Only the adapter registers this bridge; callers cannot supply its command, profile, working directory or environment. Native diff/undo capture is not provided by this path. Common Files and Artifacts use the same bound workspace. -For each tool call, the bridge starts `launch.mjs` with the Session's private profile (`workspace-profile.json`, written by the daemon). The launcher checks that the profile's `workspace` is a canonical absolute path and that `network` is `enabled`, creates the `scratch` directory, and runs the worker in the workspace. An optional `toolEnvFile` supplies the Runtime's frozen tool environment, read only at launch. A present `capabilityRoot` must be a canonical absolute path, and `skills` requires one. A mismatched or invalid profile rejects the call. +For each tool call, the bridge starts `launch.mjs` with the Session's private profile (`workspace-profile.json`, written by the daemon). The launcher checks that the profile's `workspace` is a canonical absolute path, creates the `scratch` directory, and runs the worker in the workspace with the bridge's environment. An invalid profile rejects the call. ## Build diff --git a/packages/mcode-harness/cancellation.test.mjs b/packages/mcode-harness/cancellation.test.mjs index 5910df622..13b0181a8 100644 --- a/packages/mcode-harness/cancellation.test.mjs +++ b/packages/mcode-harness/cancellation.test.mjs @@ -32,7 +32,7 @@ for (const operation of ['cancel', 'close']) { setInterval(()=>appendFileSync(name+'.ticks','tick\\n'),20); `); const profile = join(root, 'profile.json'); - await writeFile(profile, JSON.stringify({ workspace: root, scratch, network: 'enabled' })); + await writeFile(profile, JSON.stringify({ workspace: root, scratch })); const { ToolExecutor } = await import(pathToFileURL(join(artifact, 'tool-executor.mjs'))); const executor = new ToolExecutor(profile); t.after(() => executor.close()); diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs index 3400808ff..4d3906f12 100644 --- a/packages/mcode-harness/launch.mjs +++ b/packages/mcode-harness/launch.mjs @@ -1,37 +1,10 @@ import { spawn } from 'node:child_process'; import { readFileSync, mkdirSync } from 'node:fs'; -import { isIP } from 'node:net'; import { dirname, join, isAbsolute, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const profile = JSON.parse(readFileSync(process.argv[2], 'utf8')); -if (!isAbsolute(profile.workspace) || normalize(profile.workspace) !== profile.workspace || (process.argv[3] && profile.workspace !== process.argv[3])) - throw new Error('Workspace profile does not match execution binding'); -if (profile.network !== 'enabled') throw new Error('Invalid network policy'); -const domains=profile.allowedDomains??[]; -const hostname=/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; -if (!Array.isArray(domains) || (profile.network==='restricted' - ? domains.length<1 || domains.length>100 || domains.some(host=>typeof host!=='string'||!hostname.test(host)||host.trim()!==host||isIP(host)!==0) - : domains.length!==0)) throw new Error('Invalid network domains'); -const baseEnv = {...process.env}; -if (Object.hasOwn(profile, 'toolEnv')) throw new Error('Inline tool environment is unsupported'); -if (profile.toolEnvFile !== undefined) { - // Resolve values only at tool launch, from the Runtime-owned frozen snapshot. - // Never include parser diagnostics: malformed input can contain credentials. - let values; - try { - const path = profile.toolEnvFile; - if (typeof path !== 'string' || !isAbsolute(path) || normalize(path) !== path) throw new Error(); - const raw = readFileSync(path); - if (raw.length > 1024 * 1024) throw new Error(); - values = JSON.parse(raw.toString('utf8')); - if (!values || typeof values !== 'object' || Array.isArray(values) || - Object.entries(values).some(([key, value]) => !key || /[=\x00\r\n]/.test(key) || typeof value !== 'string' || value.includes('\0'))) throw new Error(); - } catch { throw new Error('Runtime tool environment unavailable'); } - Object.assign(baseEnv, values); -} -if (profile.capabilityRoot && (typeof profile.capabilityRoot !== 'string' || !isAbsolute(profile.capabilityRoot) || normalize(profile.capabilityRoot) !== profile.capabilityRoot || profile.capabilityRoot === '/' || /[\\\x00-\x1f\x7f]/.test(profile.capabilityRoot))) throw new Error('Invalid capability root'); -if (profile.skills && !profile.capabilityRoot) throw new Error('Missing capability root'); +if (!isAbsolute(profile.workspace) || normalize(profile.workspace) !== profile.workspace) throw new Error('Invalid workspace profile'); let child; let cancelled=false; const cancel=()=>{cancelled=true;child?.kill('SIGTERM');}; @@ -39,7 +12,7 @@ process.on('SIGTERM',cancel);process.on('SIGINT',cancel); try { mkdirSync(profile.scratch,{recursive:true}); if (cancelled) throw new Error('Cancelled before workspace tool start'); - child=spawn(process.execPath,[join(here,'dist/worker.mjs'),profile.workspace],{cwd:profile.workspace,env:baseEnv,stdio:['pipe','pipe','pipe']}); + child=spawn(process.execPath,[join(here,'dist/worker.mjs'),profile.workspace],{cwd:profile.workspace,env:process.env,stdio:['pipe','pipe','pipe']}); process.stdin.pipe(child.stdin);child.stdout.pipe(process.stdout);child.stderr.pipe(process.stderr); child.stdin.on('error',()=>cancel()); const status=await new Promise((resolve,reject)=>{child.on('error',reject);child.on('close',resolve);}); diff --git a/packages/mcode-harness/launch.test.mjs b/packages/mcode-harness/launch.test.mjs index a2aa3859c..754132398 100644 --- a/packages/mcode-harness/launch.test.mjs +++ b/packages/mcode-harness/launch.test.mjs @@ -1,28 +1,28 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import {mkdtemp,mkdir,writeFile,copyFile,rm,realpath} from 'node:fs/promises'; +import {mkdtemp,mkdir,writeFile,copyFile,rm,realpath,stat} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {ToolExecutor} from './tool-executor.mjs'; -test('Runtime directly executes host worker with bound cwd and initialized environment',async t=>{ +test('Runtime directly executes host worker with bound cwd and the bridge environment',async t=>{ const root=await realpath(await mkdtemp(join(tmpdir(),'mcode-host-'))); t.after(()=>rm(root,{recursive:true,force:true})); await mkdir(join(root,'dist')); const workspace=join(root,'workspace');await mkdir(workspace); const launcher=join(root,'launch.mjs');await copyFile(new URL('./launch.mjs',import.meta.url),launcher); - await writeFile(join(root,'dist','worker.mjs'),`import {readFileSync} from 'node:fs';const request=JSON.parse(readFileSync(0,'utf8'));console.log(JSON.stringify({tool_name:request.tool,text:JSON.stringify({cwd:process.cwd(),value:process.env.INITIALIZED}),content:[]}));`); + await writeFile(join(root,'dist','worker.mjs'),`import {readFileSync} from 'node:fs';const request=JSON.parse(readFileSync(0,'utf8'));console.log(JSON.stringify({tool_name:request.tool,text:JSON.stringify({cwd:process.cwd(),value:process.env.OAC_TEST_BRIDGE_ENV}),content:[]}));`); const profile=join(root,'profile.json'); - const environment=join(root,'runtime-env.json'); - await writeFile(environment,JSON.stringify({INITIALIZED:'ordinary'}),{mode:0o600}); - await writeFile(profile,JSON.stringify({workspace,scratch:join(root,'scratch'),network:'enabled',toolEnvFile:environment})); + const scratch=join(root,'scratch'); + await writeFile(profile,JSON.stringify({workspace,scratch})); + process.env.OAC_TEST_BRIDGE_ENV='ordinary'; + t.after(()=>{delete process.env.OAC_TEST_BRIDGE_ENV;}); const executor=new ToolExecutor(profile,launcher);t.after(()=>executor.close()); const result=await executor.execute('bash',{}); assert.deepEqual(JSON.parse(result.text),{cwd:workspace,value:'ordinary'}); - for (const body of ['{\"SECRET\":\"fixture-canary\",', '[]', '{\"KEY\":123}']) { - await writeFile(environment,body); + assert.ok((await stat(scratch)).isDirectory()); + for (const invalid of ['workspace', workspace+'/../workspace']) { + await writeFile(profile,JSON.stringify({workspace:invalid,scratch})); await assert.rejects(executor.execute('bash',{})); } - await rm(environment); - await assert.rejects(executor.execute('bash',{})); }); diff --git a/packages/mcode-harness/snapshot.test.mjs b/packages/mcode-harness/snapshot.test.mjs deleted file mode 100644 index e5463f13d..000000000 --- a/packages/mcode-harness/snapshot.test.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { copyFile, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { spawnSync } from 'node:child_process'; - -// This opt-in test requires an isolated Linux Runtime containing the built -// sandbox/worker artifact. It runs only native tools; no model is contacted. -const artifact = process.env.OAC_TEST_MCODE_NATIVE_ARTIFACT; -const isolated = process.env.OAC_TEST_MCODE_SNAPSHOT_ISOLATED === '1'; -test('capability read exceptions preserve denied ancestors and immutable snapshots', { - skip: !artifact || !isolated, timeout: 60_000, -}, async t => { - const fixture = await mkdtemp(join(tmpdir(), 'oac-mcode-snapshot-')); - t.after(() => rm(fixture, { recursive: true, force: true })); - await copyFile(new URL('./launch.mjs', import.meta.url), join(fixture, 'launch.mjs')); - await symlink(join(artifact, 'dist'), join(fixture, 'dist')); - const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"; - for (const [name, ancestor, root] of [ - ['standard', '/environment', '/environment/initialization/capabilities'], - ['custom', join(fixture, 'private-layout'), join(fixture, 'private-layout', 'frozen', 'skills')], - ]) { - await t.test(name, async t => { - const scratch = join(fixture, name + '-scratch'); - const secret = join(ancestor, 'private', 'snapshot-test-secret'); - await mkdir(root, { recursive: true }); - await mkdir(join(ancestor, 'private'), { recursive: true }); - await mkdir(scratch); - const script = join(root, 'proof.sh'), attachment = join(root, 'attachment.txt'); - await writeFile(script, 'printf SNAPSHOT_SCRIPT_READABLE', { mode: 0o700 }); - await writeFile(attachment, 'SNAPSHOT_ATTACHMENT_READABLE', { mode: 0o600 }); - await writeFile(secret, 'PRIVATE_SNAPSHOT_CANARY', { mode: 0o600 }); - const profile = join(fixture, name + '.json'); - const config = { workspace: '/workspace', scratch, network: 'disabled', - allowedDomains: [], protectedDirs: [ancestor], capabilityRoot: root, skills: true }; - await writeFile(profile, JSON.stringify(config)); - const executor = { execute(tool, input) { - const child = spawnSync(process.execPath, [join(fixture, 'launch.mjs'), profile, '/workspace'], { - input: JSON.stringify({ tool, input }), encoding: 'utf8', timeout: 15_000, - env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', LANG: 'C.UTF-8' }, - }); - assert.ok(child.stdout, child.stderr || String(child.error)); - return JSON.parse(child.stdout); - } }; - const read = await executor.execute('bash', { - command: 'sh ' + quote(script) + '; cat ' + quote(attachment), - }); - assert.notEqual(read.isError, true, read.text); - assert.ok(read.text.includes('SNAPSHOT_SCRIPT_READABLE')); - assert.ok(read.text.includes('SNAPSHOT_ATTACHMENT_READABLE')); - const protectedResult = await executor.execute('bash', { - command: [ - 'if printf changed >> ' + quote(attachment) + '; then exit 41; fi', - 'if chmod 0777 ' + quote(script) + '; then exit 42; fi', - 'if touch ' + quote(join(root, 'new-sibling')) + '; then exit 43; fi', - 'if cat ' + quote(secret) + '; then exit 44; fi', - 'printf SNAPSHOT_PROTECTION_OK', - ].join('; '), - }); - assert.notEqual(protectedResult.isError, true); - assert.ok(protectedResult.text.includes('SNAPSHOT_PROTECTION_OK')); - assert.ok(!protectedResult.text.includes('PRIVATE_SNAPSHOT_CANARY')); - assert.equal(await readFile(attachment, 'utf8'), 'SNAPSHOT_ATTACHMENT_READABLE'); - assert.equal((await stat(script)).mode & 0o777, 0o700); - await assert.rejects(stat(join(root, 'new-sibling')), { code: 'ENOENT' }); - // Negative control proves that the ancestor deny really masks this root. - await writeFile(profile, JSON.stringify({ ...config, capabilityRoot: undefined, skills: false })); - const hidden = await executor.execute('bash', { command: 'cat ' + quote(attachment) }); - assert.equal(hidden.isError, true); - assert.ok(!hidden.text.includes('SNAPSHOT_ATTACHMENT_READABLE')); - }); - } -}); diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 190bbbf22..3e0489a7d 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -124,11 +124,6 @@ "regex": "\"agents-api-(?:session)?\"", "reason": "Dispatch derives each Session's native state key in the existing agents-api- namespace; this change does not introduce an alternate identity or compatibility path." }, - { - "path": "apps/daemon/internal/agent/mcode/tool_environment_test.go", - "regex": "agents-api-", - "reason": "The regression fixture uses the existing persisted Session state-key namespace required by Runtime binding validation." - }, { "path": "services/core/migrations/000015_retire_item_backfill.sql", "regex": "services/agents-api/README\\.md", From 37fdb0c4e93f8b66a90e508d1198cc99b24c60c3 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 12:17:09 +0000 Subject: [PATCH 3/3] Finish the guest Runtime removal --- AGENTS.md | 1 - contracts/agents-api/harness-onboarding.md | 16 +- contracts/agents-api/model-execution.md | 2 +- contracts/agents-api/zh/harness-onboarding.md | 18 +- contracts/agents-api/zh/model-execution.md | 4 +- docs.json | 1 - docs/development.md | 1 - docs/runtime-bootstrap.md | 36 --- docs/zh/development.md | 3 +- docs/zh/runtime-bootstrap.md | 38 --- internal/runtimebootstrap/bootstrap.go | 100 ------ internal/runtimebootstrap/bootstrap_test.go | 60 ---- scripts/build-core.sh | 2 +- .../mcp_bearer_fixture_linux_test.go | 151 --------- .../mcp_bearer_live_linux_test.go | 280 ----------------- .../mcp_bearer_process_linux_test.go | 296 ------------------ .../function_execution_native_test.go | 82 ----- .../function_images_native_test.go | 96 ------ .../tests/integration/function_model_test.go | 121 ------- .../function_public_native_test.go | 85 ----- .../function_stream_native_test.go | 53 ---- .../integration/mcode_public_native_test.go | 175 ----------- .../integration/message_images_native_test.go | 104 ------ .../integration/model_protocol_native_test.go | 150 --------- .../tests/integration/native_daemon_test.go | 184 ----------- .../integration/native_environment_test.go | 141 --------- .../native_public_execution_test.go | 53 ---- .../structured_output_native_test.go | 96 ------ .../integration/tool_policy_native_test.go | 118 ------- .../integration/tool_search_native_test.go | 96 ------ services/core/tests/official_execution.py | 205 ------------ .../core/tests/official_function_images.py | 148 --------- .../core/tests/official_function_stream.py | 100 ------ services/core/tests/official_functions.py | 84 ----- services/core/tests/official_mcode_native.py | 102 ------ .../core/tests/official_message_images.py | 163 ---------- .../tests/official_model_protocol_native.py | 223 ------------- .../core/tests/official_structured_output.py | 151 --------- services/core/tests/official_tool_policy.py | 177 ----------- services/core/tests/official_tool_search.py | 149 --------- 40 files changed, 12 insertions(+), 4053 deletions(-) delete mode 100644 docs/runtime-bootstrap.md delete mode 100644 docs/zh/runtime-bootstrap.md delete mode 100644 internal/runtimebootstrap/bootstrap.go delete mode 100644 internal/runtimebootstrap/bootstrap_test.go delete mode 100644 services/core/internal/runtimegateway/mcp_bearer_fixture_linux_test.go delete mode 100644 services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go delete mode 100644 services/core/internal/runtimegateway/mcp_bearer_process_linux_test.go delete mode 100644 services/core/tests/integration/function_execution_native_test.go delete mode 100644 services/core/tests/integration/function_images_native_test.go delete mode 100644 services/core/tests/integration/function_model_test.go delete mode 100644 services/core/tests/integration/function_public_native_test.go delete mode 100644 services/core/tests/integration/function_stream_native_test.go delete mode 100644 services/core/tests/integration/mcode_public_native_test.go delete mode 100644 services/core/tests/integration/message_images_native_test.go delete mode 100644 services/core/tests/integration/model_protocol_native_test.go delete mode 100644 services/core/tests/integration/native_daemon_test.go delete mode 100644 services/core/tests/integration/native_environment_test.go delete mode 100644 services/core/tests/integration/native_public_execution_test.go delete mode 100644 services/core/tests/integration/structured_output_native_test.go delete mode 100644 services/core/tests/integration/tool_policy_native_test.go delete mode 100644 services/core/tests/integration/tool_search_native_test.go delete mode 100644 services/core/tests/official_execution.py delete mode 100644 services/core/tests/official_function_images.py delete mode 100644 services/core/tests/official_function_stream.py delete mode 100644 services/core/tests/official_functions.py delete mode 100644 services/core/tests/official_mcode_native.py delete mode 100644 services/core/tests/official_message_images.py delete mode 100644 services/core/tests/official_model_protocol_native.py delete mode 100644 services/core/tests/official_structured_output.py delete mode 100644 services/core/tests/official_tool_policy.py delete mode 100644 services/core/tests/official_tool_search.py diff --git a/AGENTS.md b/AGENTS.md index e91a1f448..1a60fde62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,6 @@ Do not multiply entities without necessity. The long-term goal is minimal code, | Nodes and daemons–Core (`/api/v1` HTTP routes; the node and daemon wire protocols are separate rows) | Route annotations in `services/core/internal/api/`; `make openapi` generates `contracts/agents-api/runtime.openapi.yaml` | [Machine connection API](contracts/agents-api/machine-api.md) | | Core–Sandbox Provider | `services/core/internal/sandbox/sandbox_provider.go` | [Sandbox Provider guide](docs/sandbox-provider.md) | | Core–sandbox node | `services/core/internal/sandbox/node/wire.go` | [Sandbox node protocol](contracts/agents-api/node-generation-protocol.md) | -| Provider–Runtime startup | `internal/runtimebootstrap/bootstrap.go` | [Runtime bootstrap](docs/runtime-bootstrap.md) | | Runtime and Sandbox I/O service–relay (Link) | `internal/sandboxlink/protocol.go` | [Sandbox link protocol](docs/sandbox-link-protocol.md) | | Provider–Sandbox I/O startup | `internal/sandboxbootstrap/bootstrap.go` | [Sandbox bootstrap](docs/sandbox-bootstrap.md) | | Runtime–file service | `internal/sandboxfs/protocol.go` | [File access protocol](docs/file-access-protocol.md) | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index eb0963e62..c2e982225 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -189,22 +189,12 @@ The declaration's ordered `protocols` list is the only source of accepted protoc Before starting, record the operation set, expected results, exclusions and stopping conditions. A qualification ends when its declared operations pass; it does not expand to match another Harness's feature list. -1. **Contract tests.** Call `agent/contracttest.TextLifecycle` from a test named `TestSharedTextLifecycle` with the adapter's prepared Executor and a deterministic native fixture; `claudesdk/executor_test.go` is the reference. It checks independent Turn streams, native owner and history continuity, durable write and application receipts, stale cancellation and healthy continuation after cancellation. `make check-runtime-contract` runs it together with the shared wire, gateway, transport and dispatcher tests, the declaration completeness check and each adapter's `TestUnsupportedExtensionsHaveNoNativeEffects`. Adapter tests also cover two ordinary Turns sharing one native process or connection and history, cancellation followed by another Turn, stale cancellation and late events, native exit, cleanup failure, input write and application receipts, unknown outcomes and fresh per-Turn usage, function, input and child-observation state. State whether a fixture is controlled or a real provider. +1. **Contract tests.** Call `agent/contracttest.TextLifecycle` from a test named `TestSharedTextLifecycle` with the adapter's prepared Executor and a deterministic native fixture; `claudesdk/executor_test.go` is the reference. It checks independent Turn streams, native owner and history continuity, durable write and application receipts, stale cancellation and healthy continuation after cancellation. `make check-runtime-contract` runs it together with the shared wire, gateway, transport and dispatcher tests, the declaration completeness check and MiniMax Code's `TestUnsupportedExtensionsHaveNoNativeEffects`. Adapter tests also cover two ordinary Turns sharing one native process or connection and history, cancellation followed by another Turn, stale cancellation and late events, native exit, cleanup failure, input write and application receipts, unknown outcomes and fresh per-Turn usage, function, input and child-observation state. State whether a fixture is controlled or a real provider. 2. **Shared integration.** `TestThirdHarnessPublicOnboarding` runs the synthetic Harness through public Session and input admission, Worker device selection, the real WebSocket gateway, the daemon Registry and Router, neutral events and durable terminal projection. It registers under the `mcode` kind, so Core admits it against MiniMax Code's declaration, and checks applied input receipts, saved native identity, continuation, cancellation, unsupported optional requests and missing mandatory Runtime support. The fixture has no workspace, MCP or public functions, and its registration stays local to the test. It proves the integration path, not native execution. -3. **Real acceptance.** Use the pinned official Python SDK and raw HTTP against Core, a real provider API, the native Harness and a dedicated database. Verify initial execution, a warm follow-up, cancellation and restart with continuation; record native owner identity and same-condition cold and warm timing. For workspace placements also verify Files and Artifacts, workspace identity, that no credentials appear in public responses and that foreign history is rejected. `services/core/tests/official_hosted_functions_native.py` holds the shared function assertions: success and error, native file output and public Artifact bytes, same-history continuation after restart, foreign result rejection and pending-call cancellation. Synthetic or failed runs never count. The opt-in tests below run the pinned-SDK fixtures in `services/core/tests` against a real daemon and model; each runs when `OAC_TEST_OFFICIAL_SDK_PYTHON`, `OAC_TEST_NATIVE_DAEMON_BIN`, `OAC_TEST_NATIVE_PROOF_DIR` and its private options file are set. The options file is a JSON object with exactly `model` and `model_provider` (the fields of `x_agents_core.model_provider`); the test sets it as the deployment default model provider, which the fixtures' `environment: none` Sessions freeze at creation. +3. **Real acceptance.** Use the pinned official Python SDK and raw HTTP against Core, a real provider API, the native Harness and a dedicated database. Verify initial execution, a warm follow-up, cancellation and restart with continuation; record native owner identity and same-condition cold and warm timing. For workspace placements also verify Files and Artifacts, workspace identity, that no credentials appear in public responses and that foreign history is rejected. `services/core/tests/official_hosted_functions_native.py` holds the shared function assertions: success and error, native file output and public Artifact bytes, same-history continuation after restart, foreign result rejection and pending-call cancellation. Synthetic or failed runs never count. Use the agent-host tests in [Qualify the view](#qualify-the-view) for native workspace and capability acceptance. Real-model public API acceptance must also cover model provider protocols, MiniMax Code text, message images, function results with images, structured output, deferred function discovery, disabled web search and programmatic tool calling; passing view tests alone does not qualify those public API behaviors. 4. **Regression.** Existing Harnesses keep working. Run targeted tests, then `make check`; run `make openapi` after API changes and `make sqlc-generate` after query changes. 5. **Review.** Follow the [blind review workflow](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#review). -| Operation | Test in `services/core/tests/integration` | Options file variable, and Harness variable where the test takes one | -| --- | --- | --- | -| Model provider protocols | `TestNativeModelProtocolPublicExecution` | [Model execution](./model-execution.md#acceptance) | -| MiniMax Code text | `TestNativeMCodePublicExecution` | `OAC_TEST_MCODE_REAL_OPTIONS` | -| Message images | `TestNativeMessageImagePublicExecution` | `OAC_TEST_MESSAGE_IMAGE_REAL_OPTIONS`, `OAC_TEST_MESSAGE_IMAGE_ENGINE` | -| Function results with images | `TestNativeFunctionImagePublicExecution` | `OAC_TEST_FUNCTION_IMAGE_REAL_OPTIONS`, `OAC_TEST_FUNCTION_IMAGE_ENGINE` | -| Structured output | `TestNativeStructuredOutputPublicExecution` | `OAC_TEST_STRUCTURED_OUTPUT_REAL_OPTIONS` | -| Deferred function discovery | `TestNativeToolSearchPublicExecution` | `OAC_TEST_TOOL_SEARCH_REAL_OPTIONS` | -| Disabled web search and programmatic tool calling | `TestNativeToolPolicyPublicExecution` | `OAC_TEST_TOOL_POLICY_REAL_OPTIONS`, `OAC_TEST_TOOL_POLICY_ENGINE` | - Environment acceptance uses `services/core/tests/official_environment_{templates,setup,skills,plugins,plugin_mcp,composition,initial_files,network,skill_references}.py`. For composed preparation, change the Skill default and Template, delete the sources, retry and restart; verify frozen bytes, one setup execution and MCP cancellation. `official_hosted_structured_native.py` covers hosted structured output. Record exact source revisions, native versions and commands with each acceptance result. Keep provider keys in private operator files, never in commits or logs. Existing focused tests, relative to `apps/daemon/internal/agent`: @@ -216,7 +206,7 @@ Keep provider keys in private operator files, never in commits or logs. Existing | Claude input ownership, cancellation and preparation cleanup | `claudesdk/executor_test.go`, `cancellation_test.go`, `preparation_test.go` | | MiniMax cancellation retirement, failed Start and cleanup retry | `mcode/executor_test.go`, `executor_backpressure_test.go` | | MiniMax native history binding | `mcode/session_test.go` | -| Explicit refusals without native effects or fabricated results | Each adapter's `unsupported_test.go` | +| Explicit refusals without native effects or fabricated results | `mcode/unsupported_test.go` | ## Native installer participation diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index e34e3bc4a..7875f6754 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -145,4 +145,4 @@ These are best-effort Core receipt times after the terminal commit, not provider ## Acceptance -`TestNativeModelProtocolPublicExecution` (`services/core/tests/integration/model_protocol_native_test.go`) with `services/core/tests/official_model_protocol_native.py` runs each Harness against real provider APIs through the pinned official client. It runs when `OAC_TEST_OFFICIAL_SDK_PYTHON`, `OAC_TEST_NATIVE_DAEMON_BIN`, `OAC_TEST_NATIVE_PROOF_DIR` and `OAC_TEST_MODEL_PROTOCOL_OPTIONS` are set; the last names a private model settings file. Never commit those settings or print their values. +Qualify each protocol declared by a Harness through the pinned official client, Core, the agent host and a real provider API. Cover initial execution, warm continuation, function results, cancellation and history continuity after restart. Record exact revisions, native versions and commands; adapter or view tests alone do not qualify public API protocol acceptance. Keep model settings in private operator files and never commit or print them. diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 614ac4319..3fdd1c863 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: 38e609d8e8154d6f3c99c03466160c1ed30a70ea65434f8c999e98478c50b256 +source_hash: 45f460051d8c8f87045d0146f44f86fa9d3379974000870856eac7705f33347c --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、支持声明和验收。 @@ -191,22 +191,12 @@ Core 会识别[内置 Harness 注册项](harness-catalog.md)。向 `internal/har 开始前,记录操作集、预期结果、排除项和停止条件。当其声明的操作通过时,资格认定即结束;它不会扩展为匹配另一个 Harness 的功能列表。 -1. **契约测试。** 在名为 `TestSharedTextLifecycle` 的测试中,使用适配器准备好的 Executor 和确定性的原生夹具调用 `agent/contracttest.TextLifecycle`;`claudesdk/executor_test.go` 是参考实现。它检查独立的 Turn 流、原生所有者和历史连续性、持久化写入与应用回执、过期取消,以及取消后的健康继续执行。`make check-runtime-contract` 会将它与共享线协议、gateway、传输层和调度器测试、声明完整性检查以及每个适配器的 `TestUnsupportedExtensionsHaveNoNativeEffects` 一起运行。适配器测试还覆盖两个普通 Turn 共享一个原生进程或连接和历史、取消后执行另一个 Turn、过期取消和迟到事件、原生退出、清理失败、输入写入与应用回执、未知结果,以及每 Turn 新鲜的 usage、函数、输入和子项观察状态。必须说明夹具是受控夹具还是真实 Provider。 +1. **契约测试。** 在名为 `TestSharedTextLifecycle` 的测试中,使用适配器准备好的 Executor 和确定性的原生夹具调用 `agent/contracttest.TextLifecycle`;`claudesdk/executor_test.go` 是参考实现。它检查独立的 Turn 流、原生所有者和历史连续性、持久化写入与应用回执、过期取消,以及取消后的健康继续执行。`make check-runtime-contract` 会将它与共享线协议、gateway、传输层和调度器测试、声明完整性检查以及MiniMax Code 的 `TestUnsupportedExtensionsHaveNoNativeEffects` 一起运行。适配器测试还覆盖两个普通 Turn 共享一个原生进程或连接和历史、取消后执行另一个 Turn、过期取消和迟到事件、原生退出、清理失败、输入写入与应用回执、未知结果,以及每 Turn 新鲜的 usage、函数、输入和子项观察状态。必须说明夹具是受控夹具还是真实 Provider。 2. **共享集成。** `TestThirdHarnessPublicOnboarding` 让合成 Harness 通过公共 Session 和输入准入、Worker 设备选择、真实 WebSocket gateway、daemon Registry 和 Router、中立事件以及持久化终态投影运行。它以 `mcode` kind 注册,因此 Core 按 MiniMax Code 的声明准入它,并检查已应用输入回执、已保存原生身份、继续执行、取消、不受支持的可选请求以及缺少强制 Runtime 支持。该夹具没有工作区、MCP 或公共函数,其注册仅保留在测试本地。它证明的是集成路径,而不是原生执行。 -3. **真实验收。** 使用锁定的官方 Python SDK 和针对 Core 的原始 HTTP、真实 Provider API、原生 Harness 以及专用数据库。验证初始执行、热后续执行、取消以及带继续执行的重启;记录原生所有者身份以及相同条件下的冷启动和热运行时间。对于工作区放置方式,还要验证 Files 和 Artifacts、工作区身份、公开响应中未出现凭据,以及外部历史会被拒绝。`services/core/tests/official_hosted_functions_native.py` 保存共享函数断言:成功和错误、原生文件输出和公共 Artifact 字节、重启后的同历史继续执行、外部结果拒绝以及待处理调用取消。合成运行或失败运行绝不计入。下面的选择性测试会在 `services/core/tests` 中针对真实 daemon 和模型运行锁定 SDK 夹具;设置 `OAC_TEST_OFFICIAL_SDK_PYTHON`、`OAC_TEST_NATIVE_DAEMON_BIN`、`OAC_TEST_NATIVE_PROOF_DIR` 及其私有选项文件后,每项测试才会运行。选项文件是一个 JSON 对象,恰好包含 `model` 和 `model_provider`(即 `x_agents_core.model_provider` 的字段);测试会将其设置为部署默认模型 Provider,而夹具的 `environment: none` Session 会在创建时将其冻结。 +3. **真实验收。** 使用锁定的官方 Python SDK 和针对 Core 的原始 HTTP、真实 Provider API、原生 Harness 以及专用数据库。验证初始执行、热后续执行、取消以及带继续执行的重启;记录原生所有者身份以及相同条件下的冷启动和热运行时间。对于工作区放置方式,还要验证 Files 和 Artifacts、工作区身份、公开响应中未出现凭据,以及外部历史会被拒绝。`services/core/tests/official_hosted_functions_native.py` 保存共享函数断言:成功和错误、原生文件输出和公共 Artifact 字节、重启后的同历史继续执行、外部结果拒绝以及待处理调用取消。合成运行或失败运行绝不计入。原生 Harness 的工作区与能力验收使用 [Qualify the view](#qualify-the-view) 中的 agent-host 测试。公共 API 的真实模型验收仍须覆盖模型 Provider 协议、MiniMax Code 文本、消息图像、带图像的函数结果、结构化输出、延迟函数发现、禁用 Web 搜索以及程序化工具调用;仅通过 view 测试不能证明这些公共 API 行为。 4. **回归。** 现有 Harness 必须继续正常工作。先运行定向测试,然后运行 `make check`;API 更改后运行 `make openapi`,查询更改后运行 `make sqlc-generate`。 5. **审查。** 遵循 [blind review workflow](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#review)。 -| 操作 | `services/core/tests/integration` 中的测试 | 选项文件变量;测试采用 Harness 变量时也列出该变量 | -| --- | --- | --- | -| 模型 Provider 协议 | `TestNativeModelProtocolPublicExecution` | [Model execution](model-execution.md#acceptance) | -| MiniMax Code 文本 | `TestNativeMCodePublicExecution` | `OAC_TEST_MCODE_REAL_OPTIONS` | -| 消息图像 | `TestNativeMessageImagePublicExecution` | `OAC_TEST_MESSAGE_IMAGE_REAL_OPTIONS`、`OAC_TEST_MESSAGE_IMAGE_ENGINE` | -| 带图像的函数结果 | `TestNativeFunctionImagePublicExecution` | `OAC_TEST_FUNCTION_IMAGE_REAL_OPTIONS`、`OAC_TEST_FUNCTION_IMAGE_ENGINE` | -| 结构化输出 | `TestNativeStructuredOutputPublicExecution` | `OAC_TEST_STRUCTURED_OUTPUT_REAL_OPTIONS` | -| 延迟函数发现 | `TestNativeToolSearchPublicExecution` | `OAC_TEST_TOOL_SEARCH_REAL_OPTIONS` | -| 禁用 Web 搜索和程序化工具调用 | `TestNativeToolPolicyPublicExecution` | `OAC_TEST_TOOL_POLICY_REAL_OPTIONS`、`OAC_TEST_TOOL_POLICY_ENGINE` | - Environment 验收使用 `services/core/tests/official_environment_{templates,setup,skills,plugins,plugin_mcp,composition,initial_files,network,skill_references}.py`。对于组合式准备,请更改 Skill 默认值和 Template,删除源文件,重试并重启;验证冻结字节、一次 setup 执行和 MCP 取消。`official_hosted_structured_native.py` 覆盖托管结构化输出。随每项验收结果记录精确源修订版本、原生版本和命令。 将 Provider 密钥保存在私有操作员文件中,绝不能放入提交或日志。相对于 `apps/daemon/internal/agent` 的现有定向测试如下: @@ -218,7 +208,7 @@ Environment 验收使用 `services/core/tests/official_environment_{templates,se | Claude 输入所有权、取消和准备清理 | `claudesdk/executor_test.go`、`cancellation_test.go`、`preparation_test.go` | | MiniMax 取消退役、Start 失败和清理重试 | `mcode/executor_test.go`、`executor_backpressure_test.go` | | MiniMax 原生历史绑定 | `mcode/session_test.go` | -| 无原生副作用或伪造结果的明确拒绝 | 每个适配器的 `unsupported_test.go` | +| 无原生副作用或伪造结果的明确拒绝 | `mcode/unsupported_test.go` | ## 原生安装器参与 {#native-installer-participation} diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index e8ca07076..e56adae3d 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -1,7 +1,7 @@ --- title: "模型执行" source: contracts/agents-api/model-execution.md -source_hash: e8abe9e40a54a35935d08e2d2c26e1269b8139e9972fb5359d483db525d0862e +source_hash: 1a60157b6deb30ca0eee5c626423e8a9339224f34b8ac2f48661139621c96ff9 --- 每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 定义 Core 和 Runtime 共同应用的[提供商规则](#session-override),并声明[凭据网关](#credential-gateway)转发的内容,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 @@ -147,4 +147,4 @@ Harness 和默认模型读取结果包含可空的 `last_used_at`、`last_error_ ## 验收 {#acceptance} -`TestNativeModelProtocolPublicExecution`(`services/core/tests/integration/model_protocol_native_test.go`)结合 `services/core/tests/official_model_protocol_native.py`,通过固定版本的官方客户端针对真实提供商 API 运行每个 Harness。当 `OAC_TEST_OFFICIAL_SDK_PYTHON`、`OAC_TEST_NATIVE_DAEMON_BIN`、`OAC_TEST_NATIVE_PROOF_DIR` 和 `OAC_TEST_MODEL_PROTOCOL_OPTIONS` 均已设置时运行;最后一项指定一个私有模型设置文件。绝不提交这些设置或打印其值。 +通过锁定的官方客户端、Core、agent host 和真实 Provider API 验证每个 Harness 声明支持的协议。覆盖初始执行、热继续执行、函数结果、取消以及重启后的历史连续性。记录精确修订版本、原生版本和命令;仅通过适配器或 view 测试不能证明公共 API 协议验收。将模型设置保存在私有操作员文件中,绝不提交或打印它们。 diff --git a/docs.json b/docs.json index 112a68a59..cc5b22d3d 100644 --- a/docs.json +++ b/docs.json @@ -36,7 +36,6 @@ "docs/concepts", "docs/architecture", "docs/runtime-protocol", - "docs/runtime-bootstrap", "docs/sandbox-provider", "docs/sandbox-bootstrap", "docs/sandbox-link-protocol", diff --git a/docs/development.md b/docs/development.md index f11c40757..536d8495a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -72,7 +72,6 @@ For frontend development, run `pnpm dev:web` using the fixture or Core connectio | `services/core/internal/execution` | Durable Turn dispatch and scheduling | [Runtime protocol](./runtime-protocol.md) | | `internal/harnessconfig` | Built-in Harness registrations and their support declarations | [Declare support](../contracts/agents-api/harness-onboarding.md#declare-support) | | `internal/agentdaemon/proto` | Core–Runtime wire types and validators | [Runtime protocol](./runtime-protocol.md) | -| `internal/runtimebootstrap` | Provider-to-Runtime startup input | [Runtime bootstrap](./runtime-bootstrap.md) | | `internal/sandboxwire` | Frame header, primitive encoding and request ID sequence shared by the sandbox I/O protocols | [Framing](./sandbox-link-protocol.md#framing) | | `internal/sandboxlink` | Link protocol, peer libraries and relay core | [Sandbox link protocol](./sandbox-link-protocol.md) | | `internal/sandboxbootstrap` | Provider-to-Sandbox I/O service startup input | [Sandbox bootstrap](./sandbox-bootstrap.md) | diff --git a/docs/runtime-bootstrap.md b/docs/runtime-bootstrap.md deleted file mode 100644 index 33fb5cc6e..000000000 --- a/docs/runtime-bootstrap.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: "Runtime bootstrap" ---- - -A Sandbox Provider starts a managed Runtime by handing it one bootstrap file. This document owns that Provider-to-Runtime startup input. The type and validator live in [`internal/runtimebootstrap`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/runtimebootstrap/bootstrap.go); Go providers build it with `sandbox.Bootstrap.RuntimeConnection()` in [`runtime_bootstrap.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/runtime_bootstrap.go), and SDK helpers forward the serialized object unchanged. A provider never reads or writes the Runtime's private authentication store. - -## Launch input - -Deliver one JSON object in a regular file that only the Runtime account and trusted provisioning processes can read (mode 0600 on managed Linux), and pass its absolute path: - -```sh -oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json -``` - -| Field | Meaning | -| --- | --- | -| `version` | The exact bootstrap version, `runtimebootstrap.Version` | -| `core_url` | HTTP(S) machine API base ending in `/api/v1`, without credentials, query or fragment | -| `device_id` | Canonical nonzero UUID of the daemon identity Core issued | -| `credential` | Nonempty daemon credential Core issued, without whitespace or NUL | - -The decoder rejects unknown, duplicate, missing and case-aliased fields, other versions and documents larger than `runtimebootstrap.MaxBytes` (16 KiB). Errors never include submitted values. A missing or malformed file fails before the daemon connects. - -The file is the only authentication input for this launch: the daemon refuses to combine it with self-hosted enrollment options, and reads the credential into memory without saving it to a stored profile. Credentials never go in command arguments, environment variables or receipts. The provider keeps the file for process restarts and removes it only during explicit cleanup of the resources it owns. - -## Responsibilities and readiness - -The provider creates the account, mounts and workspace, delivers this file, sets the Runtime's resource and Environment binding settings, and starts the daemon as the unprivileged Runtime account. Docker writes the file into the Runtime's owned home volume; microsandbox and E2B deliver it before launching the same command. - -The Runtime validates the input and owns authentication and connection. A successful launch proves only the handoff: an authenticated connection, prepared capabilities and execution readiness are separate observations under the [Core–Runtime protocol](./runtime-protocol.md), and the [Sandbox Provider guide](./sandbox-provider.md#four-distinct-readiness-facts) lists what each one proves. - -Operator-provisioned devices get their daemon identity in another way; the [machine connection API](../contracts/agents-api/machine-api.md#credentials) lists every credential source. A self-hosted machine runs no Runtime: it enrolls and serves its Environment through the [Sandbox I/O service](./sandbox-bootstrap.md). - -## Verification - -`go test ./internal/runtimebootstrap ./apps/daemon/internal/cli` covers the input contract, the exclusivity of credential sources and restart behavior. Provider tests verify delivery and file permissions without relying on the Runtime's private storage. diff --git a/docs/zh/development.md b/docs/zh/development.md index fdc1aef19..ce082394c 100644 --- a/docs/zh/development.md +++ b/docs/zh/development.md @@ -1,7 +1,7 @@ --- title: "开发 OpenAgentCore" source: docs/development.md -source_hash: f3e891be155a821f011ebc75916a6b5c9a68984d1ffe29753fdce59162577c51 +source_hash: 2cf6decda870e6b87ae23a813d6b8159a88f85859dde20d97ffbb492f6801a76 --- 准备工作副本,构建组件并验证修改。如需使用已安装的实例,从[入门指南](getting-started/index.md)开始。修改代码前阅读[贡献者规则](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md)。 @@ -74,7 +74,6 @@ Core 构建产物和输出目录设置见[独立 Core 构建](maintainers.md#sta | `services/core/internal/execution` | 持久化 Turn 分发与调度 | [Runtime 协议](runtime-protocol.md) | | `internal/harnessconfig` | 内置 Harness 注册及其支持声明 | [声明支持](../../contracts/agents-api/zh/harness-onboarding.md#declare-support) | | `internal/agentdaemon/proto` | Core–Runtime wire 类型与验证器 | [Runtime 协议](runtime-protocol.md) | -| `internal/runtimebootstrap` | Provider 到 Runtime 的启动输入 | [Runtime 引导](runtime-bootstrap.md) | | `internal/sandboxwire` | 各沙箱 I/O 协议共享的帧头、基本类型编码和 request ID 序列 | [帧格式](sandbox-link-protocol.md#framing) | | `internal/sandboxlink` | Link 协议、peer 库和 relay 核心 | [沙箱 Link 协议](sandbox-link-protocol.md) | | `internal/sandboxbootstrap` | Provider 到 Sandbox I/O 服务的启动输入 | [沙箱引导](sandbox-bootstrap.md) | diff --git a/docs/zh/runtime-bootstrap.md b/docs/zh/runtime-bootstrap.md deleted file mode 100644 index 9246d935d..000000000 --- a/docs/zh/runtime-bootstrap.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: "Runtime 引导" -source: docs/runtime-bootstrap.md -source_hash: 0aa851ad8d3b6ebcd3d94ad85baad15725f4a7e970b75ae0e6570f58c9dcc271 ---- - -Sandbox Provider 通过交付一个引导文件来启动托管 Runtime。本文负责 Provider 到 Runtime 的启动输入。类型与验证器位于 [`internal/runtimebootstrap`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/runtimebootstrap/bootstrap.go);Go provider 使用 [`runtime_bootstrap.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/runtime_bootstrap.go) 中的 `sandbox.Bootstrap.RuntimeConnection()` 构造输入,SDK helper 原样转发序列化对象。provider 不读取或写入 Runtime 的私有认证存储。 - -## 启动输入 {#launch-input} - -将一个 JSON 对象交付到普通文件中,该文件仅允许 Runtime 账户和可信资源供应进程读取(托管 Linux 上权限为 0600),并传入其绝对路径: - -```sh -oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json -``` - -| 字段 | 含义 | -| --- | --- | -| `version` | 精确的引导版本 `runtimebootstrap.Version` | -| `core_url` | 以 `/api/v1` 结尾的 HTTP(S) 机器 API 基址,不含凭据、查询或片段 | -| `device_id` | Core 签发的 daemon 身份的规范非零 UUID | -| `credential` | Core 签发的非空 daemon 凭据,不含空白或 NUL | - -解码器拒绝未知、重复、缺失和大小写别名字段,拒绝其他版本及超过 `runtimebootstrap.MaxBytes`(16 KiB)的文档。错误不包含提交的值。文件缺失或格式错误时,daemon 在连接前失败。 - -该文件是此次启动唯一的认证输入:daemon 拒绝将其与自托管注册选项组合使用,并将凭据读入内存而不保存到存储的 profile。凭据不进入命令参数、环境变量或回执。provider 为进程重启保留该文件,仅在明确清理自己拥有的资源时删除。 - -## 职责与就绪状态 {#responsibilities-and-readiness} - -provider 创建账户、挂载和工作区,交付该文件,设置 Runtime 的资源与 Environment 绑定配置,然后以无特权 Runtime 账户启动 daemon。Docker 将文件写入 Runtime 拥有的 home volume;microsandbox 和 E2B 在启动同一命令之前交付文件。 - -Runtime 验证输入,并负责认证与连接。启动成功仅证明交付完成:经过认证的连接、已准备的能力和执行就绪是 [Core–Runtime 协议](runtime-protocol.md) 下的独立观测;[Sandbox Provider 指南](sandbox-provider.md#four-distinct-readiness-facts) 列出各自证明的事实。 - -运维人员供应的设备通过其他方式获取 daemon 身份;[机器连接 API](../../contracts/agents-api/zh/machine-api.md#credentials) 列出所有凭据来源。自托管机器不运行 Runtime:它完成注册,并通过 [Sandbox I/O 服务](./sandbox-bootstrap.md)为其 Environment 提供服务。 - -## 验证 {#verification} - -`go test ./internal/runtimebootstrap ./apps/daemon/internal/cli` 覆盖输入契约、凭据来源互斥规则和重启行为。Provider 测试验证交付与文件权限,不依赖 Runtime 的私有存储。 diff --git a/internal/runtimebootstrap/bootstrap.go b/internal/runtimebootstrap/bootstrap.go deleted file mode 100644 index bab5e7b16..000000000 --- a/internal/runtimebootstrap/bootstrap.go +++ /dev/null @@ -1,100 +0,0 @@ -// Package runtimebootstrap owns the Provider-to-Runtime connection input. -// Providers deliver this document as a private file; only Runtime interprets it. -package runtimebootstrap - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "net/url" - "strings" - "unicode" - - "github.com/google/uuid" -) - -const Version = 1 -const MaxBytes = 16 * 1024 - -var ErrInvalid = errors.New("invalid Runtime bootstrap input") - -// Connection is a current-version launch input, not Runtime's private auth store. -// CoreURL includes the machine API base path. The provider owns delivery and -// file permissions; Runtime owns validation and its authentication representation. -type Connection struct { - Version int `json:"version"` - CoreURL string `json:"core_url"` - DeviceID string `json:"device_id"` - Credential string `json:"credential"` -} - -func (c Connection) Validate() error { - u, err := url.Parse(c.CoreURL) - id, idErr := uuid.Parse(c.DeviceID) - if c.Version != Version || err != nil || (u.Scheme != "https" && u.Scheme != "http") || - u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || - u.RawPath != "" || u.Path != "/api/v1" || - strings.ContainsAny(c.CoreURL, "?#") || strings.ContainsFunc(c.CoreURL, unicode.IsSpace) || - idErr != nil || id == uuid.Nil || id.String() != c.DeviceID || - c.Credential == "" || strings.ContainsFunc(c.Credential, func(r rune) bool { return r == 0 || unicode.IsSpace(r) }) { - return ErrInvalid - } - raw, err := json.Marshal(c) - if err != nil || len(raw) > MaxBytes { - return ErrInvalid - } - return nil -} - -func (c Connection) Marshal() ([]byte, error) { - if err := c.Validate(); err != nil { - return nil, err - } - return json.Marshal(c) -} - -// Decode accepts one exact object. Unknown, duplicate, missing and case-aliased -// fields reject instead of introducing alternate spellings of this contract. -func Decode(raw []byte) (Connection, error) { - var c Connection - if len(raw) > MaxBytes { - return c, ErrInvalid - } - d := json.NewDecoder(bytes.NewReader(raw)) - token, err := d.Token() - if err != nil || token != json.Delim('{') { - return c, ErrInvalid - } - seen := map[string]bool{} - for d.More() { - token, err = d.Token() - key, ok := token.(string) - if err != nil || !ok || seen[key] { - return Connection{}, ErrInvalid - } - seen[key] = true - switch key { - case "version": - err = d.Decode(&c.Version) - case "core_url": - err = d.Decode(&c.CoreURL) - case "device_id": - err = d.Decode(&c.DeviceID) - case "credential": - err = d.Decode(&c.Credential) - default: - return Connection{}, ErrInvalid - } - if err != nil { - return Connection{}, ErrInvalid - } - } - if token, err = d.Token(); err != nil || token != json.Delim('}') { - return Connection{}, ErrInvalid - } - if len(seen) != 4 || d.Decode(new(any)) != io.EOF || c.Validate() != nil { - return Connection{}, ErrInvalid - } - return c, nil -} diff --git a/internal/runtimebootstrap/bootstrap_test.go b/internal/runtimebootstrap/bootstrap_test.go deleted file mode 100644 index 132e5659a..000000000 --- a/internal/runtimebootstrap/bootstrap_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package runtimebootstrap - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestConnectionRoundTrip(t *testing.T) { - input := Connection{Version: Version, CoreURL: "https://core.example/api/v1", DeviceID: "da912024-1543-4242-a2c1-5f4f7ebbc6c7", Credential: "test-credential"} - raw, err := input.Marshal() - if err != nil { - t.Fatal(err) - } - got, err := Decode(raw) - if err != nil || got != input { - t.Fatal("connection changed in transit", err) - } -} - -func TestConnectionRejectsAmbiguousOrForeignInput(t *testing.T) { - good := `{"version":1,"core_url":"https://core.example/api/v1","device_id":"da912024-1543-4242-a2c1-5f4f7ebbc6c7","credential":"test-secret"}` - for name, raw := range map[string]string{ - "null": "null", "array": "[]", "trailing": good + "{}", - "duplicate": strings.Replace(good, `"version":1`, `"version":1,"version":1`, 1), - "unknown": strings.Replace(good, `"version":1`, `"extra":1,"version":1`, 1), - "case": strings.Replace(good, "credential", "Credential", 1), - "old auth": `{"server_url":"https://core.example","runtime_id":"old","runner_credential":"test-secret"}`, - "oversized": strings.Repeat(" ", MaxBytes) + good, - } { - t.Run(name, func(t *testing.T) { - if _, err := Decode([]byte(raw)); err != ErrInvalid { - t.Fatal("accepted invalid input") - } - }) - } - for field, values := range map[string][]any{ - "version": {nil, 0, 2, "1"}, - "core_url": {"http://user:pass@core.example/api/v1", "https://core.example/api/v1?", "https://core.example/api/v1#", "https://core.example", "https://core.example/api/v1/", "https://core.example/api/v1?q=1", ""}, - "device_id": {"", "invalid", "00000000-0000-0000-0000-000000000000"}, - "credential": {nil, "", "test\nsecret", "test\x00secret"}, - } { - for _, value := range values { - var input map[string]any - _ = json.Unmarshal([]byte(good), &input) - input[field] = value - raw, _ := json.Marshal(input) - if _, err := Decode(raw); err != ErrInvalid { - t.Fatalf("accepted invalid %s", field) - } - } - var input map[string]any - _ = json.Unmarshal([]byte(good), &input) - delete(input, field) - raw, _ := json.Marshal(input) - if _, err := Decode(raw); err != ErrInvalid { - t.Fatalf("accepted missing %s", field) - } - } -} diff --git a/scripts/build-core.sh b/scripts/build-core.sh index a0baf5f16..04163c3e1 100755 --- a/scripts/build-core.sh +++ b/scripts/build-core.sh @@ -27,7 +27,7 @@ tar -C "$repo_root" -cf - \ contracts/agents-api/v1 \ contracts/agents-api/openapi.go contracts/agents-api/openapi.yaml contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml \ internal/agentdaemon/proto internal/sandboxlink internal/sandboxwire internal/sandboxbootstrap internal/sandboxfs \ - internal/runtimefs internal/runtimebootstrap internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/modelprovider internal/providerassets internal/obs/log services/core \ + internal/runtimefs internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/modelprovider internal/providerassets internal/obs/log services/core \ | tar -C "$build_context" -xf - ( diff --git a/services/core/internal/runtimegateway/mcp_bearer_fixture_linux_test.go b/services/core/internal/runtimegateway/mcp_bearer_fixture_linux_test.go deleted file mode 100644 index 025061545..000000000 --- a/services/core/internal/runtimegateway/mcp_bearer_fixture_linux_test.go +++ /dev/null @@ -1,151 +0,0 @@ -//go:build linux - -package runtimegateway - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "math/big" - "net" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -type mcpBearerFixture struct { - private, anonymous *httptest.Server - caFile, memory string - mu sync.Mutex - accepted, rejected, anonymousRequests, crossed int - calls map[string]int -} - -func newMCPBearerFixture(t *testing.T, root, token string) *mcpBearerFixture { - t.Helper() - f := &mcpBearerFixture{memory: "REMEMBER_" + mcpBearerNonce(t), calls: make(map[string]int)} - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal("cannot create owned TLS key") - } - now := time.Now() - cert := &x509.Certificate{SerialNumber: big.NewInt(now.UnixNano()), Subject: pkix.Name{CommonName: "Owned MCP acceptance CA"}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature} - der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) - if err != nil { - t.Fatal("cannot create owned TLS certificate") - } - leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal("cannot create owned TLS leaf key") - } - leaf := &x509.Certificate{SerialNumber: big.NewInt(now.UnixNano() + 1), Subject: pkix.Name{CommonName: "Owned MCP HTTPS endpoint"}, NotBefore: cert.NotBefore, NotAfter: cert.NotAfter, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}} - leafDER, err := x509.CreateCertificate(rand.Reader, leaf, cert, &leafKey.PublicKey, key) - if err != nil { - t.Fatal("cannot sign owned HTTPS leaf certificate") - } - // Keep the host's provider trust roots alongside the owned MCP CA. - var roots []byte - for _, path := range []string{"/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"} { - if data, err := os.ReadFile(path); err == nil { - roots = data - break - } - } - if len(roots) == 0 { - t.Fatal("system CA bundle required for real provider TLS") - } - roots = append(append(roots, '\n'), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...) - f.caFile = filepath.Join(root, "trusted-ca.pem") - if err := os.WriteFile(f.caFile, roots, 0600); err != nil { - t.Fatal("cannot save owned trust bundle") - } - start := func(anonymous bool) *httptest.Server { - server := mcp.NewServer(&mcp.Implementation{Name: "owned-bearer-acceptance", Version: "1"}, nil) - names := []string{"remember", "fail"} - if anonymous { - names = []string{"ping"} - } - for _, name := range names { - mcp.AddTool(server, &mcp.Tool{Name: name, Description: map[string]string{"remember": "Return the unpredictable value to remember.", "fail": "Return an intentional ordinary tool error. Do not retry.", "ping": "Confirm this separate anonymous MCP server works."}[name]}, func(_ context.Context, _ *mcp.CallToolRequest, args struct { - Tag string `json:"tag" jsonschema:"The requested verification tag"` - }) (*mcp.CallToolResult, any, error) { - f.mu.Lock() - f.calls[name]++ - f.mu.Unlock() - text := f.memory - if name == "fail" { - text = "INTENTIONAL_MCP_TOOL_ERROR:" + args.Tag - } - if name == "ping" { - text = "ANONYMOUS_OK" - } - return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}, IsError: name == "fail"}, nil, nil - }) - } - transport := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil) - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f.mu.Lock() - allowed := len(r.Header.Values("Authorization")) == 1 && r.Header.Get("Authorization") == "Bearer "+token - if anonymous { - f.anonymousRequests++ - allowed = len(r.Header.Values("Authorization")) == 0 - if !allowed { - f.crossed++ - } - } else if allowed { - f.accepted++ - } else { - f.rejected++ - } - f.mu.Unlock() - if !allowed { - w.WriteHeader(http.StatusUnauthorized) - return - } - transport.ServeHTTP(w, r) - }) - s := httptest.NewUnstartedServer(handler) - s.TLS = &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{leafDER, der}, PrivateKey: leafKey}}, MinVersion: tls.VersionTLS12} - s.StartTLS() - t.Cleanup(s.Close) - return s - } - f.private, f.anonymous = start(false), start(true) - for _, authorization := range []string{"", "Bearer wrong-" + mcpBearerNonce(t)} { - req, _ := http.NewRequest(http.MethodPost, f.private.URL, strings.NewReader("{}")) - if authorization != "" { - req.Header.Set("Authorization", authorization) - } - response, err := f.private.Client().Do(req) - if err != nil { - t.Fatal("owned HTTPS authorization probe failed") - } - response.Body.Close() - if response.StatusCode != http.StatusUnauthorized { - t.Fatal("missing or wrong bearer was accepted") - } - } - return f -} - -func (f *mcpBearerFixture) observations() map[string]any { - f.mu.Lock() - defer f.mu.Unlock() - calls := make(map[string]int, len(f.calls)) - for name, count := range f.calls { - calls[name] = count - } - return map[string]any{"authenticated_requests": f.accepted, "rejected_requests": f.rejected, "anonymous_requests": f.anonymousRequests, "cross_forwarded_authorization": f.crossed, "tool_calls": calls} -} diff --git a/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go b/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go deleted file mode 100644 index 195d3f0c1..000000000 --- a/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go +++ /dev/null @@ -1,280 +0,0 @@ -//go:build linux - -package runtimegateway - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" -) - -type mcpBearerTurn struct { - Events []proto.Envelope `json:"events"` - Done proto.DonePayload `json:"done"` - Text string `json:"text"` - NativeLaunches int `json:"native_launches"` - BearerEnvironmentReference string `json:"bearer_environment_reference"` -} - -func TestLiveMCPBearerGatewayColdContinuation(t *testing.T) { - daemon, native, provider, root := mcpBearerSettings(t) - t.Logf("private MCP bearer evidence: %s", root) - token, runner := mcpBearerNonce(t), mcpBearerNonce(t) - fixture := newMCPBearerFixture(t, root, token) - capture := &mcpBearerLog{} - proof := map[string]any{"scope": "Private gateway -> built daemon -> pinned Codex -> real MiniMax with owned HTTPS MCP; no public API or Vault execution claim", "model": "MiniMax-M3", "provider_url": "https://api.minimax.cn/v1", "daemon_sha256": mcpBearerBinaryHash(t, daemon), "codex_sha256": mcpBearerBinaryHash(t, native)} - var turns []*mcpBearerTurn - t.Cleanup(func() { - mcpBearerSafeWrite(t, filepath.Join(root, "captured.log"), capture.snapshot(), token, provider) - histories := mcpBearerScanArtifacts(t, root, token, provider) - if !t.Failed() && histories == 0 { - t.Error("native history artifact missing") - } - proof["native_history_files"], proof["mcp"], proof["turns"] = histories, fixture.observations(), turns - proof["passed"] = !t.Failed() - data, err := json.MarshalIndent(proof, "", " ") - if err != nil { - t.Error("cannot encode safe acceptance evidence") - return - } - mcpBearerSafeWrite(t, filepath.Join(root, "proof.json"), data, token, provider) - }) - id := uuid.NewString() - registry := NewRegistry() - auth := NewAuthenticator(&stubRuntimeStore{ok: true, row: runtimedevice.Credential{ID: id, WorkspaceID: uuid.NewString(), Type: runtimedevice.RuntimeTypeAgentDaemon, CredentialHash: runtimedevice.HashCredential(runner)}}) - router := chi.NewRouter() - server := httptest.NewServer(router) - t.Cleanup(server.Close) - handler := NewHandler(HandlerConfig{Authenticator: auth, Registry: registry, PublicWSURL: "ws" + strings.TrimPrefix(server.URL, "http") + "/agent-daemon/ws", Log: func(format string, args ...any) { _, _ = fmt.Fprintf(capture, format+"\n", args...) }}) - RegisterRoutes(router, handler) - mcpBearerStartDaemon(t, root, daemon, native, fixture.caFile, server.URL, id, runner, capture) - ctx, cancel := context.WithTimeout(t.Context(), 6*time.Minute) - defer cancel() - peer, err := registry.WaitForDevice(ctx, id, 30*time.Second) - if err != nil { - t.Fatal("built daemon did not connect through the real gateway") - } - t.Cleanup(func() { peer.Close("owned MCP acceptance finished") }) - ready := time.Now().Add(30 * time.Second) - for { - info, found, known := peer.AgentKindStatus("codex") - if known && found && info.Available { - if !info.Capabilities.MCPHTTPTools.IsSupported() || !info.Capabilities.MCPHTTPBearerAuth.IsSupported() || !info.Capabilities.EnvironmentNone.IsSupported() { - t.Fatal("built daemon did not advertise the required private execution capabilities") - } - proof["codex_descriptor"] = info - break - } - if time.Now().After(ready) { - t.Fatal("pinned Codex capability discovery did not complete") - } - time.Sleep(50 * time.Millisecond) - } - allowed, anonymousTools := []string{"remember", "fail"}, []string{"ping"} - servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "private_mcp", ServerURL: fixture.private.URL, AllowedTools: &allowed, BearerToken: &token}, {ConnectionOrigin: "service", ServerLabel: "anonymous_mcp", ServerURL: fixture.anonymous.URL, AllowedTools: &anonymousTools}} - assignment := proto.AssignmentRef{SessionID: uuid.NewString(), AssignmentID: uuid.NewString(), Epoch: 1} - if err := peer.Bind(ctx, assignment, ""); err != nil { - t.Fatal("built daemon did not bind the Session's assignment") - } - // reconnect drops the connection: the daemon's Router closes the Session's - // Executor on shutdown, so the next Run starts a fresh native process. - reconnect := func() { - t.Helper() - old := peer - old.Close("cold continuation") - for peer == old { - next, err := registry.WaitForDevice(ctx, id, 30*time.Second) - if err != nil { - t.Fatal("built daemon did not reconnect through the real gateway") - } - if next == old { - time.Sleep(50 * time.Millisecond) - continue - } - peer = next - } - if err := peer.Bind(ctx, assignment, ""); err != nil { - t.Fatal("built daemon did not bind the Session's assignment again") - } - } - run := func(prompt, resume string, expected map[string]string) *mcpBearerTurn { - t.Helper() - turn := &mcpBearerTurn{} - turns = append(turns, turn) - runID := uuid.NewString() - request := proto.PromptRequestPayload{AgentKind: "codex", AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, MCPHTTPServers: &servers, Model: "MiniMax-M3", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: "https://api.minimax.cn/v1", APIKey: provider}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}} - sub, err := peer.SubscribeDurable(runID, assignment) - if err != nil { - t.Fatal("cannot subscribe before real daemon dispatch") - } - defer peer.Unsubscribe(runID) - control := mcpBearerStart(t, ctx, peer, assignment, request, runID, proto.TextInput(prompt)) - mcpBearerCollectTurn(t, ctx, sub, runID, turn, expected, token, provider) - peer.UnsubscribePreparation(control.id) - reconnect() - turn.NativeLaunches = mcpBearerReleased(t, root) - turn.BearerEnvironmentReference = mcpBearerConfigReference(t, root, token) - return turn - } - first := run("Call private_mcp remember exactly once with tag first. Also call anonymous_mcp ping exactly once with tag first. Reply with the exact remembered value and the ping result. Do not use any other tool.", "", map[string]string{"remember": fixture.memory, "ping": "ANONYMOUS_OK"}) - nativeID, _ := first.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - if nativeID == "" || !strings.Contains(first.Text, fixture.memory) { - t.Fatal("first real model Turn did not return its native identity and unpredictable tool result") - } - second := run("Recall the exact remembered value from the preceding tool result. Call private_mcp fail exactly once with tag cold-followup. It intentionally reports an ordinary tool error; do not retry. Reply with the earlier remembered value and the exact error text. Do not call remember, ping or any other tool.", nativeID, map[string]string{"fail": "INTENTIONAL_MCP_TOOL_ERROR:cold-followup"}) - if second.Done.Metadata[proto.DoneMetaAgentSessionID] != nativeID || !strings.Contains(second.Text, fixture.memory) || !strings.Contains(second.Text, "INTENTIONAL_MCP_TOOL_ERROR:cold-followup") { - t.Fatal("cold native continuation lost history, identity or ordinary error output") - } - if second.NativeLaunches <= first.NativeLaunches || second.BearerEnvironmentReference == first.BearerEnvironmentReference { - t.Fatal("cold continuation did not create a fresh native process and bearer environment reference") - } - fixture.mu.Lock() - valid := fixture.accepted > 0 && fixture.rejected == 2 && fixture.anonymousRequests > 0 && fixture.crossed == 0 && fixture.calls["remember"] == 1 && fixture.calls["ping"] == 1 && fixture.calls["fail"] == 1 - fixture.mu.Unlock() - if !valid { - t.Fatal("HTTPS authorization, per-server separation or expected tool-call counts failed") - } -} - -// mcpBearerControl is one prepared Executor admission of the acceptance -// Session. -type mcpBearerControl struct { - t *testing.T - ctx context.Context - peer *Session - assignment proto.AssignmentRef - id string - events *Subscription - status proto.PreparationStatusPayload -} - -// mcpBearerStart prepares the Session's Executor and starts the Run on it, as -// Core does. -func mcpBearerStart(t *testing.T, ctx context.Context, peer *Session, assignment proto.AssignmentRef, request proto.PromptRequestPayload, runID string, input proto.MessageInput) *mcpBearerControl { - t.Helper() - c := &mcpBearerControl{t: t, ctx: ctx, peer: peer, assignment: assignment, id: uuid.NewString()} - events, err := peer.SubscribePreparation(c.id, assignment) - if err != nil { - t.Fatal("cannot subscribe to the preparation") - } - c.events = events - c.send(proto.TypeExecutionPrepare, proto.ExecutionPreparePayload{SessionID: assignment.SessionID, Configuration: request}) - c.await("ready") - c.send(proto.TypeExecutionStart, proto.ExecutionStartPayload{Handle: c.status.Handle, ExecutorID: c.status.ExecutorID, RunID: runID, Input: input}) - c.await("started") - return c -} - -func (c *mcpBearerControl) send(kind string, payload any) { - c.t.Helper() - env, err := proto.NewEnvelope(kind, c.id, payload) - if err != nil { - c.t.Fatal("cannot encode the preparation control") - } - env.Assignment = c.assignment - if c.peer.Send(c.ctx, env) != nil { - c.t.Fatal("cannot dispatch the preparation control") - } -} - -func (c *mcpBearerControl) await(state string) { - c.t.Helper() - for c.status.State != state { - select { - case env, ok := <-c.events.Events: - if !ok || env.DecodePayload(&c.status) != nil || c.status.State == "rejected" || c.status.State == "failed" { - c.t.Fatal("built daemon did not admit the prepared Run") - } - case <-c.ctx.Done(): - c.t.Fatal("prepared Run admission timed out") - } - } -} - -func mcpBearerCollectTurn(t *testing.T, ctx context.Context, sub *Subscription, runID string, turn *mcpBearerTurn, expected map[string]string, secrets ...string) { - t.Helper() - before, after := make(map[string]string), make(map[string]int) - for { - var event proto.Envelope - select { - case value, ok := <-sub.Events: - if !ok { - t.Fatal("real daemon subscription closed before Done") - } - event = value - case <-ctx.Done(): - t.Fatal("real MiniMax MCP Turn timed out") - } - data, _ := json.Marshal(event) - for _, secret := range secrets { - if bytes.Contains(data, []byte(secret)) { - t.Fatal("secret appeared in a daemon event") - } - } - if event.ID != runID { - t.Fatal("real daemon event changed Run identity") - } - turn.Events = append(turn.Events, event) - switch event.Type { - case proto.TypeError: - t.Fatal("unexpected execution failure during private MCP acceptance") - case proto.TypeDelta: - var delta proto.DeltaPayload - if event.DecodePayload(&delta) != nil { - t.Fatal("invalid native message delta") - } - turn.Text += delta.Delta - case proto.TypeToolCall: - var call proto.ToolCallPayload - if event.DecodePayload(&call) != nil || call.Observation == nil { - t.Fatal("missing normalized native tool observation") - } - obs := call.Observation - output, wanted := expected[obs.Name] - server := "private_mcp" - if obs.Name == "ping" { - server = "anonymous_mcp" - } - if !wanted || obs.Kind != "mcp" || obs.Server != server || call.ID == "" { - t.Fatal("unexpected native tool identity or server") - } - if call.Stage == "before" { - if obs.Status != "in_progress" || before[call.ID] != "" { - t.Fatal("invalid native tool start observation") - } - before[call.ID] = obs.Name - continue - } - status := "completed" - if obs.Name == "fail" { - status = "failed" - } - if call.Stage != "after" || before[call.ID] != obs.Name || obs.Status != status || !bytes.Contains(obs.Output, []byte(output)) || (len(obs.Error) != 0 && string(obs.Error) != "null") { - t.Fatal("native tool result, lifecycle or ordinary error semantics changed") - } - after[obs.Name]++ - case proto.TypeDone: - if event.DecodePayload(&turn.Done) != nil || sub.Err() != nil || turn.Text == "" || turn.Done.Metadata[proto.DoneMetaAgentSessionType] != "codex_thread" { - t.Fatal("invalid native Done or incomplete gateway delivery") - } - for name := range expected { - if after[name] != 1 { - t.Fatal("expected exactly one complete native observation per requested tool") - } - } - return - } - } -} diff --git a/services/core/internal/runtimegateway/mcp_bearer_process_linux_test.go b/services/core/internal/runtimegateway/mcp_bearer_process_linux_test.go deleted file mode 100644 index a61bca86c..000000000 --- a/services/core/internal/runtimegateway/mcp_bearer_process_linux_test.go +++ /dev/null @@ -1,296 +0,0 @@ -//go:build linux - -package runtimegateway - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" - "io/fs" - "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "syscall" - "testing" - "time" -) - -func mcpBearerSettings(t *testing.T) (daemon, native, provider, root string) { - t.Helper() - names := []string{"OAC_TEST_MCP_BEARER_DAEMON_BIN", "OAC_TEST_MCP_BEARER_CODEX_BIN", "OAC_TEST_MCP_BEARER_MODEL_KEY_FILE", "OAC_TEST_MCP_BEARER_PROOF_DIR"} - for _, name := range names { - if os.Getenv(name) == "" { - t.Skip("real MCP bearer acceptance requires all four explicit binary, provider-file and proof settings") - } - if !filepath.IsAbs(os.Getenv(name)) { - t.Fatal("MCP bearer acceptance settings must be absolute paths") - } - } - home, err := os.UserHomeDir() - if err != nil { - t.Fatal("cannot resolve managed runtime home") - } - proof, err := filepath.EvalSymlinks(os.Getenv(names[3])) - if err != nil { - t.Fatal("explicit proof directory must already exist") - } - managed, err := filepath.EvalSymlinks(filepath.Join(home, ".oac")) - if err != nil || !strings.HasPrefix(proof, managed+string(os.PathSeparator)) { - t.Fatal("proof directory must be below ~/.oac") - } - root, err = os.MkdirTemp(proof, "mcp-bearer-") - if err != nil { - t.Fatal("cannot allocate owned proof directory") - } - key, err := os.ReadFile(os.Getenv(names[2])) - if err != nil { - t.Fatal("cannot read explicitly supplied provider key file") - } - provider = strings.TrimSpace(string(key)) - if provider == "" { - t.Fatal("explicit provider key file is empty") - } - return os.Getenv(names[0]), os.Getenv(names[1]), provider, root -} - -func mcpBearerNonce(t *testing.T) string { - t.Helper() - value := make([]byte, 32) - if _, err := rand.Read(value); err != nil { - t.Fatal("cannot generate unpredictable acceptance value") - } - return hex.EncodeToString(value) -} - -type mcpBearerLog struct { - mu sync.Mutex - data bytes.Buffer -} - -func (w *mcpBearerLog) Write(p []byte) (int, error) { - w.mu.Lock() - defer w.mu.Unlock() - return w.data.Write(p) -} -func (w *mcpBearerLog) snapshot() []byte { - w.mu.Lock() - defer w.mu.Unlock() - return bytes.Clone(w.data.Bytes()) -} - -func mcpBearerStartDaemon(t *testing.T, root, daemon, native, caFile, base, id, runner string, log *mcpBearerLog) { - t.Helper() - for _, dir := range []string{"home", "tmp", "runtime/daemon/execution"} { - if err := os.MkdirAll(filepath.Join(root, dir), 0700); err != nil { - t.Fatal("cannot create owned daemon directories") - } - } - auth, _ := json.Marshal(map[string]string{"server_url": base, "runtime_id": id, "runner_credential": runner}) - if err := os.WriteFile(filepath.Join(root, "runtime/daemon/execution/auth.json"), auth, 0600); err != nil { - t.Fatal("cannot configure owned daemon identity") - } - wrapper := filepath.Join(root, "native-wrapper") - script := `#!/bin/sh -set -eu -for argument in "$@"; do - if [ "$argument" = app-server ]; then - printf '%s %s\n' "$$" "$(awk '{print $22}' /proc/$$/stat)" >> "$OAC_TEST_MCP_BEARER_STARTS" - printf '%s\0' "$@" >> "$OAC_TEST_MCP_BEARER_ARGV" - break - fi -done -exec "$OAC_TEST_MCP_BEARER_NATIVE" "$@" -` - if err := os.WriteFile(wrapper, []byte(script), 0700); err != nil { - t.Fatal("cannot create owned native wrapper") - } - env := []string{"HOME=" + filepath.Join(root, "home"), "TMPDIR=" + filepath.Join(root, "tmp"), "OAC_RUNTIME_HOME=" + filepath.Join(root, "runtime"), "OAC_RUNTIME_CODEX_BIN=" + wrapper, "SSL_CERT_FILE=" + caFile, "OAC_TEST_MCP_BEARER_NATIVE=" + native, "OAC_TEST_MCP_BEARER_STARTS=" + filepath.Join(root, "native-starts"), "OAC_TEST_MCP_BEARER_ARGV=" + filepath.Join(root, "native-argv")} - for _, name := range []string{"PATH", "LANG", "LC_ALL", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"} { - if value, ok := os.LookupEnv(name); ok { - env = append(env, name+"="+value) - } - } - versionCtx, cancel := context.WithTimeout(t.Context(), 15*time.Second) - defer cancel() - version := exec.CommandContext(versionCtx, native, "--version") - version.Env, version.Dir = env, root - output, err := version.Output() - if err != nil || strings.TrimSpace(string(output)) != "codex-cli 0.153.4" { - t.Fatal("explicit native binary must be pinned Codex 0.153.4") - } - cmd := exec.Command(daemon, "connect", "--profile", "execution") - cmd.Env, cmd.Dir, cmd.Stdout, cmd.Stderr = env, root, log, log - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - if err := cmd.Start(); err != nil { - t.Fatal("cannot start explicitly supplied daemon binary") - } - stopped := make(chan error, 1) - go func() { stopped <- cmd.Wait() }() - t.Cleanup(func() { - _ = cmd.Process.Signal(syscall.SIGTERM) - select { - case <-stopped: - case <-time.After(10 * time.Second): - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - <-stopped - } - // Native RPC children own separate groups. Match recorded start time before cleanup. - _, _ = mcpBearerProcesses(root, true) - deadline := time.Now().Add(3 * time.Second) - for { - _, active := mcpBearerProcesses(root, false) - if active == 0 { - break - } - if time.Now().After(deadline) { - t.Error("owned native process did not exit during cleanup") - break - } - time.Sleep(25 * time.Millisecond) - } - }) -} - -func mcpBearerProcesses(root string, kill bool) (launches, active int) { - data, _ := os.ReadFile(filepath.Join(root, "native-starts")) - for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { - fields := strings.Fields(line) - if len(fields) != 2 { - continue - } - pid, err := strconv.Atoi(fields[0]) - if err != nil || pid <= 1 { - continue - } - launches++ - stat, err := os.ReadFile(filepath.Join("/proc", fields[0], "stat")) - if err != nil { - continue - } - _, tail, ok := strings.Cut(string(stat), ") ") - state := strings.Fields(tail) - if !ok || len(state) <= 19 || state[19] != fields[1] || state[0] == "Z" { - continue - } - active++ - if group, err := syscall.Getpgid(pid); kill && err == nil && group == pid { - _ = syscall.Kill(-group, syscall.SIGKILL) - } - } - return launches, active -} - -func mcpBearerReleased(t *testing.T, root string) int { - t.Helper() - deadline := time.Now().Add(5 * time.Second) - for { - launches, active := mcpBearerProcesses(root, false) - if launches > 0 && active == 0 { - return launches - } - if time.Now().After(deadline) { - t.Fatal("the owned native process was not released") - } - time.Sleep(25 * time.Millisecond) - } -} - -func mcpBearerConfigReference(t *testing.T, root, token string) string { - t.Helper() - pattern := regexp.MustCompile(`(?m)^bearer_token_env_var\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"`) - var references []string - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.Name() != "config.toml" || !entry.Type().IsRegular() { - return nil - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - if bytes.Contains(data, []byte(token)) { - t.Error("MCP bearer persisted in native configuration") - return nil - } - for _, match := range pattern.FindAllSubmatch(data, -1) { - references = append(references, string(match[1])) - } - return nil - }) - if err != nil || len(references) != 1 { - t.Fatal("expected one native bearer environment reference") - } - return references[0] -} - -func mcpBearerSafeWrite(t *testing.T, path string, data []byte, secrets ...string) { - t.Helper() - for _, secret := range secrets { - if secret != "" && bytes.Contains(data, []byte(secret)) { - t.Error("secret detected in captured acceptance artifact") - _ = os.Remove(path) - return - } - } - if err := os.WriteFile(path, data, 0600); err != nil { - t.Error("cannot save safe acceptance artifact") - } -} - -func mcpBearerScanArtifacts(t *testing.T, root, token, provider string) int { - t.Helper() - histories := 0 - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if !entry.Type().IsRegular() { - return nil - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - if strings.Contains(path, "/sessions/") && strings.HasSuffix(path, ".jsonl") { - histories++ - } - mcpLeak, providerPresent := bytes.Contains(data, []byte(token)), bytes.Contains(data, []byte(provider)) - if mcpLeak || providerPresent { - if err := os.Remove(path); err != nil { - t.Error("cannot remove secret-bearing owned artifact") - } - if mcpLeak { - t.Error("injected MCP bearer persisted in an owned runtime artifact") - } - } - return nil - }) - if err != nil { - t.Error("cannot scan owned runtime artifacts") - } - return histories -} - -func mcpBearerBinaryHash(t *testing.T, path string) string { - t.Helper() - file, err := os.Open(path) - if err != nil { - t.Fatal("cannot read explicit acceptance binary") - } - defer file.Close() - digest := sha256.New() - if _, err := io.Copy(digest, file); err != nil { - t.Fatal("cannot fingerprint acceptance binary") - } - return hex.EncodeToString(digest.Sum(nil)) -} diff --git a/services/core/tests/integration/function_execution_native_test.go b/services/core/tests/integration/function_execution_native_test.go deleted file mode 100644 index 96844b3d4..000000000 --- a/services/core/tests/integration/function_execution_native_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package integration - -import ( - "encoding/json" - "fmt" - "testing" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" -) - -func TestNativeFunctionExecutionPersistsCallsResultsAndContinuity(t *testing.T) { - h, ctx, home := nativeDispatchHarness(t) - model, output, requests := nativeFunctionModel(t, home) - defer model.Close() - var err error - h.session, err = h.s.CreateSession(ctx, h.tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "native-functions", Configuration: json.RawMessage(functionConfiguration), ModelProvider: nativeModelProvider(model), ModelProviderSource: v1.ModelProviderSourceDeployment}) - if err != nil { - t.Fatal(err) - } - if err := bindSessionDevice(t, h.s, h.tenant, h.session.ID, h.device.ID); err != nil { - t.Fatal(err) - } - nativeID := "" - for index := range 3 { - input := h.message(fmt.Sprint(index), "Look up ticket 42") - running := h.run(ctx, input.TurnID) - state := functionState(t, h, 1) - action := state.RequiredActions[0] - if action.Name != "lookup_ticket" || action.TurnID != input.TurnID || state.LastTurn.Status != sessions.TurnWaiting { - t.Fatal(action, state.LastTurn) - } - if index == 2 { - if _, err := requestCancel(ctx, h.s, h.tenant, h.session.ID, "native-cancel"); err != nil { - t.Fatal(err) - } - h.finished(running, sessions.TurnCancelled) - break - } - value := map[string]any{"success": index == 0, "output": output} - if index == 1 { - value["error"] = "synthetic failure" - } - raw, _ := json.Marshal(value) - for range 2 { - if err := SubmitFixtureFunctionResult(ctx, h.s, h.tenant, h.session.ID, input.TurnID, action.CallID, raw); err != nil { - t.Fatal(err) - } - } - h.finished(running, sessions.TurnCompleted) - saved, err := FixtureFunctionCall(ctx, h.s.pool, h.tenant, h.session.ID, input.TurnID, action.CallID) - if err != nil || !saved.Applied { - t.Fatal(saved, err) - } - page, err := sessionAdapter(h.s).ListItems(ctx, h.tenant, h.session.ID, "", 100, true) - if err != nil { - t.Fatal(err) - } - found := false - for _, item := range page.Items { - if item.Type == "function_call" && item.CallID == action.CallID { - found = true - } - } - if !found { - t.Fatal("required action identity differs from recovered function item") - } - bound, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) - if err != nil || bound.NativeSessionID == "" || (nativeID != "" && bound.NativeSessionID != nativeID) { - t.Fatal(bound, err) - } - nativeID = bound.NativeSessionID - } - functionState(t, h, 0) - if requests.Load() != 5 { - t.Fatal("function replay or missing model continuation", requests.Load()) - } - if t.Failed() { - return - } - t.Logf("Native daemon/engine functions, complete text/image/error results, receipts, Items identity, resume and cancellation passed; evidence %s", home) -} diff --git a/services/core/tests/integration/function_images_native_test.go b/services/core/tests/integration/function_images_native_test.go deleted file mode 100644 index da1e4eb34..000000000 --- a/services/core/tests/integration/function_images_native_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -func TestNativeFunctionImagePublicExecution(t *testing.T) { - python, binary, root, optionsFile := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON"), os.Getenv("OAC_TEST_NATIVE_DAEMON_BIN"), os.Getenv("OAC_TEST_NATIVE_PROOF_DIR"), os.Getenv("OAC_TEST_FUNCTION_IMAGE_REAL_OPTIONS") - if python == "" || binary == "" || root == "" || optionsFile == "" { - t.Skip("native daemon, fixed SDK, real model options and evidence directory required") - } - model, provider := readNativeModelDefaults(t, optionsFile) - kind := os.Getenv("OAC_TEST_FUNCTION_IMAGE_ENGINE") - if kind != "codex" && kind != "claude_sdk" { - t.Fatal("image acceptance requires a specified native engine") - } - h := newDispatchHarness(t) - home, err := os.MkdirTemp(root, "function-image-public-") - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) - defer cancel() - worker := startWorker(t, ctx, h.s, h.d) - done := make(chan error, 1) - go func() { done <- worker.Run(ctx) }() - defer func() { - cancel() - select { - case <-done: - case <-time.After(20 * time.Second): - t.Error("worker did not stop") - } - }() - token, foreign := uuid.NewString(), uuid.NewString() - auth := newTestAuthenticator(t, []testAPIKey{ - {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, - {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, - }) - handler, err := publicHandler(t, h.s, auth, kind, workerExecution(t, worker), nativeDeploymentDefaults(model, provider)) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - stop := startNativeEngineDaemon(t, h, home, binary, kind) - defer func() { stop() }() - evidence := filepath.Join(home, "public.json") - run := func(stage string) { - cmd := exec.CommandContext(ctx, python, "../../tests/official_function_images.py", server.URL, token, foreign, model, stage, evidence) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("function images %s: %v %s; evidence %s", stage, err, output, home) - } - } - run("initial") - var proof struct { - Session string `json:"session"` - Calls []struct{ Turn, Call string } `json:"calls"` - } - raw, err := os.ReadFile(evidence) - if err != nil || json.Unmarshal(raw, &proof) != nil || len(proof.Calls) != 4 { - t.Fatal("invalid evidence", err) - } - for _, item := range proof.Calls { - call, err := FixtureFunctionCall(ctx, h.s.pool, h.tenant, proof.Session, item.Turn, item.Call) - if err != nil || !call.Applied { - t.Fatal("function delivery acknowledgement missing", err) - } - inputs, err := sessionAdapter(h.s).ListTurnInputs(ctx, h.tenant, proof.Session, item.Turn, 0, 100) - if err != nil || len(inputs) != 2 || inputs[0].Kind != "message" || inputs[1].Kind != "tool_result" { - t.Fatal("function result admission duplicated or mutated", err) - } - } - before, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID == "" { - t.Fatal("native binding missing", err) - } - stop() - stop = startNativeEngineDaemon(t, h, home, binary, kind) - run("resume") - after, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID != after.NativeSessionID { - t.Fatal("native history changed", err) - } - t.Logf("Real function image SDK/raw HTTP, delivery, cold daemon recovery and isolation passed: %s", home) -} diff --git a/services/core/tests/integration/function_model_test.go b/services/core/tests/integration/function_model_test.go deleted file mode 100644 index 2cb93e03b..000000000 --- a/services/core/tests/integration/function_model_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package integration - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "image" - "image/color" - "image/png" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "reflect" - "strings" - "sync/atomic" - "testing" -) - -func nativeFunctionModel(t *testing.T, home string) (*httptest.Server, []any, *atomic.Int32) { - t.Helper() - picture := image.NewRGBA(image.Rect(0, 0, 1, 1)) - picture.Set(0, 0, color.RGBA{R: 255, A: 255}) - var encoded bytes.Buffer - if err := png.Encode(&encoded, picture); err != nil { - t.Fatal(err) - } - output := []any{map[string]any{"type": "input_text", "text": "before"}, map[string]any{"type": "input_image", "image_url": "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())}, map[string]any{"type": "input_text", "text": "after"}} - expected := append([]any(nil), output...) - // Codex adds its default image detail at the model transport boundary. - expected[1] = map[string]any{"type": "input_image", "image_url": output[1].(map[string]any)["image_url"], "detail": "high"} - failure := append(append([]any(nil), expected...), map[string]any{"type": "input_text", "text": "synthetic failure"}) - model, requests := nativeFunctionResultsModel(t, home, []any{expected, failure}) - return model, output, requests -} - -func nativeFunctionResultsModel(t *testing.T, home string, results []any) (*httptest.Server, *atomic.Int32) { - t.Helper() - var requests atomic.Int32 - model := nativeModelServer(t, home, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - return - } - assertNativeSubagentsDisabled(t, body) - n := requests.Add(1) - raw, _ := json.MarshalIndent(body, "", " ") - _ = os.WriteFile(filepath.Join(home, fmt.Sprintf("functions-model-%d.json", n)), raw, 0600) - var config struct { - Agent struct{ Tools []map[string]any } - } - if err := json.Unmarshal([]byte(functionConfiguration), &config); err != nil { - t.Error(err) - return - } - expectedTool := config.Agent.Tools[0] - delete(expectedTool, "defer_loading") - expectedTool["strict"] = false - foundTool := false - for _, tool := range body["tools"].([]any) { - if reflect.DeepEqual(tool, expectedTool) { - foundTool = true - } - } - if !foundTool { - t.Error("configured function changed at the model boundary") - } - var entry map[string]any - if n%2 == 1 { - entry = map[string]any{"id": fmt.Sprintf("fc_%d", n), "type": "function_call", "call_id": fmt.Sprintf("call_%d", n), "name": "lookup_ticket", "arguments": `{"ticket":"42"}`, "status": "completed"} - } else { - found := false - for _, value := range body["input"].([]any) { - item := value.(map[string]any) - if item["type"] != "function_call_output" || item["call_id"] != fmt.Sprintf("call_%d", n-1) { - continue - } - found = true - if int(n/2) > len(results) { - t.Error("unexpected native result continuation", n) - return - } - expected := results[n/2-1] - if !reflect.DeepEqual(item["output"], expected) { - t.Errorf("complete result changed: %v", item["output"]) - } - } - if !found { - t.Error("native model did not receive stored result") - } - entry = map[string]any{"id": fmt.Sprintf("message_%d", n), "type": "message", "role": "assistant", "phase": "final_answer", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": "FUNCTION-EXECUTION-OK", "annotations": []any{}}}} - } - w.Header().Set("Content-Type", "text/event-stream") - send := func(kind string, data map[string]any) { - data["type"] = kind - raw, _ := json.Marshal(data) - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, raw) - w.(http.Flusher).Flush() - } - send("response.created", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "status": "in_progress", "output": []any{}}}) - send("response.output_item.added", map[string]any{"output_index": 0, "item": entry}) - send("response.output_item.done", map[string]any{"output_index": 0, "item": entry}) - send("response.completed", map[string]any{"response": map[string]any{"id": fmt.Sprintf("r_%d", n), "object": "response", "created_at": 0, "status": "completed", "model": "gpt-5.5", "output": []any{entry}}}) - })) - return model, &requests -} - -func assertNativeSubagentsDisabled(t *testing.T, body map[string]any) { - t.Helper() - raw, err := json.Marshal(body["tools"]) - if err != nil { - t.Fatal(err) - } - for _, forbidden := range []string{"Multi-agent tools:", "spawn_agent", "send_input", "wait_agent", "resume_agent", "close_agent", "send_message_to_agent"} { - if strings.Contains(string(raw), forbidden) { - t.Errorf("disabled subagent tool remains discoverable: %s", forbidden) - } - } -} diff --git a/services/core/tests/integration/function_public_native_test.go b/services/core/tests/integration/function_public_native_test.go deleted file mode 100644 index 0da28f9c9..000000000 --- a/services/core/tests/integration/function_public_native_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -func TestNativePublicFunctionExecution(t *testing.T) { - python := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON") - if python == "" { - t.Skip("pinned official Python SDK required") - } - h, ctx, home := nativeDispatchHarness(t) - model, output, requests := nativeFunctionModel(t, home) - defer model.Close() - serverURL, token := nativePublicFunctionServer(t, h, ctx, nativeModelProvider(model)) - outputPath, proofPath := filepath.Join(home, "function-output.json"), filepath.Join(home, "public-functions.json") - raw, _ := json.Marshal(output) - if err := os.WriteFile(outputPath, raw, 0600); err != nil { - t.Fatal(err) - } - command := exec.CommandContext(ctx, python, "../../tests/official_functions.py", serverURL, token, outputPath, proofPath) - if log, err := command.CombinedOutput(); err != nil { - t.Fatalf("official native functions: %v %s", err, log) - } - var proof struct { - Session string `json:"session"` - Turns []string `json:"turns"` - Calls []string `json:"calls"` - } - raw, err := os.ReadFile(proofPath) - if err != nil || json.Unmarshal(raw, &proof) != nil || len(proof.Turns) != 3 || len(proof.Calls) != 3 { - t.Fatal(proof, err) - } - for i, callID := range proof.Calls { - call, err := FixtureFunctionCall(ctx, h.s.pool, h.tenant, proof.Session, proof.Turns[i], callID) - if err != nil || call.Applied != (i < 2) { - t.Fatal(call, err) - } - } - bound, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || bound.NativeSessionID == "" || bound.Device.ID != h.device.ID { - t.Fatal(bound, err) - } - if requests.Load() != 5 { - t.Fatal("unexpected replay or missing native continuation", requests.Load()) - } - t.Logf("Official SDK configured functions, native text/image/error results, application receipts, next Turn and cancellation passed; evidence %s", home) -} - -func nativePublicFunctionServer(t *testing.T, h *dispatchHarness, ctx context.Context, provider *v1.ModelProviderInput) (string, string) { - t.Helper() - worker := startWorker(t, ctx, h.s, h.d) - ctx, cancel := context.WithCancel(ctx) - t.Cleanup(cancel) - done := make(chan error, 1) - go func() { done <- worker.Run(ctx) }() - t.Cleanup(func() { - cancel() - select { - case <-done: - case <-time.After(15 * time.Second): - t.Error("worker did not stop") - } - }) - token := uuid.NewString() - auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}}) - handler, err := publicHandler(t, h.s, auth, "codex", workerExecution(t, worker), nativeDeploymentDefaults("gpt-5.5", provider)) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - return server.URL, token -} diff --git a/services/core/tests/integration/function_stream_native_test.go b/services/core/tests/integration/function_stream_native_test.go deleted file mode 100644 index 29fcf4954..000000000 --- a/services/core/tests/integration/function_stream_native_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package integration - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "testing" -) - -func TestNativePublicFunctionStreamHelper(t *testing.T) { - python := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON") - if python == "" { - t.Skip("pinned official Python SDK required") - } - h, ctx, home := nativeDispatchHarness(t) - // Codex sends a result made of one input_text item as a plain string output. - model, requests := nativeFunctionResultsModel(t, home, []any{ - "setup complete", - `{"ticket":"42","status":"open"}`, - "Tool handler failed.", - }) - defer model.Close() - serverURL, token := nativePublicFunctionServer(t, h, ctx, nativeModelProvider(model)) - proofPath := filepath.Join(home, "public-function-stream.json") - command := exec.CommandContext(ctx, python, "../../tests/official_function_stream.py", serverURL, token, proofPath) - if log, err := command.CombinedOutput(); err != nil { - t.Fatalf("official native stream helper: %v %s", err, log) - } - var proof struct { - Session string `json:"session"` - Turns []string `json:"turns"` - Calls []string `json:"calls"` - } - raw, err := os.ReadFile(proofPath) - if err != nil || json.Unmarshal(raw, &proof) != nil || len(proof.Turns) != 2 || len(proof.Calls) != 2 { - t.Fatal(proof, err) - } - for i, callID := range proof.Calls { - call, err := FixtureFunctionCall(ctx, h.s.pool, h.tenant, proof.Session, proof.Turns[i], callID) - if err != nil || !call.Applied { - t.Fatal(call, err) - } - } - bound, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || bound.NativeSessionID == "" || bound.Device.ID != h.device.ID { - t.Fatal(bound, err) - } - if requests.Load() != 6 { - t.Fatal("unexpected replay or missing native continuation", requests.Load()) - } - t.Logf("Official SDK stream tool handlers, error omission, public history and native application passed; evidence %s", home) -} diff --git a/services/core/tests/integration/mcode_public_native_test.go b/services/core/tests/integration/mcode_public_native_test.go deleted file mode 100644 index 84c6f6ab0..000000000 --- a/services/core/tests/integration/mcode_public_native_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -// This opt-in fixture never supplies model responses. The provider options must -// name a real API; private operator files are deliberately outside the repository. -func TestNativeMCodePublicExecution(t *testing.T) { - python, binary, root, optionsFile := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON"), os.Getenv("OAC_TEST_NATIVE_DAEMON_BIN"), os.Getenv("OAC_TEST_NATIVE_PROOF_DIR"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") - if python == "" || binary == "" || root == "" || optionsFile == "" { - t.Skip("native daemon, fixed SDK, private real-model options and proof directory required") - } - model, provider := readNativeModelDefaults(t, optionsFile) - h := newDispatchHarness(t) - home, err := os.MkdirTemp(root, "mcode-public-") - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 12*time.Minute) - defer cancel() - worker := startWorker(t, ctx, h.s, h.d) - stopped := make(chan error, 1) - go func() { stopped <- worker.Run(ctx) }() - defer func() { - cancel() - select { - case <-stopped: - case <-time.After(20 * time.Second): - t.Error("worker did not stop") - } - }() - token, foreign := uuid.NewString(), uuid.NewString() - auth := newTestAuthenticator(t, []testAPIKey{ - {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, - {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, - }) - handler, err := publicHandler(t, h.s, auth, "mcode", workerExecution(t, worker), acceptUnavailable(t), nativeDeploymentDefaults(model, provider)) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - stop := startNativeEngineDaemon(t, h, home, binary, "mcode") - defer func() { stop() }() - evidence := filepath.Join(home, "public.json") - run := func(stage string) { - command := exec.CommandContext(ctx, python, "../../tests/official_mcode_native.py", server.URL, token, foreign, model, stage, evidence) - if log, err := command.CombinedOutput(); err != nil { - data, _ := os.ReadFile(evidence) - var identity struct { - Session string `json:"session"` - } - _ = json.Unmarshal(data, &identity) - if page, e := sessionAdapter(h.s).ListTurns(ctx, h.tenant, identity.Session, "", 100, true); e == nil { - diagnostic, _ := json.Marshal(page) - text := strings.ReplaceAll(string(diagnostic), provider.APIKey, "[REDACTED]") - _ = os.WriteFile(filepath.Join(home, "failed-turns.json"), []byte(text), 0600) - } - t.Fatalf("public mcode %s failed: %v %s; evidence %s", stage, err, log, home) - } - } - run("initial") - data, err := os.ReadFile(evidence) - if err != nil { - t.Fatal(err) - } - var proof struct { - Session string `json:"session"` - FirstTurn string `json:"first_turn"` - } - if json.Unmarshal(data, &proof) != nil { - t.Fatal("invalid evidence") - } - turn, err := sessionAdapter(h.s).GetTurn(ctx, h.tenant, proof.Session, proof.FirstTurn) - if err != nil { - t.Fatal(err) - } - inputs, err := sessionAdapter(h.s).ListTurnInputs(ctx, h.tenant, proof.Session, proof.FirstTurn, 0, 100) - if err != nil || len(inputs) != 2 { - t.Fatal("steering input not in same turn", err) - } - var outcome execution.Result - if json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.AppliedThrough != inputs[1].Sequence { - t.Fatal("native applied receipt missing") - } - before, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID == "" { - t.Fatal("native binding missing", err) - } - stop() - stop = startNativeEngineDaemon(t, h, home, binary, "mcode") - run("resume") - after, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID != after.NativeSessionID { - t.Fatal("native history changed", err) - } - if err := os.WriteFile(filepath.Join(home, "native-session-id"), []byte(after.NativeSessionID), 0600); err != nil { - t.Fatal(err) - } - t.Logf("Real mcode common-contract acceptance passed: %s", home) -} - -func startNativeEngineDaemon(t *testing.T, h *dispatchHarness, home, binary, engine string) func() { - t.Helper() - if h.conn != nil { - _ = h.conn.Close() - } - profile := filepath.Join(home, "daemon", "execution") - if err := os.MkdirAll(profile, 0700); err != nil { - t.Fatal(err) - } - auth, _ := json.Marshal(map[string]string{"server_url": h.url + "/api/v1", "runtime_id": h.device.ID, "runner_credential": h.credential, "device_name": "native proof"}) - if err := os.WriteFile(filepath.Join(profile, "auth.json"), auth, 0600); err != nil { - t.Fatal(err) - } - log, err := os.OpenFile(filepath.Join(home, "daemon.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) - if err != nil { - t.Fatal(err) - } - old, _ := h.registry.LookupDevice(h.device.ID) - cmd := exec.Command(binary, "connect", "--profile", "execution") - cmd.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+home) - cmd.Stdout, cmd.Stderr = log, log - if err = cmd.Start(); err != nil { - log.Close() - t.Fatal(err) - } - done := make(chan struct{}) - go func() { _ = cmd.Wait(); close(done) }() - stop := func() { - select { - case <-done: - return - default: - } - _ = cmd.Process.Signal(os.Interrupt) - select { - case <-done: - case <-time.After(10 * time.Second): - _ = cmd.Process.Kill() - <-done - } - _ = log.Close() - } - t.Cleanup(stop) - deadline := time.Now().Add(45 * time.Second) - for time.Now().Before(deadline) { - if peer, err := h.registry.LookupDevice(h.device.ID); err == nil && peer != old { - if info, found, known := peer.AgentKindStatus(engine); found && known && info.Available && info.Capabilities.EnvironmentNone.IsSupported() { - return stop - } - } - select { - case <-done: - t.Fatalf("daemon exited; evidence %s", home) - default: - } - time.Sleep(100 * time.Millisecond) - } - t.Fatalf("native daemon not ready; evidence %s", home) - return stop -} diff --git a/services/core/tests/integration/message_images_native_test.go b/services/core/tests/integration/message_images_native_test.go deleted file mode 100644 index 6f9818674..000000000 --- a/services/core/tests/integration/message_images_native_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -func TestNativeMessageImagePublicExecution(t *testing.T) { - python, binary, root, optionsFile := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON"), os.Getenv("OAC_TEST_NATIVE_DAEMON_BIN"), os.Getenv("OAC_TEST_NATIVE_PROOF_DIR"), os.Getenv("OAC_TEST_MESSAGE_IMAGE_REAL_OPTIONS") - if python == "" || binary == "" || root == "" || optionsFile == "" { - t.Skip("native daemon, fixed SDK, real model options and evidence directory required") - } - model, provider := readNativeModelDefaults(t, optionsFile) - kind := os.Getenv("OAC_TEST_MESSAGE_IMAGE_ENGINE") - if kind != "codex" && kind != "claude_sdk" { - t.Fatal("image acceptance requires a specified native engine") - } - h := newDispatchHarness(t) - home, err := os.MkdirTemp(root, "message-image-public-") - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) - defer cancel() - worker := startWorker(t, ctx, h.s, h.d) - done := make(chan error, 1) - go func() { done <- worker.Run(ctx) }() - defer func() { - cancel() - select { - case <-done: - case <-time.After(20 * time.Second): - t.Error("worker did not stop") - } - }() - token, foreign := uuid.NewString(), uuid.NewString() - auth := newTestAuthenticator(t, []testAPIKey{ - {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, - {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, - }) - handler, err := publicHandler(t, h.s, auth, kind, workerExecution(t, worker), nativeDeploymentDefaults(model, provider)) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - stop := startNativeEngineDaemon(t, h, home, binary, kind) - defer func() { stop() }() - evidence := filepath.Join(home, "public.json") - run := func(stage string) { - cmd := exec.CommandContext(ctx, python, "../../tests/official_message_images.py", server.URL, token, foreign, model, stage, evidence) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("message images %s: %v %s; evidence %s", stage, err, output, home) - } - } - run("initial") - var proof struct { - Session string `json:"session"` - Turn string `json:"turn"` - Call string `json:"call"` - } - raw, err := os.ReadFile(evidence) - if err != nil || json.Unmarshal(raw, &proof) != nil { - t.Fatal("invalid evidence", err) - } - call, err := FixtureFunctionCall(ctx, h.s.pool, h.tenant, proof.Session, proof.Turn, proof.Call) - if err != nil || !call.Applied { - t.Fatal("function application receipt missing", err) - } - turn, err := sessionAdapter(h.s).GetTurn(ctx, h.tenant, proof.Session, proof.Turn) - if err != nil { - t.Fatal(err) - } - var outcome execution.Result - if json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.AppliedThrough < 1 { - t.Fatal("native input receipt missing") - } - inputs, err := sessionAdapter(h.s).ListTurnInputs(ctx, h.tenant, proof.Session, proof.Turn, 0, 100) - if err != nil || len(inputs) != 3 || inputs[0].Kind != "message" || inputs[1].Kind != "message" || inputs[2].Kind != "tool_result" || outcome.AppliedThrough != inputs[2].Sequence { - t.Fatal("active image batch was not applied exactly once in the same Turn", err) - } - before, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID == "" { - t.Fatal("native binding missing", err) - } - stop() - stop = startNativeEngineDaemon(t, h, home, binary, kind) - run("resume") - after, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID != after.NativeSessionID { - t.Fatal("native history changed", err) - } - t.Logf("Real message image SDK/raw HTTP, active receipt, cold daemon recovery and isolation passed: %s", home) -} diff --git a/services/core/tests/integration/model_protocol_native_test.go b/services/core/tests/integration/model_protocol_native_test.go deleted file mode 100644 index b80e5a281..000000000 --- a/services/core/tests/integration/model_protocol_native_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "io" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -// TestNativeModelProtocolPublicExecution is opt-in and never fabricates a model -// response. Run separately for each private engine/protocol configuration. -func TestNativeModelProtocolPublicExecution(t *testing.T) { - python := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON") - binary := os.Getenv("OAC_TEST_NATIVE_DAEMON_BIN") - root := os.Getenv("OAC_TEST_NATIVE_PROOF_DIR") - optionsFile := os.Getenv("OAC_TEST_MODEL_PROTOCOL_OPTIONS") - if python == "" || binary == "" || root == "" || optionsFile == "" { - t.Skip("fixed SDK, native daemon, private model protocol options and proof directory required") - } - raw, err := os.ReadFile(optionsFile) - if err != nil { - t.Fatal("cannot read private model protocol options") - } - var options struct { - Engine string `json:"engine"` - Model string `json:"model"` - Provider v1.ModelProviderInput `json:"model_provider"` - HarnessConfig json.RawMessage `json:"harness_config"` - } - if json.Unmarshal(raw, &options) != nil || options.Model == "" || options.Provider.BaseURL == "" || options.Provider.APIKey == "" { - t.Fatal("invalid private model protocol options") - } - switch options.Engine { - case "codex", "claude_sdk", "mcode": - default: - t.Fatal("unsupported native test engine") - } - switch options.Provider.Protocol { - case "anthropic", "responses", "chat_completions": - default: - t.Fatal("unsupported native test protocol") - } - optionsFile, err = filepath.Abs(optionsFile) - if err != nil { - t.Fatal("cannot resolve private model protocol options") - } - h := newDispatchHarness(t) - home, err := os.MkdirTemp(root, "model-protocol-public-") - if err != nil { - t.Fatal("cannot create controlled evidence directory") - } - ctx, cancel := context.WithTimeout(t.Context(), 15*time.Minute) - defer cancel() - worker, err := startWorkerErr(t, ctx, h.s, h.d) - if err != nil { - t.Fatal("cannot start native execution worker") - } - done := make(chan error, 1) - go func() { done <- worker.Run(ctx) }() - defer func() { - cancel() - select { - case <-done: - case <-time.After(20 * time.Second): - t.Error("native execution worker did not stop") - } - }() - token := uuid.NewString() - auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}}) - providerRevision := uuid.New() - handler, err := publicHandler(t, h.s, auth, options.Engine, workerExecution(t, worker), modelProviderDefaults(func(context.Context, string) (*modelconfiguration.Snapshot, error) { - return &modelconfiguration.Snapshot{Model: options.Model, HarnessConfig: options.HarnessConfig, Provider: &options.Provider, Revision: providerRevision}, nil - })) - if err != nil { - t.Fatal("cannot create public API handler") - } - server := httptest.NewServer(handler) - defer server.Close() - stop := startNativeEngineDaemon(t, h, home, binary, options.Engine) - defer func() { stop() }() - evidence := filepath.Join(home, "public.json") - run := func(stage string) { - command := exec.CommandContext(ctx, python, "../../tests/official_model_protocol_native.py", server.URL, token, optionsFile, stage, evidence) - // Exceptions, SDK HTTP bodies and daemon diagnostics must never be echoed - // into the test log. The script writes only allowlisted proof summaries. - command.Stdout, command.Stderr = io.Discard, io.Discard - if command.Run() != nil { - t.Fatalf("native public model protocol %s failed; controlled evidence: %s", stage, evidence) - } - } - run("initial") - var proof struct { - Session string `json:"session"` - Calls []struct { - Turn string `json:"turn"` - Call string `json:"call"` - Success bool `json:"success"` - } `json:"calls"` - } - raw, err = os.ReadFile(evidence) - if err != nil || json.Unmarshal(raw, &proof) != nil || proof.Session == "" { - t.Fatal("missing controlled public proof") - } - expectedCalls := 3 - if options.Engine == "mcode" { - expectedCalls = 0 - } - if len(proof.Calls) != expectedCalls { - t.Fatal("unexpected public function call count") - } - failed := 0 - for _, item := range proof.Calls { - call, err := FixtureFunctionCall(ctx, h.s.pool, h.tenant, proof.Session, item.Turn, item.Call) - if err != nil || !call.Applied { - t.Fatal("public function result lacks native delivery acknowledgement") - } - inputs, err := sessionAdapter(h.s).ListTurnInputs(ctx, h.tenant, proof.Session, item.Turn, 0, 100) - if err != nil || len(inputs) != 2 || inputs[0].Kind != "message" || inputs[1].Kind != "tool_result" { - t.Fatal("public function result input was lost or duplicated") - } - if !item.Success { - failed++ - } - } - if expectedCalls > 0 && failed != 1 { - t.Fatal("missing failed function-result scenario") - } - before, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID == "" { - t.Fatal("native Session binding missing before restart") - } - stop() - stop = startNativeEngineDaemon(t, h, home, binary, options.Engine) - run("resume") - after, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID != after.NativeSessionID { - t.Fatal("cold Session continuation changed native history") - } - t.Logf("Native public model protocol acceptance passed (engine=%s protocol=%s); controlled evidence: %s", options.Engine, options.Provider.Protocol, evidence) -} diff --git a/services/core/tests/integration/native_daemon_test.go b/services/core/tests/integration/native_daemon_test.go deleted file mode 100644 index ac6cb9dee..000000000 --- a/services/core/tests/integration/native_daemon_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package integration - -import ( - "bytes" - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" - "encoding/pem" - "io" - "math/big" - "net" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" - "github.com/google/uuid" -) - -func nativeDispatchHarness(t *testing.T) (*dispatchHarness, context.Context, string) { - t.Helper() - return nativeDispatchHarnessWithTimeout(t, 120*time.Second) -} - -func nativeDispatchHarnessWithTimeout(t *testing.T, timeout time.Duration) (*dispatchHarness, context.Context, string) { - t.Helper() - binary, root := os.Getenv("OAC_TEST_NATIVE_DAEMON_BIN"), os.Getenv("OAC_TEST_NATIVE_PROOF_DIR") - if binary == "" || root == "" { - t.Skip("explicit native daemon binary and evidence directory required") - } - h := newDispatchHarness(t) - ctx, cancel := context.WithTimeout(context.Background(), timeout) - t.Cleanup(cancel) - home, err := os.MkdirTemp(root, "execution-native-") - if err != nil { - t.Fatal(err) - } - startNativeDispatchDaemon(t, h, home, binary) - return h, ctx, home -} - -func startNativeDispatchDaemon(t *testing.T, h *dispatchHarness, home, binary string) { - t.Helper() - oldPeer, _ := h.registry.LookupDevice(h.device.ID) - if h.conn != nil { - _ = h.conn.Close() - } - profile := filepath.Join(home, "daemon", "execution") - if err := os.MkdirAll(profile, 0700); err != nil { - t.Fatal(err) - } - auth, _ := json.Marshal(map[string]string{"server_url": h.url + "/api/v1", "runtime_id": h.device.ID, "runner_credential": h.credential, "device_name": "native proof"}) - if err := os.WriteFile(filepath.Join(profile, "auth.json"), auth, 0600); err != nil { - t.Fatal(err) - } - daemonLog, err := os.Create(filepath.Join(home, "daemon.log")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = daemonLog.Close() }) - cmd := exec.Command(binary, "connect", "--profile", "execution") - // The device trusts the synthetic model server's certificate, which - // nativeModelServer writes before the first Turn starts Codex. - cmd.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+home, "CODEX_CA_CERTIFICATE="+nativeModelCertificate(home)) - cmd.Stdout, cmd.Stderr = daemonLog, daemonLog - if err = cmd.Start(); err != nil { - t.Fatal(err) - } - stopped := make(chan error, 1) - go func() { stopped <- cmd.Wait() }() - t.Cleanup(func() { - _ = cmd.Process.Signal(os.Interrupt) - select { - case <-stopped: - case <-time.After(8 * time.Second): - _ = cmd.Process.Kill() - <-stopped - } - }) - deadline := time.Now().Add(20 * time.Second) - for { - peer, e := h.registry.LookupDevice(h.device.ID) - if e == nil && peer != oldPeer { - if info, found, known := peer.AgentKindStatus("codex"); known && found && info.Available && info.Capabilities.EnvironmentNone.IsSupported() { - break - } - } - if time.Now().After(deadline) { - t.Fatalf("native daemon not ready; logs %s", home) - } - time.Sleep(50 * time.Millisecond) - } -} - -func nativeModelCertificate(home string) string { return filepath.Join(home, "model-ca.pem") } - -// nativeModelServer serves a synthetic model over HTTPS, as the model provider -// contract requires, and writes the issuing CA for the native daemon to trust. -// Codex verifies with webpki, which rejects the self-signed CA certificate that -// httptest serves by default, so the server gets a leaf issued by a test CA. -func nativeModelServer(t *testing.T, home string, handler http.Handler) *httptest.Server { - t.Helper() - now := time.Now() - caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - caTemplate := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "synthetic model CA"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} - caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) - if err != nil { - t.Fatal(err) - } - ca, err := x509.ParseCertificate(caDER) - if err != nil { - t.Fatal(err) - } - leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - leafTemplate := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "127.0.0.1"}, IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} - leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, ca, &leafKey.PublicKey, caKey) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(nativeModelCertificate(home), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0600); err != nil { - t.Fatal(err) - } - model := httptest.NewUnstartedServer(handler) - model.TLS = &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{leafDER}, PrivateKey: leafKey}}} - model.StartTLS() - return model -} - -// nativeModelProvider is the provider bundle that reaches a synthetic model server. -func nativeModelProvider(model *httptest.Server) *v1.ModelProviderInput { - return &v1.ModelProviderInput{Protocol: "responses", BaseURL: model.URL + "/v1", APIKey: "synthetic-test-token"} -} - -// nativeDeploymentDefaults makes provider the deployment default model provider, -// which public environment:none Sessions freeze at creation. -func nativeDeploymentDefaults(model string, provider *v1.ModelProviderInput) func(*api.Dependencies) { - revision := uuid.New() - return modelProviderDefaults(func(context.Context, string) (*modelconfiguration.Snapshot, error) { - return &modelconfiguration.Snapshot{Model: model, Provider: provider, Revision: revision}, nil - }) -} - -// readNativeModelDefaults reads a private real-model file holding exactly the -// deployment default an operator would configure: {"model", "model_provider"}. -func readNativeModelDefaults(t *testing.T, path string) (string, *v1.ModelProviderInput) { - t.Helper() - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - var defaults struct { - Model string `json:"model"` - ModelProvider *v1.ModelProviderInput `json:"model_provider"` - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if decoder.Decode(&defaults) != nil || decoder.Decode(new(any)) != io.EOF || defaults.ModelProvider == nil { - t.Fatal("private options must hold only model and model_provider") - } - if defaults.ModelProvider.Validate() != nil { - t.Fatal("invalid private model_provider") - } - if defaults.Model == "" { - t.Fatal("real model required") - } - return defaults.Model, defaults.ModelProvider -} diff --git a/services/core/tests/integration/native_environment_test.go b/services/core/tests/integration/native_environment_test.go deleted file mode 100644 index dc02c61a2..000000000 --- a/services/core/tests/integration/native_environment_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package integration - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" -) - -func TestNativeNoExecutionEnvironment(t *testing.T) { - h, ctx, home := nativeDispatchHarness(t) - var requests atomic.Int32 - marker := filepath.Join(home, "must-not-exist") - model := nativeModelServer(t, home, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || !strings.HasSuffix(r.URL.Path, "/responses") { - http.NotFound(w, r) - return - } - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - return - } - if body["model"] == "custom-provider-model" && !strings.Contains(fmt.Sprint(body["input"]), "DEFAULT-VERBOSITY") { - t.Error("unsupported verbosity reached model execution") - } - assertNativeSubagentsDisabled(t, body) - for _, value := range body["tools"].([]any) { - tool := value.(map[string]any) - if kind, _ := tool["type"].(string); strings.HasPrefix(kind, "web_search") { - t.Error("undeclared web search reached the model") - } - } - - encoded, _ := json.Marshal(body) - expected := "medium" - for _, level := range []string{"low", "high"} { - if strings.Contains(string(encoded), "TEXT-VERBOSITY:"+level) { - expected = level - } - } - textConfig, _ := body["text"].(map[string]any) - if body["model"] == "custom-provider-model" { - if _, present := textConfig["verbosity"]; present { - t.Error("native default sent an unsupported verbosity override") - } - } else if textConfig["verbosity"] != expected { - t.Errorf("effective verbosity = %v, want %s", textConfig["verbosity"], expected) - } - n := requests.Add(1) - raw, _ := json.MarshalIndent(body, "", " ") - _ = os.WriteFile(filepath.Join(home, fmt.Sprintf("model-request-%d.json", n)), raw, 0600) - if strings.Contains(string(raw), "PUBLIC-CANCEL") { - w.Header().Set("Content-Type", "text/event-stream") - fmt.Fprint(w, "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"cancel_response\",\"status\":\"in_progress\",\"output\":[]}}\n\n") - w.(http.Flusher).Flush() - <-r.Context().Done() - return - } - var item map[string]any - if n == 1 { - args, _ := json.Marshal(map[string]string{"cmd": "touch " + marker}) - item = map[string]any{"id": "fc_forbidden", "type": "function_call", "call_id": "call_forbidden", "name": "exec_command", "arguments": string(args), "status": "completed"} - } else { - item = map[string]any{"id": fmt.Sprintf("message_%d", n), "type": "message", "role": "assistant", "phase": "final_answer", "status": "completed", "content": []any{map[string]any{"type": "output_text", "text": "NO-ENVIRONMENT-OK", "annotations": []any{}}}} - } - w.Header().Set("Content-Type", "text/event-stream") - send := func(kind string, data map[string]any) { - data["type"] = kind - b, _ := json.Marshal(data) - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", kind, b) - w.(http.Flusher).Flush() - } - send("response.created", map[string]any{"response": map[string]any{"id": fmt.Sprintf("response_%d", n), "status": "in_progress", "output": []any{}}}) - if item["type"] == "message" { - initial := map[string]any{"id": item["id"], "type": "message", "role": "assistant", "phase": "final_answer", "status": "in_progress", "content": []any{}} - send("response.output_item.added", map[string]any{"output_index": 0, "item": initial}) - send("response.content_part.added", map[string]any{"output_index": 0, "content_index": 0, "item_id": item["id"], "part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}}}) - for _, fragment := range []string{"NO-", "ENVIRONMENT-", "OK"} { - send("response.output_text.delta", map[string]any{"output_index": 0, "content_index": 0, "item_id": item["id"], "delta": fragment}) - } - time.Sleep(time.Second) - } else { - send("response.output_item.added", map[string]any{"output_index": 0, "item": item}) - } - send("response.output_item.done", map[string]any{"output_index": 0, "item": item}) - send("response.completed", map[string]any{"response": map[string]any{"id": fmt.Sprintf("response_%d", n), "object": "response", "created_at": time.Now().Unix(), "status": "completed", "model": "gpt-5.5", "output": []any{item}, "usage": map[string]any{"input_tokens": 10, "output_tokens": 3, "total_tokens": 13, "input_tokens_details": map[string]any{"cached_tokens": 4}, "output_tokens_details": map[string]any{"reasoning_tokens": 2}}}}) - })) - defer model.Close() - provider := nativeModelProvider(model) - config, _ := json.Marshal(map[string]any{"agent": map[string]string{"model": "gpt-5.5", "instructions": "Keep this instruction."}, "environment": map[string]string{"type": "none"}}) - var err error - h.session, err = h.s.CreateSession(ctx, h.tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "native-session", Configuration: config, ModelProvider: provider, ModelProviderSource: v1.ModelProviderSourceDeployment}) - if err != nil { - t.Fatal(err) - } - if err = bindSessionDevice(t, h.s, h.tenant, h.session.ID, h.device.ID); err != nil { - t.Fatal(err) - } - first := h.message("first", "Return an answer.") - h.finished(h.run(ctx, first.TurnID), sessions.TurnCompleted) - bound, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) - if err != nil || bound.NativeSessionID == "" { - t.Fatal(bound, err) - } - second := h.message("second", "Continue the same conversation.") - h.finished(h.run(ctx, second.TurnID), sessions.TurnCompleted) - again, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) - if err != nil || again.NativeSessionID != bound.NativeSessionID { - t.Fatal(again, err) - } - if requests.Load() != 3 { - t.Fatalf("expected rejected command and two answers; requests=%d; evidence %s", requests.Load(), home) - } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatalf("forbidden command may have executed: %v", err) - } - page, err := sessionAdapter(h.s).ListItems(ctx, h.tenant, h.session.ID, "", 100, true) - if err != nil { - t.Fatal(err) - } - answers := 0 - for _, item := range page.Items { - if item.Role == "assistant" && item.Status == "completed" && len(item.Content) > 0 && item.Content[0].Text != nil && *item.Content[0].Text == "NO-ENVIRONMENT-OK" { - answers++ - } - } - if answers != 2 { - t.Fatal(page) - } - verifyNativePublicExecution(t, h, ctx, home, provider) - t.Logf("Native environment none: command rejected, two Turns resumed and recovered. Evidence: %s", home) -} diff --git a/services/core/tests/integration/native_public_execution_test.go b/services/core/tests/integration/native_public_execution_test.go deleted file mode 100644 index 9fbf1e266..000000000 --- a/services/core/tests/integration/native_public_execution_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package integration - -import ( - "context" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -func verifyNativePublicExecution(t *testing.T, h *dispatchHarness, parent context.Context, evidence string, provider *v1.ModelProviderInput) { - t.Helper() - python := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON") - if python == "" { - t.Log("Public SDK proof requires OAC_TEST_OFFICIAL_SDK_PYTHON") - return - } - ctx, cancel := context.WithCancel(parent) - defer cancel() - // The Turns before this proof ran on the harness's execution Owner, so the - // Worker takes that lease rather than a second one. - worker := startOwnedWorker(t, ctx, h.s, h.d, h.owner()) - done := make(chan error, 1) - go func() { done <- worker.Run(ctx) }() - defer func() { - cancel() - select { - case <-done: - case <-time.After(15 * time.Second): - t.Error("worker did not stop") - } - }() - token, foreign := uuid.NewString(), uuid.NewString() - auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}}) - handler, err := publicHandler(t, h.s, auth, "codex", workerExecution(t, worker), nativeDeploymentDefaults("gpt-5.5", provider)) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - command := exec.CommandContext(ctx, python, "../../tests/official_execution.py", server.URL, token, foreign, filepath.Join(evidence, "public-execution.json")) - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("official SDK native execution: %v\n%s", err, output) - } - t.Logf("Official SDK public execution, retry identity, isolation, result recovery and native cancellation passed: %s", evidence) -} diff --git a/services/core/tests/integration/structured_output_native_test.go b/services/core/tests/integration/structured_output_native_test.go deleted file mode 100644 index afa3de2b6..000000000 --- a/services/core/tests/integration/structured_output_native_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -func TestNativeStructuredOutputPublicExecution(t *testing.T) { - python, binary, root, optionsFile := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON"), os.Getenv("OAC_TEST_NATIVE_DAEMON_BIN"), os.Getenv("OAC_TEST_NATIVE_PROOF_DIR"), os.Getenv("OAC_TEST_STRUCTURED_OUTPUT_REAL_OPTIONS") - if python == "" || binary == "" || root == "" || optionsFile == "" { - t.Skip("native daemon, fixed SDK, real model options and evidence directory required") - } - model, provider := readNativeModelDefaults(t, optionsFile) - h := newDispatchHarness(t) - home, err := os.MkdirTemp(root, "structured-public-") - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) - defer cancel() - worker := startWorker(t, ctx, h.s, h.d) - done := make(chan error, 1) - go func() { done <- worker.Run(ctx) }() - defer func() { - cancel() - select { - case <-done: - case <-time.After(20 * time.Second): - t.Error("worker did not stop") - } - }() - token, foreign := uuid.NewString(), uuid.NewString() - auth := newTestAuthenticator(t, []testAPIKey{ - {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, - {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, - }) - handler, err := publicHandler(t, h.s, auth, "claude_sdk", workerExecution(t, worker), nativeDeploymentDefaults(model, provider)) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - stop := startNativeEngineDaemon(t, h, home, binary, "claude_sdk") - defer func() { stop() }() - evidence := filepath.Join(home, "public.json") - run := func(stage string) { - cmd := exec.CommandContext(ctx, python, "../../tests/official_structured_output.py", server.URL, token, foreign, model, stage, evidence) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("structured output %s: %v %s; evidence %s", stage, err, output, home) - } - } - run("initial") - var proof struct { - Session string `json:"session"` - Turn string `json:"turn"` - Call string `json:"call"` - } - raw, err := os.ReadFile(evidence) - if err != nil || json.Unmarshal(raw, &proof) != nil { - t.Fatal("invalid evidence", err) - } - call, err := FixtureFunctionCall(ctx, h.s.pool, h.tenant, proof.Session, proof.Turn, proof.Call) - if err != nil || !call.Applied { - t.Fatal("function application receipt missing", err) - } - turn, err := sessionAdapter(h.s).GetTurn(ctx, h.tenant, proof.Session, proof.Turn) - if err != nil { - t.Fatal(err) - } - var outcome execution.Result - if json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.Done.Usage.Raw["claude_sdk_result"] == nil || outcome.AppliedThrough < 1 { - t.Fatal("native usage or input receipt missing") - } - before, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID == "" { - t.Fatal("native binding missing", err) - } - stop() - stop = startNativeEngineDaemon(t, h, home, binary, "claude_sdk") - run("resume") - after, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID != after.NativeSessionID { - t.Fatal("native history changed", err) - } - t.Logf("Real structured output SDK/raw HTTP, function receipt, cold daemon recovery and isolation passed: %s", home) -} diff --git a/services/core/tests/integration/tool_policy_native_test.go b/services/core/tests/integration/tool_policy_native_test.go deleted file mode 100644 index f12193e93..000000000 --- a/services/core/tests/integration/tool_policy_native_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -// Run once per engine with a real provider and a daemon containing that adapter. -// Native tool inventory qualification is separate from these public API checks. -func TestNativeToolPolicyPublicExecution(t *testing.T) { - python, binary, root, optionsFile := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON"), os.Getenv("OAC_TEST_NATIVE_DAEMON_BIN"), os.Getenv("OAC_TEST_NATIVE_PROOF_DIR"), os.Getenv("OAC_TEST_TOOL_POLICY_REAL_OPTIONS") - if python == "" || binary == "" || root == "" || optionsFile == "" { - t.Skip("native daemon, pinned SDK, private real-model options and evidence directory required") - } - kind := os.Getenv("OAC_TEST_TOOL_POLICY_ENGINE") - if kind != "codex" && kind != "claude_sdk" && kind != "mcode" { - t.Fatal("tool policy acceptance requires codex, claude_sdk or mcode") - } - model, provider := readNativeModelDefaults(t, optionsFile) - h := newDispatchHarness(t) - home, err := os.MkdirTemp(root, "tool-policy-"+kind+"-") - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 15*time.Minute) - defer cancel() - worker := startWorker(t, ctx, h.s, h.d) - done := make(chan error, 1) - go func() { done <- worker.Run(ctx) }() - defer func() { - cancel() - select { - case <-done: - case <-time.After(20 * time.Second): - t.Error("worker did not stop") - } - }() - token, foreign, foreignTenant := uuid.NewString(), uuid.NewString(), uuid.NewString() - auth := newTestAuthenticator(t, []testAPIKey{ - {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, - {OrganizationID: "test", ProjectID: foreignTenant, SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: foreignTenant}, - }) - handler, err := publicHandler(t, h.s, auth, kind, workerExecution(t, worker), nativeDeploymentDefaults(model, provider)) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - stop := startNativeEngineDaemon(t, h, home, binary, kind) - defer func() { stop() }() - evidence := filepath.Join(home, "public.json") - run := func(stage string) { - cmd := exec.CommandContext(ctx, python, "../../tests/official_tool_policy.py", server.URL, token, foreign, model, stage, evidence) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("tool policy %s: %v %s; evidence %s", stage, err, output, home) - } - } - run("initial") - var proof struct { - Sessions []struct { - ID string `json:"id"` - FirstTurn string `json:"first_turn"` - } `json:"sessions"` - } - raw, err := os.ReadFile(evidence) - if err != nil || json.Unmarshal(raw, &proof) != nil || len(proof.Sessions) != 4 { - t.Fatal("invalid public evidence", err) - } - page, err := sessionAdapter(h.s).ListSessions(ctx, h.tenant, "", 100, true, nil) - if err != nil || len(page.Sessions) != 1+len(proof.Sessions) { - t.Fatal("rejected configuration persisted a Session", err) - } - foreignPage, err := sessionAdapter(h.s).ListSessions(ctx, foreignTenant, "", 100, true, nil) - if err != nil || len(foreignPage.Sessions) != 0 { - t.Fatal("foreign Agent reference persisted a Session", err) - } - nativeIDs := make(map[string]string, len(proof.Sessions)) - for _, item := range proof.Sessions { - session, err := sessionAdapter(h.s).GetSession(ctx, h.tenant, item.ID) - if err != nil || session.Engine != kind { - t.Fatal("selected engine was not persisted", err) - } - turn, err := sessionAdapter(h.s).GetTurn(ctx, h.tenant, item.ID, item.FirstTurn) - if err != nil { - t.Fatal(err) - } - inputs, err := sessionAdapter(h.s).ListTurnInputs(ctx, h.tenant, item.ID, item.FirstTurn, 0, 100) - var outcome execution.Result - if err != nil || len(inputs) != 1 || json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.AppliedThrough != inputs[0].Sequence { - t.Fatal("native text input receipt missing", err) - } - binding, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, item.ID) - if err != nil || binding.NativeSessionID == "" { - t.Fatal("native binding missing", err) - } - nativeIDs[item.ID] = binding.NativeSessionID - } - stop() - stop = startNativeEngineDaemon(t, h, home, binary, kind) - run("resume") - for id, before := range nativeIDs { - after, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, id) - if err != nil || after.NativeSessionID != before { - t.Fatal("cold continuation changed native history", err) - } - } - t.Logf("Real %s disabled tool policy SDK/raw HTTP, native receipts, cold continuation and tenant isolation passed: %s", kind, home) -} diff --git a/services/core/tests/integration/tool_search_native_test.go b/services/core/tests/integration/tool_search_native_test.go deleted file mode 100644 index f5e950500..000000000 --- a/services/core/tests/integration/tool_search_native_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/google/uuid" -) - -func TestNativeToolSearchPublicExecution(t *testing.T) { - python, binary, root, optionsFile := os.Getenv("OAC_TEST_OFFICIAL_SDK_PYTHON"), os.Getenv("OAC_TEST_NATIVE_DAEMON_BIN"), os.Getenv("OAC_TEST_NATIVE_PROOF_DIR"), os.Getenv("OAC_TEST_TOOL_SEARCH_REAL_OPTIONS") - if python == "" || binary == "" || root == "" || optionsFile == "" { - t.Skip("native daemon, fixed SDK, real model options and evidence directory required") - } - model, provider := readNativeModelDefaults(t, optionsFile) - h := newDispatchHarness(t) - home, err := os.MkdirTemp(root, "tool-search-public-") - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) - defer cancel() - worker := startWorker(t, ctx, h.s, h.d) - done := make(chan error, 1) - go func() { done <- worker.Run(ctx) }() - defer func() { - cancel() - select { - case <-done: - case <-time.After(20 * time.Second): - t.Error("worker did not stop") - } - }() - token, foreign := uuid.NewString(), uuid.NewString() - auth := newTestAuthenticator(t, []testAPIKey{ - {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, - {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, - }) - handler, err := publicHandler(t, h.s, auth, "claude_sdk", workerExecution(t, worker), nativeDeploymentDefaults(model, provider)) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(handler) - defer server.Close() - stop := startNativeEngineDaemon(t, h, home, binary, "claude_sdk") - defer func() { stop() }() - evidence := filepath.Join(home, "public.json") - run := func(stage string) { - cmd := exec.CommandContext(ctx, python, "../../tests/official_tool_search.py", server.URL, token, foreign, model, stage, evidence) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("tool search %s: %v %s; evidence %s", stage, err, output, home) - } - } - run("initial") - var proof struct { - Session string `json:"session"` - Turn string `json:"turn"` - Call string `json:"call"` - } - raw, err := os.ReadFile(evidence) - if err != nil || json.Unmarshal(raw, &proof) != nil { - t.Fatal("invalid evidence", err) - } - call, err := FixtureFunctionCall(ctx, h.s.pool, h.tenant, proof.Session, proof.Turn, proof.Call) - if err != nil || !call.Applied { - t.Fatal("function application receipt missing", err) - } - turn, err := sessionAdapter(h.s).GetTurn(ctx, h.tenant, proof.Session, proof.Turn) - if err != nil { - t.Fatal(err) - } - var outcome execution.Result - if json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.Done.Usage.Raw["claude_sdk_result"] == nil || outcome.AppliedThrough < 1 { - t.Fatal("native usage or input receipt missing") - } - before, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID == "" { - t.Fatal("native binding missing", err) - } - stop() - stop = startNativeEngineDaemon(t, h, home, binary, "claude_sdk") - run("resume") - after, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, proof.Session) - if err != nil || before.NativeSessionID != after.NativeSessionID { - t.Fatal("native history changed", err) - } - t.Logf("Real tool search SDK/raw HTTP, function receipt, cold daemon recovery and isolation passed: %s", home) -} diff --git a/services/core/tests/official_execution.py b/services/core/tests/official_execution.py deleted file mode 100644 index 27f828500..000000000 --- a/services/core/tests/official_execution.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Verify public execution against a native-daemon integration fixture.""" - -import importlib.metadata -import json -import sys -import time -from pathlib import Path - -import httpx2 -from openai import ConflictError, NotFoundError, OpenAI - - -def main(): - base, token, foreign_token, evidence = sys.argv[1:] - root = Path(__file__).resolve().parents[3] - pin = json.loads((root / "contracts/agents-api/upstream.json").read_text()) - source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json")) - assert source["vcs_info"]["commit_id"] == pin["commit"] - client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, - _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) - foreign = OpenAI(base_url=base + "/v1", api_key=foreign_token, max_retries=0, - _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) - sessions = client.beta.agents.sessions - - def message(*texts): - return {"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": text}]} for text in texts]} - - def wait_turn(session, status, count=1): - deadline = time.monotonic() + 25 - while time.monotonic() < deadline: - turns = sessions.turns.list(session, limit=100, order="asc").data - if len(turns) == count and turns[-1].status == status: - return turns[-1] - time.sleep(0.05) - raise AssertionError([(turn.id, turn.status, turn.error) for turn in turns]) - - def create(initial, **options): - return sessions.create(agent={"model": "gpt-5.5", "instructions": "Keep the conversation."}, - environment={"type": "none"}, input=initial, **options) - - def until_idle(stream): - events = [] - for event in stream: - events.append(event) - assert len(events) < 1000 - if event.type == "agent.session.turn.output_text.delta": - assert sessions.turns.retrieve(event.turn_id, session_id=event.session_id).status == "in_progress" - if event.type == "agent.session.idle": - assert event.session.status == "idle" - assert len({value.event_id for value in events}) == len(events) - return events - raise AssertionError("stream ended without an idle Session") - - try: - event = message("Search the web for this answer.", "Second message in the same event.") - creation_key = {"Idempotency-Key": "first"} - with create(event["input"], stream=True, extra_headers=creation_key) as stream: - session = next(stream).session - assert session.agent.tools == [] - assert create(event["input"], extra_headers=creation_key).id == session.id - first_events = until_idle(stream) - first = wait_turn(session.id, "completed") - assert sessions.retrieve(session.id).status == "idle" - expected_usage = {"input_tokens": 10, "input_tokens_details": {"cached_tokens": 4}, - "output_tokens": 3, "output_tokens_details": {"reasoning_tokens": 2}, "total_tokens": 13} - assert first.usage is not None and first.usage.model_dump() == expected_usage, first.usage - assert sessions.retrieve(session.id).usage.model_dump() == expected_usage - items = sessions.items.list(session.id, limit=100, order="asc").data - users = [item for item in items if item.type == "message" and item.role == "user"] - answers = [item for item in items if item.type == "message" and item.role == "assistant"] - assert len(users) == 2 and len(answers) == 1 - assert answers[0].content[0].text == "NO-ENVIRONMENT-OK" - types = [value.type for value in first_events] - for kind in ("created", "in_progress", "completed", "item.added", "item.done", - "content_part.added", "content_part.done", "output_text.delta", "output_text.done"): - assert "agent.session.turn." + kind in types, types - terminal = next(value for value in first_events if value.type == "agent.session.turn.completed") - assert terminal.turn.usage.model_dump() == expected_usage - # Top-level terminal usage mirrors the Turn snapshot; other events omit it. - assert terminal.usage.model_dump() == expected_usage - assert all("usage" not in value.to_dict() for value in first_events if value is not terminal) - assert first_events[-1].session.usage.model_dump() == expected_usage - text_events = [value for value in first_events if value.type.startswith("agent.session.turn.output_text.")] - assert all(value.item_id == answers[0].id and value.output_index == 0 and value.content_index == 0 for value in text_events) - assert text_events[-1].text == answers[0].content[0].text - deltas = [value.delta for value in text_events if value.type.endswith(".delta")] - assert len(deltas) >= 2 and "".join(deltas) == answers[0].content[0].text, deltas - # Official sequence: the answer is added empty and in progress, then an - # empty part, deltas and completion (EVT-10). Input Items carry explicit - # null output_index and phase (EVT-09). - def about_answer(value): - item = getattr(value, "item", None) - return getattr(value, "item_id", None) == answers[0].id or (item is not None and item.id == answers[0].id) - answer_events = [value for value in first_events if about_answer(value)] - answer_types = [value.type.removeprefix("agent.session.turn.") for value in answer_events] - assert answer_types[:2] == ["item.added", "content_part.added"], answer_types - assert answer_types[-3:] == ["output_text.done", "content_part.done", "item.done"], answer_types - assert set(answer_types[2:-3]) == {"output_text.delta"}, answer_types - assert answer_events[0].item.status == "in_progress" and answer_events[0].item.content == [] - assert answer_events[1].part.text == "" - user_added = [value for value in first_events if value.type == "agent.session.turn.item.added" and value.item.role == "user"] - assert len(user_added) == 2 and all(value.output_index is None and "output_index" in value.to_dict() and - value.item.to_dict().get("phase", "missing") is None for value in user_added) - assert sessions.retrieve(session.id).to_dict()["agent"]["reasoning"] == {"effort": None, "summary": None} - try: - create(message("changed")["input"], extra_headers=creation_key) - raise AssertionError("changed retry accepted") - except ConflictError: - pass - try: - foreign.beta.agents.sessions.events.create(session.id, events=[event]) - raise AssertionError("foreign tenant admitted") - except NotFoundError: - pass - with sessions.events.stream(session.id, timeout=20, extra_headers={"Last-Event-ID": first_events[-1].event_id}) as stream: - continuation = message("Continue the same native conversation.") - for _ in range(2): - assert sessions.events.create(session.id, events=[continuation], idempotency_key="second") is None - second_events = until_idle(stream) - second = wait_turn(session.id, "completed", 2) - assert all(getattr(value, "turn_id", None) != first.id for value in second_events) - assert second.usage.model_dump() == expected_usage, second.usage - expected_total = {"input_tokens": 20, "input_tokens_details": {"cached_tokens": 8}, - "output_tokens": 6, "output_tokens_details": {"reasoning_tokens": 4}, "total_tokens": 26} - assert sessions.retrieve(session.id).usage.model_dump() == expected_total - assert create(event["input"], extra_headers=creation_key).id == session.id - sessions.events.create(session.id, events=[continuation], idempotency_key="second") - try: - sessions.events.create(session.id, events=[message("changed continuation")], idempotency_key="second") - raise AssertionError("changed event retry accepted") - except ConflictError: - pass - assert len(sessions.turns.list(session.id).data) == 2 - client.close() - client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, - _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) - sessions = client.beta.agents.sessions - assert sessions.turns.retrieve(second.id, session_id=session.id).status == "completed" - assert len(sessions.items.list(session.id, limit=100).data) == 5 - assert sessions.retrieve(session.id).usage.model_dump() == expected_total - assert sessions.turns.retrieve(first.id, session_id=session.id).usage.model_dump() == expected_usage - cancelled = create("PUBLIC-CANCEL") - wait_turn(cancelled.id, "in_progress") - time.sleep(0.5) - retained = sessions.items.list(cancelled.id, limit=100).data - with sessions.events.stream(cancelled.id, timeout=20) as stream: - sessions.events.create(cancelled.id, events=[{"type": "agent.session.input.cancel"}], idempotency_key="cancel") - cancelled_events = until_idle(stream) - stopped = wait_turn(cancelled.id, "cancelled") - assert any(value.type == "agent.session.turn.cancelled" and value.turn.id == stopped.id for value in cancelled_events) - assert not any(value.type == "agent.session.turn.created" for value in cancelled_events) - assert {item.id for item in retained} <= {item.id for item in sessions.items.list(cancelled.id, limit=100).data} - assert sessions.retrieve(cancelled.id).status == "idle" - for verbosity in ("low", "medium", "high"): - agent = {"model": "gpt-5.5", "text": {"verbosity": verbosity, "format": {"type": "text"}}} - key = "text-" + verbosity - configured = sessions.create(agent=agent, environment={"type": "none"}, input="TEXT-VERBOSITY:" + verbosity, extra_headers={"Idempotency-Key": key}) - assert configured.agent.text.model_dump() == {"format": {"type": "text"}, "verbosity": verbosity} - for count in (1, 2): - if count > 1: - sessions.events.create(configured.id, events=[message("TEXT-VERBOSITY:" + verbosity)]) - wait_turn(configured.id, "completed", count) - assert sessions.retrieve(configured.id).agent.text == configured.agent.text - agent["text"]["verbosity"] = "high" if verbosity != "high" else "low" - try: - sessions.create(agent=agent, environment={"type": "none"}, input="TEXT-VERBOSITY:" + verbosity, extra_headers={"Idempotency-Key": key}) - raise AssertionError("changed text configuration reused a retry key") - except ConflictError: - pass - default_agent = {"model": "custom-provider-model"} - default = sessions.create(agent=default_agent, environment={"type": "none"}, - input="DEFAULT-VERBOSITY", extra_headers={"Idempotency-Key": "native-default"}) - # A retry is compared as sent, so spelling out the default conflicts. - for text in (None, {"verbosity": None}, {"verbosity": "medium"}): - try: - sessions.create(agent=dict(default_agent, text=text), environment={"type": "none"}, - input="DEFAULT-VERBOSITY", extra_headers={"Idempotency-Key": "native-default"}) - raise AssertionError("a retry that spells out the default reused the key") - except ConflictError: - pass - for count in (1, 2): - if count > 1: - sessions.events.create(default.id, events=[message("DEFAULT-VERBOSITY")]) - wait_turn(default.id, "completed", count) - assert sessions.retrieve(default.id).agent.text.verbosity == "medium" - unsupported = sessions.create(agent={"model": "custom-provider-model", "text": {"verbosity": "high"}}, - environment={"type": "none"}, input="UNSUPPORTED-VERBOSITY") - failed = wait_turn(unsupported.id, "failed") - assert failed.error is not None and failed.error.code == "internal_error", failed.error - assert sessions.retrieve(unsupported.id).status == "failed" - Path(evidence).write_text(json.dumps({"session": session.id, "turns": [first.id, second.id], - "cancelled_session": cancelled.id, "cancelled_turn": stopped.id, - "stream_types": types, "reconnected_events": len(second_events), - "cancelled_events": [value.type for value in cancelled_events], - "verbosity_new_and_resumed": ["low", "medium", "high"], - "native_default_session": default.id, - "unsupported_verbosity": failed.error.model_dump()})) - finally: - client.close() - foreign.close() - - -if __name__ == "__main__": - main() diff --git a/services/core/tests/official_function_images.py b/services/core/tests/official_function_images.py deleted file mode 100644 index f23133896..000000000 --- a/services/core/tests/official_function_images.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Function images through pinned SDK/raw HTTP and a real native Runtime/model.""" -import base64 -import importlib.metadata -import json -import secrets -import sys -from pathlib import Path - -import httpx2 -from openai import OpenAI -from image_fixture import picture - -base, token, foreign, model, stage, evidence = sys.argv[1:] -pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) -dist = importlib.metadata.distribution("openai") -assert dist.version == pin["sdk_version"] -assert json.loads(dist.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] -http = httpx2.Client(trust_env=False, timeout=180) -client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, - _strict_response_validation=True, http_client=http) -sessions = client.beta.agents.sessions -headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} -proof = {"calls": [], "runs": []} if stage == "initial" else json.loads(Path(evidence).read_text()) - - -def save(): - Path(evidence).write_text(json.dumps(proof, indent=2)) - - -def text(value): - return {"type": "input_text", "text": value} - - -def post(sid, events, key=None, foreign_request=False): - hdr = {**headers} - if key: - hdr["Idempotency-Key"] = key - if foreign_request: - hdr["Authorization"] = "Bearer " + foreign - return http.post(base + "/v1/agents/sessions/" + sid + "/events", headers=hdr, json={"events": events}) - - -def items(sid): - return [i.to_dict() for i in sessions.items.list(sid, order="asc", limit=100).data] - - -def answer(sid, expected): - answers = [i for i in items(sid) if i["type"] == "message" and i["role"] == "assistant"] - assert answers, "missing model answer" - result = " ".join(c["text"] for c in answers[-1]["content"] if c["type"] == "output_text").lower() - positions = [result.find(name) for name in expected] - assert all(p >= 0 for p in positions) and positions == sorted(positions), result - - -def run(sid, output=None, expected=None, cancel=False, failed_text=False, validate=False, recall=False, creation=None): - events, handled = [], False - prompt = "Call get_visual exactly once. Read the image returned by that tool and reply with its four band colors from left to right. Do not call it again." - if recall: - prompt = "Without calling tools, recall the most recent image from get_visual and repeat its four band colors from left to right." - with (creation or sessions.events.stream(sid, timeout=180)) as stream: - if creation is None: - sessions.events.create(sid, events=[{"type": "agent.session.input.message", "input": [{"role": "user", "content": [text(prompt)]}]}]) - for event in stream: - events.append(event.to_dict()) - if event.type == "agent.session.requires_action": - assert not handled and not recall - handled = True - action = event.session.required_actions[0] - assert action.name == "get_visual" and len(event.session.required_actions) == 1 - if cancel: - sessions.events.create(sid, events=[{"type": "agent.session.input.cancel"}]) - else: - result = {"type": "agent.session.input.tool_result", "turn_id": action.turn_id, - "call_id": action.call_id, "success": not failed_text, "output": output} - if failed_text: - result["error"] = "No image available; reply only TOOL-ERROR-RECEIVED." - key = "result-" + action.call_id - if validate: - before = items(sid) - invalid_urls = ["https://example.test/image.png", "data:image/png;base64,?", "data:image/png;base64,AQID"] - # Claude rejects malformed/remote and error images before consuming a call. - if proof["kind"] == "claude_sdk": - invalid = [{**result, "output": [{"type": "input_image", "image_url": u}]} for u in invalid_urls] - invalid.append({**result, "success": False}) - for bad in invalid: - assert post(sid, [{"type": "agent.session.input.message", "input": "must not persist"}, bad], key).status_code == 400 - assert items(sid) == before - assert sessions.retrieve(sid).required_actions[0].call_id == action.call_id - assert post(sid, [result], foreign_request=True).status_code == 404 - assert post(sid, [{**result, "call_id": "unknown-call"}]).status_code in {400, 404, 409} - assert items(sid) == before - assert sessions.events.create(sid, events=[result], idempotency_key=key) is None - assert post(sid, [result], key).status_code == 202 - assert post(sid, [{**result, "output": "different"}], key).status_code == 409 - proof["calls"].append({"turn": action.turn_id, "call": action.call_id, "output": output, "success": not failed_text}) - assert event.type not in {"agent.session.failed", "agent.session.turn.failed"}, event.to_dict() - if event.type == "agent.session.idle": - break - else: - raise AssertionError("stream ended without idle") - proof["runs"].append(events) - save() - types = [e["type"] for e in events] - terminal = "agent.session.turn." + ("cancelled" if cancel else "completed") - assert types.index("agent.session.turn.created") < types.index(terminal) < types.index("agent.session.idle") - assert len({e["event_id"] for e in events}) == len(events) - assert handled != recall - assert sessions.turns.list(sid, order="desc").data[0].status == ("cancelled" if cancel else "completed") - if expected: - answer(sid, expected) - recovered = {i["call_id"]: i for i in items(sid) if i["type"] == "function_call_output"} - for call in proof["calls"]: - assert recovered[call["call"]]["output"] == call["output"] - assert "error" in recovered[call["call"]] - assert (recovered[call["call"]]["error"] is None) == call["success"] - - -try: - if stage == "initial": - import os - proof["kind"] = os.environ["OAC_TEST_FUNCTION_IMAGE_ENGINE"] - colors = ["red", "green", "blue", "yellow"] - secrets.SystemRandom().shuffle(colors) - proof["colors"] = colors - creation = sessions.create(agent={"model": model, "tools": [{"type": "function", "name": "get_visual", - "description": "Return a visual to inspect.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}}]}, environment={"type": "none"}, input="Call get_visual exactly once. Read the image returned by that tool and reply with its four band colors from left to right. Do not call it again.", stream=True) - session = next(creation).session - proof["session"] = sid = session.id - save() - for scale in [1, 15]: - output = [text("Read this visual."), {"type": "input_image", "image_url": picture(colors, scale)}, text("Return its four band colors in order.")] - run(sid, output, colors, validate=scale == 1, creation=creation if scale == 1 else None) - jpeg = base64.b64encode((Path(__file__).parent / "testdata/function-bands.jpg").read_bytes()).decode() - proof["colors"] = ["yellow", "blue", "red", "green"] - run(sid, [{"type": "input_image", "image_url": "data:image/jpeg;base64," + jpeg}], proof["colors"]) - # Failed text remains supported and must not be mistaken for failed images. - run(sid, [text("No image was returned.")], failed_text=True) - for suffix in ["", "/items", "/turns"]: - response = http.get(base + "/v1/agents/sessions/" + sid + suffix, headers={**headers, "Authorization": "Bearer " + foreign}) - assert response.status_code == 404 - else: - sid = proof["session"] - run(sid, expected=proof["colors"], recall=True) - run(sid, cancel=True) - assert len(sessions.turns.list(sid, limit=100).data) == 6 - save() -finally: - client.close() diff --git a/services/core/tests/official_function_stream.py b/services/core/tests/official_function_stream.py deleted file mode 100644 index 5a6a0e494..000000000 --- a/services/core/tests/official_function_stream.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Verify the pinned stream helper through the public API and native execution.""" - -import importlib.metadata -import json -from pathlib import Path -import sys - -import httpx2 -from openai import OpenAI - -base, token, evidence = sys.argv[1:] -pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) -source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json") or "{}") -assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] -assert importlib.metadata.version("openai") == pin["sdk_version"] -agent = {"model": "gpt-5.5", "tools": [{ - "type": "function", "name": "lookup_ticket", "description": "Read a synthetic ticket", - "parameters": {"type": "object", "properties": {"ticket": {"type": "string"}}, - "required": ["ticket"], "additionalProperties": False}, -}]} -# Function result Items always carry output and error, null when not submitted. -expected = [{"output": '{"ticket":"42","status":"open"}', "error": None}, {"output": None, "error": "Tool handler failed."}] -with OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, _strict_response_validation=True, - http_client=httpx2.Client(trust_env=False, timeout=30)) as client: - sessions = client.beta.agents.sessions - # Complete one controlled initialization Turn before exercising the idle-only - # pinned stream helper. This synthetic fixture does not claim live acceptance. - with sessions.create(agent=agent, environment={"type": "none"}, - input="Look up ticket 42 for stream helper setup.", stream=True) as creation: - session = next(creation).session - setup_turn = None - for event in creation: - if event.type == "agent.session.requires_action": - action = event.session.required_actions[0] - setup_turn = action.turn_id - sessions.events.create(session.id, events=[{ - "type": "agent.session.input.tool_result", "turn_id": action.turn_id, - "call_id": action.call_id, "success": True, - "output": [{"type": "input_text", "text": "setup complete"}], - }]) - assert event.type not in {"agent.session.failed", "agent.session.turn.failed"} - if event.type == "agent.session.idle": - break - else: - raise AssertionError("setup creation stream ended without idle") - assert setup_turn is not None - assert sessions.turns.retrieve(setup_turn, session_id=session.id).status == "completed" - turns, calls, handler_calls, observed = [], [], [], [] - for index in range(2): - def lookup_ticket(arguments): - assert arguments == {"ticket": "42"}, arguments - handler_calls.append(arguments) - if index == 1: - raise RuntimeError("private-handler-exception-must-not-be-exposed") - return {"ticket": arguments["ticket"], "status": "open"} - - with sessions.stream(session.id, input="Look up ticket 42", timeout=30, - tool_handlers={"lookup_ticket": lookup_ticket}, - idempotency_key="helper-" + str(index)) as stream: - events = [event.to_dict() for event in stream] - observed.append(events) - assert len(handler_calls) == index + 1, handler_calls - created = [event for event in events if event["type"] == "agent.session.turn.created"] - assert len(created) == 1, events - turn_id = created[0]["turn"]["id"] - turns.append(turn_id) - added = [event for event in events if event["type"] == "agent.session.turn.item.added"] - functions = [event for event in added if event["item"]["type"] == "function_call"] - assert len(functions) == 1 and functions[0]["turn_id"] == turn_id, events - call_id = functions[0]["item"]["call_id"] - calls.append(call_id) - results = [event for event in added if event["item"]["type"] == "function_call_output"] - assert len(results) == 1 and results[0]["turn_id"] == turn_id, events - assert "output_index" in results[0] and results[0]["output_index"] is None - result = results[0]["item"] - assert result["call_id"] == call_id - assert {key: result[key] for key in ("output", "error") if key in result} == expected[index], result - assert not any(event["type"] == "agent.session.turn.item.done" and - event["item"]["type"] == "function_call_output" for event in events) - terminal = [event for event in events if event["type"] in ( - "agent.session.turn.completed", "agent.session.turn.failed", "agent.session.turn.cancelled")] - assert len(terminal) == 1 and terminal[0]["type"] == "agent.session.turn.completed", events - assert terminal[0]["turn"]["id"] == turn_id - assert events.index(functions[0]) < events.index(results[0]) < events.index(terminal[0]) < len(events) - 1 - assert events[-1]["type"] == "agent.session.idle" and events[-1]["session"]["required_actions"] == [] - assert not any(event["type"] == "agent.session.failed" for event in events) - assert sessions.turns.retrieve(turn_id, session_id=session.id).status == "completed" - current = sessions.retrieve(session.id) - assert current.status == "idle" and current.required_actions == [] - items = sessions.items.list(session.id, limit=100, order="asc").data - results = {item.call_id: item.to_dict() for item in items if item.type == "function_call_output"} - assert len(results) == 3 and len(sessions.turns.list(session.id).data) == 3 - for index, call_id in enumerate(calls): - assert {key: results[call_id][key] for key in ("output", "error") if key in results[call_id]} == expected[index] - answers = [item for item in items if item.type == "message" and item.role == "assistant"] - assert len(answers) == 3 and all(item.content[0].text == "FUNCTION-EXECUTION-OK" for item in answers) - proof = {"session": session.id, "turns": turns, "calls": calls, "handler_calls": handler_calls, - "events": observed, "results": results, "setup_turn": setup_turn} - assert "private-handler-exception-must-not-be-exposed" not in json.dumps(proof) - Path(evidence).write_text(json.dumps(proof)) diff --git a/services/core/tests/official_functions.py b/services/core/tests/official_functions.py deleted file mode 100644 index 7eafd7c27..000000000 --- a/services/core/tests/official_functions.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Verify public function configuration and execution against a real daemon/Codex.""" - -import importlib.metadata -import json -from pathlib import Path -import sys - -import httpx2 -from openai import ConflictError, OpenAI - -base, token, output_path, evidence = sys.argv[1:] -pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) -source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json") or "{}") -assert source.get("vcs_info", {}).get("commit_id") == pin["commit"] -output = json.loads(Path(output_path).read_text()) -tool = {"type":"function","name":"lookup_ticket","description":"Read a synthetic ticket", - "parameters":{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":False}} -agent = {"model":"gpt-5.5","tools":[tool]} -with OpenAI(base_url=base+"/v1", api_key=token, max_retries=0, _strict_response_validation=True, - http_client=httpx2.Client(trust_env=False, timeout=30)) as client: - sessions = client.beta.agents.sessions - creation = sessions.create(agent=agent, environment={"type":"none"}, input="Look up ticket 42", stream=True, extra_headers={"Idempotency-Key":"functions"}) - session = next(creation).session - expected = dict(tool, defer_loading=False) - assert session.agent.tools[0].to_dict() == expected, session.agent.tools - tool["defer_loading"] = False - assert sessions.create(agent=agent, environment={"type":"none"}, input="Look up ticket 42", extra_headers={"Idempotency-Key":"functions"}).id == session.id - tool["description"] = "changed" - try: - sessions.create(agent=agent, environment={"type":"none"}, input="Look up ticket 42", extra_headers={"Idempotency-Key":"functions"}) - raise AssertionError("changed tools reused a creation identity") - except ConflictError: - pass - turns, calls = [], [] - for index in range(3): - handled = False - with (creation if index == 0 else sessions.events.stream(session.id, timeout=30)) as stream: - message = {"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"Look up ticket 42"}]}]} - if index > 0: - sessions.events.create(session.id, events=[message], idempotency_key="message-"+str(index)) - for event in stream: - if event.type in ("agent.session.turn.item.added", "agent.session.turn.item.done") and event.item.type == "function_call_output": - assert event.type == "agent.session.turn.item.added" and event.output_index is None - assert "output_index" in event.to_dict() - submitted = event.item.to_dict() - assert submitted["output"] == output - if index == 1: - assert submitted["error"] == "synthetic failure" - else: - assert "error" in submitted and submitted["error"] is None - if event.type == "agent.session.requires_action" and not handled: - assert event.session.agent.tools[0].to_dict() == expected - action = event.session.required_actions[0] - assert action.name == "lookup_ticket" and action.arguments == {"ticket":"42"} - assert sessions.turns.retrieve(action.turn_id, session_id=session.id).status == "waiting" - turns.append(action.turn_id); calls.append(action.call_id); handled = True - if index == 2: - sessions.events.create(session.id, events=[{"type":"agent.session.input.cancel"}], idempotency_key="cancel") - else: - result = {"type":"agent.session.input.tool_result","turn_id":action.turn_id,"call_id":action.call_id,"success":index==0,"output":output} - if index == 1: - result["error"] = "synthetic failure" - for _ in range(2): - assert sessions.events.create(session.id, events=[result], idempotency_key="result-"+str(index)) is None - if event.type == "agent.session.idle": - assert handled and event.session.required_actions == [] - break - assert event.type != "agent.session.failed", event.to_dict() - assert sessions.turns.retrieve(turns[-1], session_id=session.id).status == ("cancelled" if index==2 else "completed") - assert len(sessions.turns.list(session.id).data) == index+1 - items = sessions.items.list(session.id, limit=100, order="asc").data - assert all(any(item.type=="function_call" and item.call_id==call for item in items) for call in calls) - results = {item.call_id: item.to_dict() for item in items if item.type=="function_call_output"} - assert len(results)==2 - for index, call in enumerate(calls[:2]): - assert results[call]["output"] == output - if index == 1: - assert results[call]["error"] == "synthetic failure" - else: - assert "error" in results[call] and results[call]["error"] is None - answers = [item for item in items if item.type=="message" and item.role=="assistant"] - assert len(answers)==2 and all(item.content[0].text=="FUNCTION-EXECUTION-OK" for item in answers) - assert sessions.retrieve(session.id).agent.tools[0].to_dict() == expected - Path(evidence).write_text(json.dumps({"session":session.id,"turns":turns,"calls":calls,"configured_tool":expected})) diff --git a/services/core/tests/official_mcode_native.py b/services/core/tests/official_mcode_native.py deleted file mode 100644 index 4927fcebb..000000000 --- a/services/core/tests/official_mcode_native.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Opt-in real-model MiniMax Code acceptance through the pinned public client.""" -import importlib.metadata -import json -import sys -import time -import uuid -from pathlib import Path - -import httpx2 -from openai import OpenAI - - -def main(): - base, token, foreign, model, stage, output = sys.argv[1:] - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - dist = importlib.metadata.distribution("openai") - assert dist.version == pin["sdk_version"] - assert json.loads(dist.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] - http = httpx2.Client(trust_env=False, timeout=300) - client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, - _strict_response_validation=True, http_client=http) - sessions = client.beta.agents.sessions - headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} - record = {} if stage == "initial" else json.loads(Path(output).read_text()) - - def message(text): - return {"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": text}]}]} - - def submit(sid, text, key): - sessions.events.create(sid, events=[message(text)], idempotency_key=key) - - def execute(sid, text, steer=False, cancel=False, creation=None): - types, submitted = [], False - with (creation or sessions.events.stream(sid, timeout=300)) as stream: - if creation is None: - submit(sid, text, str(uuid.uuid4())) - for event in stream: - types.append(event.type) - assert event.type != "agent.session.failed", event - if event.type == "agent.session.turn.output_text.delta" and not submitted: - submitted = True - if steer: - submit(sid, "Stop the list now and reply MCODE-STEERED.", "steer") - if cancel: - sessions.events.create(sid, events=[{"type": "agent.session.input.cancel"}]) - if event.type == "agent.session.idle": - break - end = "agent.session.turn.cancelled" if cancel else "agent.session.turn.completed" - assert end in types, types - assert types.index("agent.session.turn.created") < types.index(end) < len(types) - 1 - if steer or cancel: - assert submitted, "No native output to trigger the operation" - return types - - def answer(sid): - items = sessions.items.list(sid, order="asc", limit=100).data - answers = [i for i in items if i.type == "message" and i.role == "assistant"] - return "\n".join(c.text for c in answers[-1].content if c.type == "output_text") - - try: - if stage == "initial": - marker = "MCODE-MEMORY-" + uuid.uuid4().hex[:12] - prompt = "Remember " + marker + ". Write 120 numbered lines explaining addition, one sentence per line. Start immediately." - creation = sessions.create(agent={"model": model, "instructions": "Follow user instructions. Remember supplied markers. Do not use tools."}, environment={"type": "none"}, input=prompt, stream=True) - session = next(creation).session - record = {"session": session.id, "marker": marker, "model": model, "checks": []} - Path(output).write_text(json.dumps(record, indent=2)) - for agent_patch, environment in [({"tools": [{"type": "function", "name": "f", "parameters": {"type": "object"}}]}, {"type": "none"}), ({"text": {"verbosity": "high"}}, {"type": "none"})]: - r = http.post(base + "/v1/agents/sessions", headers=headers, json={"agent": {"model": model, **agent_patch}, "environment": environment, "input": "Verify native capability rejection."}) - assert r.status_code == 400, r.status_code - # This text-only fixture deliberately has no hosted provisioner. - r = http.post(base + "/v1/agents/sessions", headers=headers, json={ - "agent": {"model": model}, "environment": {"type": "openai_hosted"}}) - assert r.status_code == 503 and r.json()["error"]["code"] == "execution_unavailable" - record["initial_events"] = execute(session.id, prompt, steer=True, creation=creation) - turns = sessions.turns.list(session.id, order="asc", limit=100).data - assert len(turns) == 1 and turns[0].status == "completed", [(t.id, t.status) for t in turns] - record["first_turn"] = turns[0].id - record["checks"] += ["real_native_execution", "active_steering_same_turn", "unsupported_operations_rejected"] - else: - sid = record["session"] - execute(sid, "Reply with the MCODE-MEMORY marker I gave you earlier, and nothing else.") - assert record["marker"] in answer(sid), "Cold native history was not continued" - foreign_headers = {**headers, "Authorization": "Bearer " + foreign} - for path in ["", "/items", "/turns"]: - assert http.get(base + "/v1/agents/sessions/" + sid + path, headers=foreign_headers).status_code == 404 - # The input must remain ordinary text instead of triggering ACP /model. - execute(sid, "/model") - assert sessions.turns.list(sid, order="asc", limit=100).data[-1].status == "completed" - record["cancel_events"] = execute(sid, "Print the integers from 1 to 10000, one per line. Start with 1 immediately; no explanation or planning.", cancel=True) - execute(sid, "Reply with the original MCODE-MEMORY marker only.") - assert record["marker"] in answer(sid) - record["checks"] += ["cold_daemon_history_continuation", "foreign_tenant_rejected", "slash_text_execution", "cancel_and_continue"] - record["passed"] = True - Path(output).write_text(json.dumps(record, indent=2)) - finally: - client.close() - - -if __name__ == "__main__": - main() diff --git a/services/core/tests/official_message_images.py b/services/core/tests/official_message_images.py deleted file mode 100644 index d7aeee37c..000000000 --- a/services/core/tests/official_message_images.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Ordered image input through the pinned client, Core and a real native Runtime.""" -import importlib.metadata -import json -import secrets -import sys -import time -import uuid -from pathlib import Path - -import httpx2 -from openai import OpenAI -from image_fixture import picture - -base, token, foreign, model, stage, evidence = sys.argv[1:] -pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) -dist = importlib.metadata.distribution("openai") -assert dist.version == pin["sdk_version"] -assert json.loads(dist.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] -http = httpx2.Client(trust_env=False, timeout=180) -client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, - _strict_response_validation=True, http_client=http) -sessions = client.beta.agents.sessions -headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} -proof = {} if stage == "initial" else json.loads(Path(evidence).read_text()) - - -def save(): - Path(evidence).write_text(json.dumps(proof, indent=2)) - - -def text(value): - return {"type": "input_text", "text": value} - - -def messages(parts): - return [{"role": "user", "content": parts}] - - -def image_messages(url): - return messages([text("Read this image. "), {"type": "input_image", "image_url": url}, - text(" Remember this as the latest image.")]) + messages([ - text("Reply only with the four band colors from left to right, separated by commas. Do not use tools.")]) - - -def message_event(value): - return {"type": "agent.session.input.message", "input": value} - - -def answer(sid, expected): - items = sessions.items.list(sid, order="asc", limit=100).data - answers = [i for i in items if i.type == "message" and i.role == "assistant"] - assert answers, "missing native answer" - result = " ".join(c.text for c in answers[-1].content if c.type == "output_text").lower() - positions = [result.find(name) for name in expected] - assert all(p >= 0 for p in positions) and positions == sorted(positions), result - return answers[-1].id - - -def wait_initial(sid): - deadline = time.monotonic() + 150 - while time.monotonic() < deadline: - turns = sessions.turns.list(sid, order="asc", limit=100).data - if turns and turns[-1].status in {"completed", "failed", "cancelled"}: - assert len(turns) == 1 and turns[-1].status == "completed", [t.to_dict() for t in turns] - return turns[-1].id - time.sleep(.25) - raise AssertionError("initial input did not complete") - - -def run(sid, prompt, active=False, cancel=False): - events, handled = [], False - with sessions.events.stream(sid, timeout=180) as stream: - sessions.events.create(sid, events=[message_event(messages([text(prompt)]))]) - for event in stream: - events.append(event.to_dict()) - if event.type == "agent.session.requires_action": - assert not handled and (active or cancel) - handled = True - action = event.session.required_actions[0] - assert action.name == "wait_for_image" - if cancel: - sessions.events.create(sid, events=[{"type": "agent.session.input.cancel"}]) - else: - proof.update(turn=action.turn_id, call=action.call_id) - batch = [message_event(image_messages(proof["second_url"]))] - for _ in range(2): - sessions.events.create(sid, events=batch, idempotency_key="same-active-image") - sessions.events.create(sid, events=[{"type": "agent.session.input.tool_result", "turn_id": action.turn_id, - "call_id": action.call_id, "success": True, "output": "The latest image has been supplied. Read it and give the colors; do not call tools again."}]) - assert event.type not in {"agent.session.failed", "agent.session.turn.failed"}, event.to_dict() - if event.type == "agent.session.idle": - break - else: - raise AssertionError("stream ended without idle") - proof.setdefault("runs", []).append(events) - save() - terminal = "agent.session.turn." + ("cancelled" if cancel else "completed") - types = [e["type"] for e in events] - assert types.index("agent.session.turn.created") < types.index(terminal) < types.index("agent.session.idle") - assert len({e["event_id"] for e in events}) == len(events) - for index, event in enumerate(events): - if event["type"] == "agent.session.turn.item.done" and event["item"].get("role") == "assistant": - item_id = event["item"]["id"] - added = next(i for i, e in enumerate(events) if e["type"] == "agent.session.turn.item.added" and e["item"]["id"] == item_id) - assert added < index < types.index(terminal) - turns = sessions.turns.list(sid, order="asc", limit=100).data - assert turns[-1].status == ("cancelled" if cancel else "completed") - if active or cancel: - assert handled - return events - - -def check_items(sid): - expected = image_messages(proof["first_url"]) + messages([text("Call wait_for_image exactly once, then follow the incoming image instructions.")]) + image_messages(proof["second_url"]) - response = http.get(base + "/v1/agents/sessions/" + sid + "/items", headers=headers, params={"order": "asc", "limit": 100}) - assert response.status_code == 200 - actual = [i for i in response.json()["data"] if i["type"] == "message" and i["role"] == "user"] - assert [i["content"] for i in actual] == [m["content"] for m in expected] - assert [i.content for i in sessions.items.list(sid, order="asc", limit=100).data if i.type == "message" and i.role == "user"] - - -try: - if stage == "initial": - first = ["red", "green", "blue", "yellow"] - secrets.SystemRandom().shuffle(first) - second = first[1:] + first[:1] - proof.update(first=first, second=second, first_url=picture(first), second_url=picture(second)) - session = sessions.create(agent={"model": model, "tools": [{"type": "function", "name": "wait_for_image", - "description": "Wait for the user to supply the next image.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}}]}, - environment={"type": "none"}, input=image_messages(proof["first_url"])) - proof["session"] = session.id - save() - proof["initial_turn"] = wait_initial(session.id) - proof["initial_answer"] = answer(session.id, first) - run(session.id, "Call wait_for_image exactly once, then follow the incoming image instructions.", active=True) - proof["active_answer"] = answer(session.id, second) - assert len(sessions.turns.list(session.id).data) == 2 - check_items(session.id) - before = [t.id for t in sessions.turns.list(session.id).data] - invalid = [message_event(messages([text("must not be admitted")])), message_event(messages([{"type": "input_image", "image_url": "https://example.test/image.png"}]))] - response = http.post(base + "/v1/agents/sessions/" + session.id + "/events", headers=headers, json={"events": invalid}) - assert response.status_code == 400 - assert [t.id for t in sessions.turns.list(session.id).data] == before - check_items(session.id) - foreign_headers = {**headers, "Authorization": "Bearer " + foreign} - for suffix in ["", "/items", "/turns"]: - assert http.get(base + "/v1/agents/sessions/" + session.id + suffix, headers=foreign_headers).status_code == 404 - assert http.post(base + "/v1/agents/sessions/" + session.id + "/events", headers=foreign_headers, - json={"events": [message_event(image_messages(proof["second_url"]))]}).status_code == 404 - else: - sid = proof["session"] - check_items(sid) - run(sid, "Recall the latest image I supplied, not the first. Reply only with its four band colors in order. Do not call tools.") - proof["resumed_answer"] = answer(sid, proof["second"]) - assert len(sessions.turns.list(sid).data) == 3 - run(sid, "Call wait_for_image exactly once and wait for its result.", cancel=True) - run(sid, "Reply PLAIN_OK only. Do not call tools.") - items = sessions.items.list(sid, order="asc", limit=100).data - assert any(i.type == "message" and i.role == "assistant" and any(c.type == "output_text" and "PLAIN_OK" in c.text for c in i.content) for i in items) - proof["passed"] = True -finally: - save() - client.close() diff --git a/services/core/tests/official_model_protocol_native.py b/services/core/tests/official_model_protocol_native.py deleted file mode 100644 index 8e624e5db..000000000 --- a/services/core/tests/official_model_protocol_native.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Opt-in real-model protocol acceptance through public Sessions and the pinned SDK. - -OAC_TEST_MODEL_PROTOCOL_OPTIONS points to a private JSON file containing engine, -model, model_provider and optional harness_config. Evidence contains only scenario markers, resource IDs, -event counts and controlled check names; never provider options or raw errors. -""" -import importlib.metadata -import json -import logging -import os -import sys -import uuid -from pathlib import Path - -sys.dont_write_bytecode = True -logging.disable(logging.CRITICAL) -os.environ.pop("OPENAI_LOG", None) - - -class CheckFailed(Exception): - pass - - -def require(condition, code): - if not condition: - raise CheckFailed(code) - - -def main(): - base, token, options_file, stage, output = sys.argv[1:] - record = {"stage": stage, "checks": [], "calls": [], "runs": []} - - def save(): - path = Path(output) - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(descriptor, "w") as handle: - json.dump(record, handle, indent=2) - - client = None - try: - import httpx2 - from openai import OpenAI - - require(stage in {"initial", "resume"}, "invalid_stage") - settings = json.loads(Path(options_file).read_text()) - engine, model, provider = settings["engine"], settings["model"], settings["model_provider"] - harness_config = settings.get("harness_config", {}) - require(isinstance(harness_config, dict), "invalid_harness_config") - require(engine in {"codex", "claude_sdk", "mcode"}, "invalid_engine") - require(provider["protocol"] in {"anthropic", "responses", "chat_completions"}, "invalid_protocol") - require(isinstance(model, str) and bool(model), "missing_model") - require(isinstance(provider.get("api_key"), str) and bool(provider["api_key"]), "missing_provider_key") - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - distribution = importlib.metadata.distribution("openai") - source = json.loads(distribution.read_text("direct_url.json") or "{}") - require(distribution.version == pin["sdk_version"] and - source.get("vcs_info", {}).get("commit_id") == pin["commit"], "official_sdk_pin_mismatch") - if stage == "resume": - record = json.loads(Path(output).read_text()) - record["stage"] = stage - require(record["engine"] == engine and record["protocol"] == provider["protocol"], "resume_configuration_changed") - else: - record.update(engine=engine, protocol=provider["protocol"], - marker="PROTOCOL-MEMORY-" + uuid.uuid4().hex[:12]) - save() - client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, - _strict_response_validation=True, - http_client=httpx2.Client(trust_env=False, timeout=240)) - sessions = client.beta.agents.sessions - - def message(text): - return {"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": text}]}]} - - def answer(sid, expected): - items = [item.to_dict() for item in sessions.items.list(sid, order="asc", limit=100).data] - replies = [item for item in items if item["type"] == "message" and item["role"] == "assistant"] - require(bool(replies), "missing_assistant_answer") - actual = " ".join(part["text"] for part in replies[-1]["content"] if part["type"] == "output_text") - require(expected in actual, "answer_marker_mismatch") - outputs = {item["call_id"]: item for item in items if item["type"] == "function_call_output"} - for call in record["calls"]: - require(call["call"] in outputs, "function_result_not_persisted") - value = outputs[call["call"]] - require((value.get("error") is None) == call["success"], "function_result_error_state_mismatch") - - def run(sid, prompt, scenario, expected=None, tool_result=None, failed=False, cancel=False, creation=None): - events, deltas, handled, cancellation = [], 0, False, False - with (creation if creation is not None else sessions.events.stream(sid, timeout=240)) as stream: - if creation is None: - sessions.events.create(sid, events=[message(prompt)], idempotency_key=str(uuid.uuid4())) - for event in stream: - # Retain event types only, never the event's provider content. - kind = event.type - require(kind not in {"agent.session.failed", "agent.session.turn.failed"}, "native_turn_failed") - events.append(kind) - if kind == "agent.session.turn.output_text.delta": - deltas += 1 - if cancel and not cancellation: - sessions.events.create(sid, events=[{"type": "agent.session.input.cancel"}]) - cancellation = True - if kind == "agent.session.requires_action": - require(engine != "mcode" and tool_result is not None and not handled, "unexpected_function_action") - require(len(event.session.required_actions) == 1, "unexpected_parallel_function_actions") - action = event.session.required_actions[0] - require(action.name == "protocol_lookup", "unexpected_function_name") - result = {"type": "agent.session.input.tool_result", "turn_id": action.turn_id, - "call_id": action.call_id, "success": not failed, "output": tool_result} - if failed: - result["error"] = "Fixture lookup unavailable. Reply PROTOCOL-TOOL-ERROR and do not retry." - sessions.events.create(sid, events=[result], idempotency_key="protocol-" + action.call_id) - record["calls"].append({"turn": action.turn_id, "call": action.call_id, "success": not failed}) - handled = True - if kind == "agent.session.idle": - break - else: - raise CheckFailed("stream_ended_without_idle") - terminal = "agent.session.turn.cancelled" if cancel else "agent.session.turn.completed" - require("agent.session.turn.created" in events and terminal in events, "missing_turn_lifecycle") - require(events.index("agent.session.turn.created") < events.index(terminal) < len(events) - 1, - "turn_lifecycle_order") - require(deltas > 0, "missing_sse_text_delta") - require(handled == (tool_result is not None), "function_action_not_observed") - if cancel: - require(cancellation, "cancel_not_submitted") - current = sessions.turns.list(sid, order="desc", limit=1).data - require(bool(current) and current[0].status == ("cancelled" if cancel else "completed"), - "persisted_turn_status_mismatch") - usage = current[0].to_dict().get("usage") - # Adapters preserve native usage scope. A partial native breakdown - # remains null publicly; protocol conversion must not invent it. - if usage is not None: - require(isinstance(usage, dict) and usage.get("input_tokens", -1) >= 0 and - usage.get("output_tokens", -1) >= 0 and - usage.get("total_tokens") == usage["input_tokens"] + usage["output_tokens"], - "inconsistent_public_usage") - if expected is not None: - answer(sid, expected) - record["runs"].append({"scenario": scenario, "text_deltas": deltas, "completed": not cancel, - "cancelled": cancel, "function_result": handled, - "usage_present": isinstance(usage, dict)}) - record["checks"].append(scenario) - save() - - if stage == "initial": - marker = record["marker"] - agent = {"model": model, "instructions": ( - "Follow the user's exact requested tool calls and remember supplied markers. " - "Only call protocol_lookup when explicitly requested. After a tool result, answer in text and do not repeat the call.")} - if engine != "mcode": - agent["tools"] = [{"type": "function", "name": "protocol_lookup", - "description": "Return the requested fixture value.", - "parameters": {"type": "object", "properties": {"key": {"type": "string"}}, - "required": ["key"], "additionalProperties": False}}] - prompt = "Call protocol_lookup exactly once with key first. Then reply with the text returned by the tool." - else: - agent["instructions"] = "Remember user-supplied markers. Answer in text and do not use tools." - prompt = "Remember " + marker + ". Reply exactly " + marker + "." - record["not_exercised"] = ["public_functions_not_supported_by_mcode", "function_error", "cancel"] - # Explicit model selection clears deployment-native defaults, so exercise - # the public Session override instead of relying on fixture injection. - creation = sessions.create(agent=agent, environment={"type": "none"}, input=prompt, stream=True, - extra_body={"x_agents_core": {"harness_config": harness_config}}) - first = next(creation) - sid = first.session.id - record["session"] = sid - save() - public = sessions.retrieve(sid).to_dict() - require(provider["api_key"] not in json.dumps(public), "provider_key_exposed_by_public_session") - require(public.get("agent", {}).get("x_agents_core", {}).get("harness_config", {}) == harness_config, - "harness_config_snapshot_mismatch") - record["checks"].append("public_harness_config_snapshot") - if engine == "mcode": - run(sid, prompt, "native_protocol_memory_first_turn", expected=marker, creation=creation) - else: - first_value = "PROTOCOL-FIRST-" + uuid.uuid4().hex[:12] - run(sid, prompt, "function_text_first_turn", expected=first_value, - tool_result=first_value, creation=creation) - run(sid, "Call protocol_lookup exactly once with key second. Remember its result and repeat it verbatim.", - "function_text_second_turn", expected=marker, tool_result=marker) - run(sid, "Call protocol_lookup once with key failure. If it fails, reply PROTOCOL-TOOL-ERROR and do not retry.", - "function_error_result", expected="PROTOCOL-TOOL-ERROR", tool_result="Lookup failed.", failed=True) - run(sid, "Do not call tools. Print integers 1 through 10000, one per line. Begin with 1 immediately.", - "cancel_during_sse_text", cancel=True) - run(sid, "Without calling tools, repeat the PROTOCOL-MEMORY marker returned by the second lookup.", - "continue_after_cancel", expected=marker) - else: - sid = record["session"] - run(sid, "Without calling tools, repeat the PROTOCOL-MEMORY marker you remembered earlier, and nothing else.", - "cold_daemon_session_continuation", expected=record["marker"]) - turns = sessions.turns.list(sid, order="asc", limit=100).data - require(len(turns) == (2 if engine == "mcode" else 6), "unexpected_turn_count") - record["passed"] = True - save() - except CheckFailed as failure: - record["failure"] = {"stage": stage, "check": str(failure)} - save() - return 1 - except Exception as failure: - # API and validation exceptions can include private request/response - # bodies. Do not format them or persist their traceback. - record["failure"] = {"stage": stage, "check": "client_or_fixture_exception"} - status = getattr(failure, "status_code", None) - if isinstance(status, int): - record["failure"]["http_status"] = status - trace = failure.__traceback__ - while trace: - if trace.tb_frame.f_code.co_filename == __file__: - record["failure"]["script_line"] = trace.tb_lineno - trace = trace.tb_next - save() - return 1 - finally: - if client is not None: - client.close() - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception: - sys.exit(1) diff --git a/services/core/tests/official_structured_output.py b/services/core/tests/official_structured_output.py deleted file mode 100644 index 0f83a55ee..000000000 --- a/services/core/tests/official_structured_output.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Real-model structured output through the pinned SDK and public HTTP surface.""" -import importlib.metadata -import json -import sys -import uuid -from pathlib import Path - -import httpx2 -from openai import BadRequestError, NotFoundError, OpenAI - -base, token, foreign, model, stage, evidence = sys.argv[1:] -pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) -source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json")) -assert source["vcs_info"]["commit_id"] == pin["commit"] -client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, - _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=150)) -other = client.with_options(api_key=foreign) -sessions = client.beta.agents.sessions -headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} -proof = {} if stage == "initial" else json.loads(Path(evidence).read_text()) - - -def message(text): - return {"type": "agent.session.input.message", "input": [{"role": "user", "content": [{"type": "input_text", "text": text}]}]} - - -def run(session, prompt, respond=False, cancel=False, creation=None): - events, handled = [], False - with (creation or sessions.events.stream(session, timeout=150)) as stream: - if creation is None: - sessions.events.create(session, events=[message(prompt)], idempotency_key=str(uuid.uuid4())) - for event in stream: - events.append(event.to_dict()) - if event.type == "agent.session.requires_action": - assert not handled and (respond or cancel), event.to_dict() - handled = True - action = event.session.required_actions[0] - assert action.name == "remember" - if cancel: - sessions.events.create(session, events=[{"type": "agent.session.input.cancel"}]) - else: - proof.update(turn=action.turn_id, call=action.call_id) - payload = {"type": "agent.session.input.tool_result", "turn_id": action.turn_id, - "call_id": action.call_id, "success": True, - "output": [{"type": "input_text", "text": proof["memory"]}]} - for _ in range(2): - sessions.events.create(session, events=[payload], idempotency_key="same-tool-result") - assert event.type not in {"agent.session.failed", "agent.session.turn.failed"}, event.to_dict() - if event.type == "agent.session.idle": - break - else: - raise AssertionError("stream closed without idle") - proof.setdefault("runs", []).append(events) - Path(evidence).write_text(json.dumps(proof, ensure_ascii=False, indent=2)) - assert len({e["event_id"] for e in events}) == len(events) - turns = sessions.turns.list(session, order="asc", limit=100).data - assert turns[-1].status == ("cancelled" if cancel else "completed"), turns[-1].to_dict() - # Claude retains native counters; the public breakdown remains unqualified. - assert turns[-1].usage is None - terminal = "agent.session.turn." + ("cancelled" if cancel else "completed") - assert [e["type"] for e in events].index(terminal) < [e["type"] for e in events].index("agent.session.idle") - return events, turns[-1] - - -def final(session, events=None): - items = sessions.items.list(session, order="asc", limit=100).data - answers = [item for item in items if item.type == "message" and item.role == "assistant" and item.phase == "final_answer"] - assert answers - text = answers[-1].content[0].text - assert json.loads(text) == {"memory": proof["memory"]}, text - with httpx2.Client(trust_env=False) as raw: - response = raw.get(base + "/v1/agents/sessions/" + session + "/items", headers=headers, params={"order": "asc", "limit": 100}) - assert response.status_code == 200 - assert any(i["id"] == answers[-1].id and i["content"][0]["text"] == text for i in response.json()["data"]) - if events is not None: - item_id = answers[-1].id - added = next(i for i, e in enumerate(events) if e["type"] == "agent.session.turn.item.added" and e["item"]["id"] == item_id) - done = next(i for i, e in enumerate(events) if e["type"] == "agent.session.turn.item.done" and e["item"]["id"] == item_id) - terminal = next(i for i, e in enumerate(events) if e["type"] == "agent.session.turn.completed") - assert added < done < terminal - # The native final is framed like a streamed message; deltas carry its exact text (EVT-10). - assert events[added]["item"]["status"] == "in_progress" and events[added]["item"]["content"] == [] - deltas = [e["delta"] for e in events if e["type"] == "agent.session.turn.output_text.delta" and e["item_id"] == item_id] - assert deltas and "".join(deltas) == text, deltas - assert next(e["text"] for e in events if e["type"] == "agent.session.turn.output_text.done" and e["item_id"] == item_id) == text - return answers[-1].id - - -try: - if stage == "initial": - schema = {"type": "object", "properties": {"memory": {"type": "string"}}, "required": ["memory"], "additionalProperties": False} - config = {"model": model, "text": {"format": {"type": "json_schema", "schema": schema}}, - "tools": [{"type": "function", "name": "remember", "description": "Return a private memory value.", - "parameters": {"type": "object", "properties": {}, "additionalProperties": False}}]} - saved = client.beta.agents.create(**config) - prompt = "Call remember exactly once and return its exact memory value as the requested JSON. Do not invent it." - creation = sessions.create(agent_id=saved.id, environment={"type": "none"}, input=prompt, stream=True) - session = next(creation).session - proof.update(session=session.id, agent=saved.id, memory=str(uuid.uuid4()), format=config["text"]["format"]) - assert session.agent.text.format.to_dict() == proof["format"] - events, turn = run(session.id, prompt, respond=True, creation=creation) - proof.update(first_events=events, first_output=final(session.id, events)) - assert len([i for i in sessions.items.list(session.id, limit=100).data if i.type == "function_call"]) == 1 - try: - other.beta.agents.sessions.retrieve(session.id) - raise AssertionError("cross-tenant read accepted") - except NotFoundError: - pass - try: - other.beta.agents.sessions.create(agent_id=saved.id, environment={"type": "none"}, input="Verify foreign Agent rejection.") - raise AssertionError("cross-tenant Agent reference accepted") - except NotFoundError: - pass - # Saving arbitrary schemas remains separate from runtime qualification. - huge = client.beta.agents.create(model=model, text={"format": {"type": "json_schema", "schema": {"type": "object", "const": 9007199254740993}}}) - assert huge.text.format.to_dict()["schema"]["const"] == 9007199254740993 - try: - sessions.create(agent_id=huge.id, environment={"type": "none"}, input="Verify unsupported schema rejection.") - raise AssertionError("lossy runtime schema accepted") - except BadRequestError: - pass - client.beta.agents.delete(huge.id) - else: - session = sessions.retrieve(proof["session"]) - assert session.agent.text.format.to_dict() == proof["format"] - assert final(session.id) == proof["first_output"] - events, turn = run(session.id, "Recall the exact memory from the previous turn and return it using the same JSON format. Do not call remember again.") - proof.update(resume_events=events, resumed_output=final(session.id, events), resumed_turn=turn.id) - assert proof["resumed_output"] != proof["first_output"] - assert len(sessions.turns.list(session.id).data) == 2 - assert len([i for i in sessions.items.list(session.id, limit=100).data if i.type == "function_call"]) == 1 - prompt = "Call remember to obtain the memory, then return it as JSON." - creation = sessions.create(agent_id=proof["agent"], environment={"type": "none"}, input=prompt, stream=True) - cancelled = next(creation).session - events, _ = run(cancelled.id, prompt, cancel=True, creation=creation) - assert not any(i.type == "message" and i.role == "assistant" and i.phase == "final_answer" for i in sessions.items.list(cancelled.id, limit=100).data) - proof["cancel_events"] = events - prompt = "Do not use tools. Say PLAIN_OK in ordinary text." - creation = sessions.create(agent_id=proof["agent"], agent={"text": {"format": {"type": "text"}}}, environment={"type": "none"}, input=prompt, stream=True) - plain = next(creation).session - events, _ = run(plain.id, prompt, creation=creation) - assert any(i.type == "message" and i.role == "assistant" and "PLAIN_OK" in i.content[0].text for i in sessions.items.list(plain.id, limit=100).data) - proof["plain_events"] = events - sessions.delete(cancelled.id) - sessions.delete(plain.id) - proof["passed"] = True - Path(evidence).write_text(json.dumps(proof, ensure_ascii=False, indent=2)) -finally: - Path(evidence).write_text(json.dumps(proof, ensure_ascii=False, indent=2)) - client.close() - other.close() diff --git a/services/core/tests/official_tool_policy.py b/services/core/tests/official_tool_policy.py deleted file mode 100644 index 1da3e06fa..000000000 --- a/services/core/tests/official_tool_policy.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Opt-in disabled tool policy acceptance through the pinned SDK and raw HTTP.""" -import importlib.metadata -import json -import sys -import uuid -from pathlib import Path -from contextlib import ExitStack, nullcontext -from types import SimpleNamespace - -import httpx2 -from openai import BadRequestError, NotFoundError, OpenAI - - -def main(): - base, token, foreign, model, stage, evidence = sys.argv[1:] - assert stage in {"initial", "resume"} - pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) - distribution = importlib.metadata.distribution("openai") - assert distribution.version == pin["sdk_version"] - assert json.loads(distribution.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] - client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, - _strict_response_validation=True, - http_client=httpx2.Client(trust_env=False, timeout=150)) - other = client.with_options(api_key=foreign) - raw = httpx2.Client(trust_env=False, timeout=150) - sessions = client.beta.agents.sessions - headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} - foreign_headers = {**headers, "Authorization": "Bearer " + foreign} - proof = {"sessions": []} if stage == "initial" else json.loads(Path(evidence).read_text()) - tools = [{"type": "web_search", "mode": "disabled"}, - {"type": "programmatic_tool_calling", "enabled": False}] - # These are pinned response defaults, including explicit nullable fields. - expected = [{"type": "web_search", "mode": "disabled", "context_size": "medium", - "allowed_domains": None, "location": None}, tools[1]] - config = {"model": model, "instructions": "Follow user instructions and remember supplied markers.", "tools": tools} - - def request(method, path, *, status=200, foreign_tenant=False, **kwargs): - response = raw.request(method, base + "/v1/agents" + path, - headers=foreign_headers if foreign_tenant else headers, **kwargs) - assert response.status_code == status, (method, path, response.status_code) - return response.json() - - def save(): - Path(evidence).write_text(json.dumps(proof, indent=2)) - - def check_config(sid): - assert [tool.to_dict() for tool in sessions.retrieve(sid).agent.tools] == expected - assert request("GET", "/sessions/" + sid)["agent"]["tools"] == expected - - def execute(entry, prompt, creation=None): - sid = entry["id"] - events = [] - with (nullcontext(creation) if creation is not None else sessions.events.stream(sid, timeout=150)) as stream: - if creation is None: - sessions.events.create(sid, events=[{"type": "agent.session.input.message", "input": [ - {"role": "user", "content": [{"type": "input_text", "text": prompt}]}]}], - idempotency_key=str(uuid.uuid4())) - for event in stream: - events.append(event.type) - assert event.type not in {"agent.session.failed", "agent.session.turn.failed", "agent.session.requires_action"}, event.type - if event.type == "agent.session.idle": - break - else: - raise AssertionError("stream ended without idle") - assert events.index("agent.session.turn.created") < events.index("agent.session.turn.completed") < events.index("agent.session.idle") - turns = sessions.turns.list(sid, order="asc", limit=100).data - assert turns[-1].status == "completed", turns[-1].status - assert len(turns) == (1 if stage == "initial" else 2) - items = sessions.items.list(sid, order="asc", limit=100).data - answers = [item for item in items if item.type == "message" and item.role == "assistant" and item.turn_id == turns[-1].id] - text = "\n".join(content.text for item in answers for content in item.content if content.type == "output_text") - assert entry["marker"] in text, "Native answer did not retain the marker" - assert not any(item.type in {"web_search_call", "function_call", "mcp_call", "command_execution"} for item in items) - public = request("GET", "/sessions/" + sid + "/items", params={"order": "asc", "limit": 100}) - assert public["data"] == [item.to_dict() for item in items] - entry["first_turn" if stage == "initial" else "resumed_turn"] = turns[-1].id - entry[stage + "_events"] = events - save() - - def reject_configuration(payload, message): - error = request("POST", "/sessions", status=400, json=payload)["error"] - assert error["code"] == "unsupported_or_invalid_configuration" and error["message"] == message, error - try: - sessions.create(**payload) - raise AssertionError("Unsupported enabled tool configuration was admitted") - except BadRequestError: - pass - - try: - if stage == "initial": - sdk_agent = client.beta.agents.create(**config) - assert [tool.to_dict() for tool in sdk_agent.tools] == expected - raw_agent = request("POST", "", status=201, json=config) - assert raw_agent["tools"] == expected - proof["agents"] = [sdk_agent.id, raw_agent["id"]] - for aid in proof["agents"]: - assert [tool.to_dict() for tool in client.beta.agents.retrieve(aid).tools] == expected - assert request("GET", "/" + aid)["tools"] == expected - payloads = [ - ("sdk_saved", {"agent_id": sdk_agent.id}), - ("sdk_inline", {"agent": config}), - ("raw_saved", {"agent_id": raw_agent["id"]}), - ("raw_inline", {"agent": config}), - ] - for name, payload in payloads: - payload["environment"] = {"type": "none"} - marker = "TOOL-POLICY-" + uuid.uuid4().hex - prompt = "Remember this exact marker for later: " + marker + ". Reply with that marker only." - payload.update(input=prompt, stream=True) - with ExitStack() as stack: - if name.startswith("sdk"): - creation = stack.enter_context(sessions.create(**payload)) - first = next(creation) - assert first.type == "agent.session.created" - sid = first.session.id - else: - response = stack.enter_context(raw.stream("POST", base + "/v1/agents/sessions", headers=headers, json=payload)) - assert response.status_code == 201 - frames = (json.loads(line[6:]) for line in response.iter_lines() if line.startswith("data: ")) - first = next(frames) - assert first["type"] == "agent.session.created" - sid = first["session"]["id"] - creation = (SimpleNamespace(type=frame["type"]) for frame in frames) - entry = {"id": sid, "path": name, "marker": marker} - proof["sessions"].append(entry) - save() - check_config(sid) - execute(entry, prompt, creation=creation) - search_only = "Only disabled web_search is qualified for execution." - ptc_only = "Programmatic tool calling is not qualified for execution." - for enabled, message in [({"type": "web_search", "mode": "cached"}, search_only), - ({"type": "web_search", "mode": "live"}, search_only), - ({"type": "programmatic_tool_calling", "enabled": True}, ptc_only)]: - reject_configuration({"agent": {"model": model, "tools": [enabled]}, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}, message) - reject_configuration({"agent_id": sdk_agent.id, "agent": {"tools": [enabled]}, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}, message) - # Saving PTC intent is independent of Session execution qualification. - enabled_agent = client.beta.agents.create(model=model, tools=[{"type": "programmatic_tool_calling", "enabled": True}]) - reject_configuration({"agent_id": enabled_agent.id, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}, ptc_only) - # Saved enabled or omitted-mode search is resource data (TV-05); admission still rejects it. - for search in ({"type": "web_search"}, {"type": "web_search", "mode": "cached"}): - searched = client.beta.agents.create(model=model, tools=[search]) - reject_configuration({"agent_id": searched.id, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}, search_only) - # Omitted-tool default projections are covered by the queued SDK/raw - # resource fixture; these live cases exercise explicit disabled tools. - for aid in proof["agents"]: - request("GET", "/" + aid, status=404, foreign_tenant=True) - request("POST", "/sessions", status=404, foreign_tenant=True, - json={"agent_id": aid, "environment": {"type": "none"}, "input": "Verify rejected tool policy configuration."}) - try: - other.beta.agents.sessions.create(agent_id=aid, environment={"type": "none"}, input="Verify foreign Agent rejection.") - raise AssertionError("Foreign tenant used a saved Agent") - except NotFoundError: - pass - else: - for entry in proof["sessions"]: - check_config(entry["id"]) - # The marker is absent from this input: success requires native history. - execute(entry, "Reply with the exact TOOL-POLICY marker I asked you to remember earlier, and nothing else.") - proof["passed"] = True - for entry in proof["sessions"]: - sid = entry["id"] - for suffix in ["", "/items", "/turns"]: - request("GET", "/sessions/" + sid + suffix, status=404, foreign_tenant=True) - try: - other.beta.agents.sessions.retrieve(sid) - raise AssertionError("Foreign tenant retrieved a Session") - except NotFoundError: - pass - finally: - save() - raw.close() - other.close() - client.close() - - -if __name__ == "__main__": - main() diff --git a/services/core/tests/official_tool_search.py b/services/core/tests/official_tool_search.py deleted file mode 100644 index 387a363ea..000000000 --- a/services/core/tests/official_tool_search.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Real deferred functions through the pinned SDK and raw Agents API HTTP.""" -import importlib.metadata -import json -import secrets -import sys -import uuid -from pathlib import Path - -import httpx2 -from openai import BadRequestError, NotFoundError, OpenAI -from image_fixture import picture - -base, token, foreign, model, stage, evidence = sys.argv[1:] -pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) -source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json")) -assert source["vcs_info"]["commit_id"] == pin["commit"] -client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, - _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=150)) -other = client.with_options(api_key=foreign) -sessions = client.beta.agents.sessions -headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} -proof = {} if stage == "initial" else json.loads(Path(evidence).read_text()) - - -def run(session, name, cancel=False, images=False, creation_request=None): - marker = "RESULT-" + str(uuid.uuid4()) - events, handled = [], False - prompt = "Call " + name + " exactly once, using the exact required ticket from its schema. Return only the fresh tool result. Discover its definition if needed." - message = {"type":"agent.session.input.message", "input":[{"role":"user","content":[{"type":"input_text","text":prompt}]}]} - colors = secrets.SystemRandom().sample(["red", "green", "blue", "yellow"], 4) - active_colors = secrets.SystemRandom().sample(["red", "green", "blue", "yellow"], 4) - if images: - message["input"][0]["content"].append({"type":"input_image","image_url":picture(colors)}) - message["input"][0]["content"].append({"type":"input_text","text":"Also remember these four band colors in left-to-right order."}) - if creation_request is not None: - creation = sessions.create(**creation_request, input=message["input"], stream=True) - session = next(creation).session.id - proof["cancel_session" if cancel else "session"] = session - if not cancel: - proof["initial_input"] = message["input"] - Path(evidence).write_text(json.dumps(proof, indent=2)) - else: - creation = None - with (creation or sessions.events.stream(session, timeout=150)) as stream: - if creation is None: - sessions.events.create(session, events=[message], idempotency_key=str(uuid.uuid4())) - for event in stream: - events.append(event.to_dict()) - if event.type == "agent.session.requires_action": - assert not handled and len(event.session.required_actions) == 1, event.to_dict() - handled = True - action = event.session.required_actions[0] - assert action.name == name and action.arguments == {"ticket":proof["parameter"]}, action.to_dict() - assert sessions.turns.retrieve(action.turn_id, session_id=session).status == "waiting" - if cancel: - sessions.events.create(session, events=[{"type":"agent.session.input.cancel"}]) - else: - if images: - update = {"type":"agent.session.input.message", "input":[{"role":"user","content":[ - {"type":"input_text","text":"New instruction: use this latest image instead. In your final answer print its four band colors from left to right and the tool result. Do not make another tool call."}, - {"type":"input_image","image_url":picture(active_colors)}]}]} - sessions.events.create(session, events=[update], idempotency_key="active-image") - if "turn" not in proof: - proof.update(turn=action.turn_id, call=action.call_id) - payload = {"type":"agent.session.input.tool_result", "turn_id":action.turn_id, "call_id":action.call_id, - "success":True, "output":[{"type":"input_text","text":marker}]} - result_key = str(uuid.uuid4()) - for _ in range(2): - sessions.events.create(session, events=[payload], idempotency_key=result_key) - assert event.type not in {"agent.session.failed", "agent.session.turn.failed"}, event.to_dict() - if event.type == "agent.session.idle": - break - else: - raise AssertionError("stream ended without idle") - assert handled - proof.setdefault("runs", []).append(events) - Path(evidence).write_text(json.dumps(proof, indent=2)) - turns = sessions.turns.list(session, order="asc", limit=100).data - turn = turns[-1] - assert turn.status == ("cancelled" if cancel else "completed"), turn.to_dict() - kinds = [e["type"] for e in events] - assert kinds.index("agent.session.turn." + turn.status) < kinds.index("agent.session.idle") - assert len({e["event_id"] for e in events}) == len(events) - items = sessions.items.list(session, limit=100, order="asc").data - calls = [i for i in items if i.type == "function_call" and i.turn_id == turn.id] - outputs = [i for i in items if i.type == "function_call_output" and i.call_id == action.call_id] - assert len(calls) == 1 and calls[0].call_id == action.call_id - assert len(outputs) == (0 if cancel else 1) - if not cancel: - answers = [i for i in items if i.type == "message" and i.role == "assistant" and i.turn_id == turn.id] - assert any(marker in json.dumps(i.to_dict()) for i in answers), [i.to_dict() for i in answers] - if images: - answer = " ".join(c.text for i in answers for c in i.content if c.type == "output_text").lower() - positions = [answer.find(c) for c in active_colors] - assert all(p >= 0 for p in positions) and positions == sorted(positions), answer - proof["image_colors"] = {"initial":colors,"active":active_colors} - assert outputs[0].output[0].text == marker - assert any(e["type"] == "agent.session.turn.item.added" and e["item"]["id"] == outputs[0].id for e in events) - with httpx2.Client(trust_env=False) as raw: - response = raw.get(base + "/v1/agents/sessions/" + session + "/items", headers=headers, params={"order":"asc","limit":100}) - assert response.status_code == 200 - assert [i["id"] for i in response.json()["data"]] == [i.id for i in items] - return turn.id - - -try: - if stage == "initial": - proof["parameter"] = "ARG-" + str(uuid.uuid4()) - schema = {"type":"object","properties":{"ticket":{"type":"string","enum":[proof["parameter"]]}}, "required":["ticket"],"additionalProperties":False} - tools = [{"type":"tool_search"}] + [ - {"type":"function","name":name,"description":description,"parameters":schema,"defer_loading":deferred} - for name, description, deferred in [("lookup_account","Return the account result.",True), ("clock","Return the current clock result.",False), ("unrelated_report","Read an unrelated report.",True)]] - config = {"model":model,"tools":tools} - saved = client.beta.agents.create(**config) - request = {"agent_id": saved.id, "environment": {"type": "none"}, - "extra_headers": {"Idempotency-Key": "discovery-create"}} - proof.update(agent=saved.id, tools=tools) - run(None, "lookup_account", images=True, creation_request=request) - session = sessions.retrieve(proof["session"]) - assert sessions.create(**request, input=proof["initial_input"]).id == session.id - assert [t.to_dict() for t in saved.tools] == tools - assert [t.to_dict() for t in session.agent.tools] == tools[1:] - run(session.id, "clock") - for action in [lambda:other.beta.agents.sessions.retrieve(session.id), lambda:other.beta.agents.sessions.create(agent_id=saved.id,environment={"type":"none"}, input="Verify tenant rejection.")]: - try: - action() - raise AssertionError("foreign tenant accessed discovery configuration") - except NotFoundError: - pass - for invalid in [[tools[1]], [tools[0]], [tools[0],tools[0],tools[1]], [{"type":"tool_search","execution":"client"},tools[1]]]: - try: - sessions.create(agent={"model":model,"tools":invalid}, environment={"type":"none"}, input="Verify unqualified tool configuration rejection.") - raise AssertionError("unqualified discovery configuration admitted") - except BadRequestError: - pass - else: - session = sessions.retrieve(proof["session"]) - assert [t.to_dict() for t in session.agent.tools] == proof["tools"][1:] - run(session.id, "lookup_account") - assert len(sessions.turns.list(session.id).data) == 3 - assert len([i for i in sessions.items.list(session.id,limit=100).data if i.type == "function_call"]) == 3 - # Inline configuration exercises a fresh native Session and pending-call cancellation. - run(None, "lookup_account", cancel=True, creation_request={ - "agent": {"model": model, "tools": proof["tools"]}, "environment": {"type": "none"}}) - proof["passed"] = True -finally: - Path(evidence).write_text(json.dumps(proof, indent=2)) - client.close() - other.close()