From 8f4d48b08b5438f0fb7bc13142085c7fdf41cd67 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 18:35:01 +0000 Subject: [PATCH 01/13] Move Environment owner outcomes into the dispatch contract The owner contract now holds its result types, workspace errors and a typed initialization failure, so a second owner returns every outcome without importing localworkspace. The export bound and empty-write filter move to the Router, and Binding.Resolve replaces the CLI and test copies of the resolver. --- .../internal/agent/workspace_directory.go | 14 ----- apps/daemon/internal/agent/workspace_read.go | 12 ---- apps/daemon/internal/agent/workspace_write.go | 25 -------- apps/daemon/internal/cli/connect.go | 7 +-- apps/daemon/internal/dispatch/environment.go | 51 ++++++++++++++- apps/daemon/internal/dispatch/export_test.go | 16 ----- .../internal/dispatch/local_directory_test.go | 4 +- .../dispatch/native_file_results_test.go | 8 +-- .../internal/dispatch/preparation_test.go | 4 +- .../internal/dispatch/runtime_preparation.go | 3 +- .../dispatch/runtime_preparation_test.go | 13 ++-- .../internal/dispatch/workspace_export.go | 21 ++++++- .../dispatch/workspace_export_test.go | 62 +++++++++++-------- .../internal/dispatch/workspace_read.go | 11 ++-- .../internal/dispatch/workspace_write.go | 15 +++-- .../internal/dispatch/workspace_write_test.go | 2 +- .../daemon/internal/localworkspace/binding.go | 11 +++- .../internal/localworkspace/directory.go | 6 +- .../localworkspace/directory_native_test.go | 5 +- apps/daemon/internal/localworkspace/export.go | 30 --------- .../internal/localworkspace/native_files.go | 50 +++++++-------- .../localworkspace/runtime_initialization.go | 36 +++++------ .../runtime_initialization_process.go | 5 +- .../runtime_initialization_test.go | 5 +- apps/daemon/internal/localworkspace/write.go | 14 ++--- .../internal/localworkspace/write_test.go | 17 ++--- 26 files changed, 211 insertions(+), 236 deletions(-) delete mode 100644 apps/daemon/internal/agent/workspace_directory.go delete mode 100644 apps/daemon/internal/agent/workspace_read.go delete mode 100644 apps/daemon/internal/agent/workspace_write.go delete mode 100644 apps/daemon/internal/localworkspace/export.go diff --git a/apps/daemon/internal/agent/workspace_directory.go b/apps/daemon/internal/agent/workspace_directory.go deleted file mode 100644 index d344f49a9..000000000 --- a/apps/daemon/internal/agent/workspace_directory.go +++ /dev/null @@ -1,14 +0,0 @@ -package agent - -// WorkspaceDirectoryEntry describes an entry observed without following its final symlink. -type WorkspaceDirectoryEntry struct { - Name string - Kind string - SizeBytes *int64 -} - -// WorkspaceDirectoryResult is a live, bounded observation, not a filesystem snapshot. -type WorkspaceDirectoryResult struct { - Entries []WorkspaceDirectoryEntry - Truncated bool -} diff --git a/apps/daemon/internal/agent/workspace_read.go b/apps/daemon/internal/agent/workspace_read.go deleted file mode 100644 index b156d6c2d..000000000 --- a/apps/daemon/internal/agent/workspace_read.go +++ /dev/null @@ -1,12 +0,0 @@ -package agent - -import "errors" - -var ( - ErrWorkspaceReadUnavailable = errors.New("workspace read unavailable") - ErrWorkspaceReadInvalid = errors.New("workspace read invalid") - ErrWorkspaceReadUncertain = errors.New("workspace read outcome uncertain") - // ErrWorkspaceNotDirectory reports that a directory request's own path is - // missing, a regular file or a symbolic link; the link was not followed. - ErrWorkspaceNotDirectory = errors.New("workspace path is not a directory") -) diff --git a/apps/daemon/internal/agent/workspace_write.go b/apps/daemon/internal/agent/workspace_write.go deleted file mode 100644 index c50b075bd..000000000 --- a/apps/daemon/internal/agent/workspace_write.go +++ /dev/null @@ -1,25 +0,0 @@ -package agent - -import ( - "errors" - "fmt" -) - -type WorkspaceWriteResult struct { - SizeBytes int64 -} - -var ( - ErrWorkspaceWriteUnavailable = errors.New("workspace write unavailable") - ErrWorkspaceWriteBusy = errors.New("workspace write busy") - ErrWorkspaceWriteInvalid = errors.New("workspace write invalid") - ErrWorkspaceWriteRejected = errors.New("workspace write rejected") - ErrWorkspaceWriteUncertain = errors.New("workspace write outcome uncertain") -) - -// Known Files.create destination refusals. Both wrap ErrWorkspaceWriteRejected: -// nothing was installed. -var ( - ErrWorkspaceWriteDirectory = fmt.Errorf("%w: destination is a directory", ErrWorkspaceWriteRejected) - ErrWorkspaceWriteUnsafe = fmt.Errorf("%w: destination exists or its path is not a plain directory chain", ErrWorkspaceWriteRejected) -) diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go index 45b6b79d1..d9fe10b8c 100644 --- a/apps/daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -321,12 +321,7 @@ func localEnvironments(local *localworkspace.Binding) func(proto.AssignmentRef, if local == nil { return nil } - return func(ref proto.AssignmentRef, bind proto.AssignmentBindPayload) dispatch.Environment { - if !local.Matches(bind.EnvironmentID, ref.SessionID) { - return nil - } - return local - } + return local.Resolve } // localEnvironmentKinds declares, for each kind that supports a local diff --git a/apps/daemon/internal/dispatch/environment.go b/apps/daemon/internal/dispatch/environment.go index 2a845e0e9..bc9cb4150 100644 --- a/apps/daemon/internal/dispatch/environment.go +++ b/apps/daemon/internal/dispatch/environment.go @@ -3,9 +3,9 @@ package dispatch import ( "context" "errors" + "fmt" "io" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -24,14 +24,59 @@ type Environment interface { // ApplyRuntimePreparation applies one complete runtime_prepare transfer and // returns only after its mutations stop. ApplyRuntimePreparation(context.Context, proto.RuntimePreparePayload, []byte) error - ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) - WriteWorkspaceFile(ctx context.Context, path string, data []byte) (agent.WorkspaceWriteResult, error) + ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (WorkspaceDirectoryResult, error) + WriteWorkspaceFile(ctx context.Context, path string, data []byte) (WorkspaceWriteResult, error) ExportOutputs(context.Context, io.Writer) error // Close releases what the owner holds for the assignment once its work and // Executors have settled. A retried release calls it again. Close(context.Context) error } +// InitializationFailure is a runtime_prepare step that confirmably failed. It +// carries only the step's safe exit status, when it has one. +type InitializationFailure struct{ ExitCode *int } + +func (*InitializationFailure) Error() string { return "Runtime initialization failed" } + +// WorkspaceDirectoryEntry describes an entry observed without following its final symlink. +type WorkspaceDirectoryEntry struct { + Name string + Kind string + SizeBytes *int64 +} + +// WorkspaceDirectoryResult is a live, bounded observation, not a filesystem snapshot. +type WorkspaceDirectoryResult struct { + Entries []WorkspaceDirectoryEntry + Truncated bool +} + +type WorkspaceWriteResult struct { + SizeBytes int64 +} + +var ( + ErrWorkspaceReadUnavailable = errors.New("workspace read unavailable") + ErrWorkspaceReadInvalid = errors.New("workspace read invalid") + ErrWorkspaceReadUncertain = errors.New("workspace read outcome uncertain") + // ErrWorkspaceNotDirectory reports that a directory request's own path is + // missing, a regular file or a symbolic link; the link was not followed. + ErrWorkspaceNotDirectory = errors.New("workspace path is not a directory") + + ErrWorkspaceWriteUnavailable = errors.New("workspace write unavailable") + ErrWorkspaceWriteBusy = errors.New("workspace write busy") + ErrWorkspaceWriteInvalid = errors.New("workspace write invalid") + ErrWorkspaceWriteRejected = errors.New("workspace write rejected") + ErrWorkspaceWriteUncertain = errors.New("workspace write outcome uncertain") +) + +// Known Files.create destination refusals. Both wrap ErrWorkspaceWriteRejected: +// nothing was installed. +var ( + ErrWorkspaceWriteDirectory = fmt.Errorf("%w: destination is a directory", ErrWorkspaceWriteRejected) + ErrWorkspaceWriteUnsafe = fmt.Errorf("%w: destination exists or its path is not a plain directory chain", ErrWorkspaceWriteRejected) +) + func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { if (req.LocalEnvironment != nil) == req.DisableExecutionEnvironment { return errors.New("execution requires exactly one of local_environment and disable_execution_environment") diff --git a/apps/daemon/internal/dispatch/export_test.go b/apps/daemon/internal/dispatch/export_test.go index 5a77ad2b1..6ee90d36e 100644 --- a/apps/daemon/internal/dispatch/export_test.go +++ b/apps/daemon/internal/dispatch/export_test.go @@ -1,21 +1,5 @@ package dispatch -import ( - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// LocalEnvironments resolves binding as the owner of the Session it binds, as -// the daemon composes it. -func LocalEnvironments(binding *localworkspace.Binding) func(proto.AssignmentRef, proto.AssignmentBindPayload) Environment { - return func(ref proto.AssignmentRef, bind proto.AssignmentBindPayload) Environment { - if !binding.Matches(bind.EnvironmentID, ref.SessionID) { - return nil - } - return binding - } -} - func (r *Router) PreparationOwnershipForTest(handle string) (bool, bool) { r.mu.Lock() defer r.mu.Unlock() diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index 019df9ba3..730b48893 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -34,7 +34,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: dispatch.LocalEnvironments(binding)}) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, Environments: binding.Resolve}) if err != nil { t.Fatal(err) } @@ -105,7 +105,7 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { return nil, errors.New("must not prepare a harness") }) sender := &recSender{} - r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, Environments: dispatch.LocalEnvironments(binding)}) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, Environments: binding.Resolve}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/native_file_results_test.go b/apps/daemon/internal/dispatch/native_file_results_test.go index 3c9ca7d3d..a3d15e8bd 100644 --- a/apps/daemon/internal/dispatch/native_file_results_test.go +++ b/apps/daemon/internal/dispatch/native_file_results_test.go @@ -15,11 +15,11 @@ func TestNativeDirectoryFailureMapping(t *testing.T) { err error outcome, code string }{ - {agent.ErrWorkspaceNotDirectory, "rejected", proto.WorkspaceReadNotDirectory}, - {agent.ErrWorkspaceReadInvalid, "rejected", "invalid_request"}, + {ErrWorkspaceNotDirectory, "rejected", proto.WorkspaceReadNotDirectory}, + {ErrWorkspaceReadInvalid, "rejected", "invalid_request"}, {fs.ErrNotExist, "rejected", "not_found"}, {fs.ErrPermission, "rejected", "permission_denied"}, - {agent.ErrWorkspaceReadUncertain, "unknown", "read_unconfirmed"}, + {ErrWorkspaceReadUncertain, "unknown", "read_unconfirmed"}, } { got := workspaceReadFailure(test.err) if got.Outcome != test.outcome || got.ErrorCode != test.code { @@ -33,7 +33,7 @@ func TestLocalUploadUnknownRetainsOwner(t *testing.T) { if err != nil { t.Fatal(err) } - got := workspaceWriteResult(agent.WorkspaceWriteResult{}, errors.New("unconfirmed mutation"), 3) + got := workspaceWriteResult(WorkspaceWriteResult{}, errors.New("unconfirmed mutation"), 3) if got.Outcome != "unknown" { t.Fatal(got) } diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index fa31d3bd6..dd01a2a34 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -103,7 +103,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: dispatch.LocalEnvironments(preparationWorkspace(t))}) + h.router, err = dispatch.New(dispatch.Config{Registry: h.reg, Sender: h.sender, Environments: preparationWorkspace(t).Resolve}) if err != nil { t.Fatal(err) } @@ -123,7 +123,7 @@ func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Durati reg := agent.NewRegistry() reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported})}, prototest.ModelConfiguration()) reg.RegisterExecutor("prepared", preparationExecutorFixture(factory)) - r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, Environments: dispatch.LocalEnvironments(preparationWorkspace(t))}) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, Environments: preparationWorkspace(t).Resolve}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/runtime_preparation.go b/apps/daemon/internal/dispatch/runtime_preparation.go index 492282d4b..e120cf423 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation.go +++ b/apps/daemon/internal/dispatch/runtime_preparation.go @@ -7,7 +7,6 @@ import ( "errors" "time" - "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/google/uuid" @@ -187,7 +186,7 @@ func runtimePreparationResult(err error, size int) proto.RuntimePrepareResultPay if err == nil { return proto.RuntimePrepareResultPayload{Outcome: "completed", SizeBytes: size} } - var initialization *localworkspace.InitializationFailure + var initialization *InitializationFailure if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && errors.As(err, &initialization) { code := 0 if initialization.ExitCode != nil { diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go index 3a17d03cb..08c836b25 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -12,7 +12,6 @@ import ( "time" "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/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" @@ -36,12 +35,8 @@ var capabilityRef = proto.AssignmentRef{SessionID: "0b6f1f3e-6f0a-4d38-9c1e-2f5d func capabilitiesTestRouter(t *testing.T) (*Router, *capabilitiesTestSender, string, string) { t.Helper() environment, session := uuid.NewString(), capabilityRef.SessionID - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, t.TempDir(), t.TempDir()) - if err != nil { - t.Fatal(err) - } sender := &capabilitiesTestSender{frames: make(chan proto.Envelope, 64)} - router, err := New(Config{Registry: agent.NewRegistry(), Sender: sender, Environments: LocalEnvironments(binding)}) + router, err := New(Config{Registry: agent.NewRegistry(), Sender: sender, Environments: func(proto.AssignmentRef, proto.AssignmentBindPayload) Environment { return stubEnvironment{} }}) if err != nil { t.Fatal(err) } @@ -299,7 +294,7 @@ func TestRuntimePreparationCancellationKeepsOwnershipUntilApplyStops(t *testing. func TestRuntimePreparationInitializationReceipts(t *testing.T) { for _, code := range []int{-1, 0, 1, 255, 256} { - got := runtimePreparationResult(&localworkspace.InitializationFailure{ExitCode: &code}, 0) + got := runtimePreparationResult(&InitializationFailure{ExitCode: &code}, 0) if code > 0 && code <= 255 { if got.Outcome != "failed" || got.ExitCode != code { t.Fatalf("lost confirmed exit code: %+v", got) @@ -308,10 +303,10 @@ func TestRuntimePreparationInitializationReceipts(t *testing.T) { t.Fatalf("accepted invalid failure receipt: %+v", got) } } - if got := runtimePreparationResult(&localworkspace.InitializationFailure{}, 0); got.Outcome != "failed" || got.ExitCode != 0 { + if got := runtimePreparationResult(&InitializationFailure{}, 0); got.Outcome != "failed" || got.ExitCode != 0 { t.Fatalf("lost confirmed generic failure: %+v", got) } - if got := runtimePreparationResult(errors.Join(&localworkspace.InitializationFailure{}, context.Canceled), 0); got.Outcome != "unknown" { + if got := runtimePreparationResult(errors.Join(&InitializationFailure{}, context.Canceled), 0); got.Outcome != "unknown" { t.Fatalf("cancellation reported confirmed: %+v", got) } } diff --git a/apps/daemon/internal/dispatch/workspace_export.go b/apps/daemon/internal/dispatch/workspace_export.go index d460bef7b..08de6fc7a 100644 --- a/apps/daemon/internal/dispatch/workspace_export.go +++ b/apps/daemon/internal/dispatch/workspace_export.go @@ -81,7 +81,7 @@ func (r *Router) runWorkspaceExport(ctx context.Context, u *workspaceExport, env exported := make(chan struct{}) go func() { defer close(exported) - err := environment.ExportOutputs(ctx, writer) + err := environment.ExportOutputs(ctx, &exportWriter{output: writer}) _ = writer.CloseWithError(err) }() var offset int64 @@ -143,3 +143,22 @@ func (r *Router) runWorkspaceExport(ctx context.Context, u *workspaceExport, env func (r *Router) sendWorkspaceExport(ctx context.Context, request proto.Envelope, result proto.WorkspaceExportResultPayload) error { return r.reply(ctx, request, proto.TypeWorkspaceExportResult, result) } + +// exportWriter bounds the archive and drops empty writes, which a pipe would +// deliver as empty reads. +type exportWriter struct { + output io.Writer + size int64 +} + +func (w *exportWriter) Write(data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } + if int64(len(data)) > proto.WorkspaceExportMaxBytes-w.size { + return 0, errors.New("workspace export exceeds bound") + } + n, err := w.output.Write(data) + w.size += int64(n) + return n, err +} diff --git a/apps/daemon/internal/dispatch/workspace_export_test.go b/apps/daemon/internal/dispatch/workspace_export_test.go index b398db252..30f84e23e 100644 --- a/apps/daemon/internal/dispatch/workspace_export_test.go +++ b/apps/daemon/internal/dispatch/workspace_export_test.go @@ -4,14 +4,12 @@ import ( "archive/tar" "bytes" "context" + "errors" "io" - "os" - "path/filepath" "testing" "time" "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" "github.com/google/uuid" ) @@ -31,32 +29,46 @@ func (s exportSender) Send(ctx context.Context, env proto.Envelope) error { } } -func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto.WorkspaceExportPayload) { - t.Helper() - workspace := t.TempDir() - if err := os.Mkdir(filepath.Join(workspace, "outputs"), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(workspace, "outputs", "a"), make([]byte, 131089), 0600); err != nil { - t.Fatal(err) +// stubEnvironment is an owner whose export writes outputs/a, 131089 zero +// bytes, and fails after it when fail is set. +type stubEnvironment struct{ fail bool } + +func (stubEnvironment) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { + return r, nil +} +func (stubEnvironment) Prepare(_ context.Context, r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { + return r, nil +} +func (stubEnvironment) ApplyRuntimePreparation(context.Context, proto.RuntimePreparePayload, []byte) error { + return errors.New("stub") +} +func (stubEnvironment) ListWorkspaceDirectory(context.Context, string, int) (WorkspaceDirectoryResult, error) { + return WorkspaceDirectoryResult{}, ErrWorkspaceReadUnavailable +} +func (stubEnvironment) WriteWorkspaceFile(context.Context, string, []byte) (WorkspaceWriteResult, error) { + return WorkspaceWriteResult{}, ErrWorkspaceWriteUnavailable +} +func (e stubEnvironment) ExportOutputs(_ context.Context, w io.Writer) error { + archive := tar.NewWriter(w) + if err := archive.WriteHeader(&tar.Header{Name: "outputs/a", Mode: 0600, Size: 131089, Typeflag: tar.TypeReg}); err != nil { + return err } - if program == "failure" { - f, err := os.Create(filepath.Join(workspace, "outputs", "z")) - if err != nil { - t.Fatal(err) - } - if err = f.Truncate(201 << 20); err != nil { - t.Fatal(err) - } - f.Close() + if _, err := archive.Write(make([]byte, 131089)); err != nil { + return err } - environment, session := uuid.NewString(), capabilityRef.SessionID - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) - if err != nil { - t.Fatal(err) + if e.fail { + return errors.New("output too large") } + return archive.Close() +} +func (stubEnvironment) Close(context.Context) error { return nil } + +func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto.WorkspaceExportPayload) { + t.Helper() + environment := uuid.NewString() + owner := stubEnvironment{fail: program == "failure"} sender := exportSender{make(chan proto.Envelope, 8)} - r, err := New(Config{Registry: agent.NewRegistry(), Sender: sender, Environments: LocalEnvironments(binding)}) + r, err := New(Config{Registry: agent.NewRegistry(), Sender: sender, Environments: func(proto.AssignmentRef, proto.AssignmentBindPayload) Environment { return owner }}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/workspace_read.go b/apps/daemon/internal/dispatch/workspace_read.go index f9ff2f561..924a4c59b 100644 --- a/apps/daemon/internal/dispatch/workspace_read.go +++ b/apps/daemon/internal/dispatch/workspace_read.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -93,14 +92,14 @@ func listWorkspaceDirectory(ctx context.Context, environment Environment, reques return workspaceReadFailure(err) } if read.Entries == nil || len(read.Entries) > request.MaxEntries { - return workspaceReadFailure(agent.ErrWorkspaceReadUncertain) + return workspaceReadFailure(ErrWorkspaceReadUncertain) } directory := &proto.WorkspaceDirectoryResult{Entries: make([]proto.WorkspaceDirectoryEntry, 0, len(read.Entries)), Truncated: read.Truncated} for _, entry := range read.Entries { directory.Entries = append(directory.Entries, proto.WorkspaceDirectoryEntry{Name: entry.Name, Kind: entry.Kind, SizeBytes: entry.SizeBytes}) } if !proto.ValidWorkspaceDirectory(directory, request.MaxEntries) { - return workspaceReadFailure(agent.ErrWorkspaceReadUncertain) + return workspaceReadFailure(ErrWorkspaceReadUncertain) } return proto.WorkspaceReadResultPayload{Outcome: "completed", Directory: directory, CloseAcknowledged: true} } @@ -114,9 +113,9 @@ func workspaceReadFailure(err error) proto.WorkspaceReadResultPayload { err error code string }{ - {agent.ErrWorkspaceReadUnavailable, "resource_unavailable"}, - {agent.ErrWorkspaceReadInvalid, "invalid_request"}, - {agent.ErrWorkspaceNotDirectory, proto.WorkspaceReadNotDirectory}, + {ErrWorkspaceReadUnavailable, "resource_unavailable"}, + {ErrWorkspaceReadInvalid, "invalid_request"}, + {ErrWorkspaceNotDirectory, proto.WorkspaceReadNotDirectory}, {fs.ErrNotExist, "not_found"}, {fs.ErrPermission, "permission_denied"}, } { diff --git a/apps/daemon/internal/dispatch/workspace_write.go b/apps/daemon/internal/dispatch/workspace_write.go index e175cbe3c..452d39286 100644 --- a/apps/daemon/internal/dispatch/workspace_write.go +++ b/apps/daemon/internal/dispatch/workspace_write.go @@ -7,7 +7,6 @@ import ( "errors" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) @@ -155,7 +154,7 @@ func rejectedWorkspaceWrite(code string) proto.WorkspaceWriteResultPayload { return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: code} } -func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int) proto.WorkspaceWriteResultPayload { +func workspaceWriteResult(write WorkspaceWriteResult, err error, size int) proto.WorkspaceWriteResultPayload { if err == nil && write.SizeBytes == int64(size) { return proto.WorkspaceWriteResultPayload{Outcome: "completed", SizeBytes: size} } @@ -163,8 +162,8 @@ func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int) err error reason string }{ - {agent.ErrWorkspaceWriteDirectory, proto.WorkspaceWriteReasonDirectory}, - {agent.ErrWorkspaceWriteUnsafe, proto.WorkspaceWriteReasonUnsafe}, + {ErrWorkspaceWriteDirectory, proto.WorkspaceWriteReasonDirectory}, + {ErrWorkspaceWriteUnsafe, proto.WorkspaceWriteReasonUnsafe}, } { if errors.Is(err, conflict.err) { return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: "write_rejected", Reason: conflict.reason} @@ -174,10 +173,10 @@ func workspaceWriteResult(write agent.WorkspaceWriteResult, err error, size int) err error code string }{ - {agent.ErrWorkspaceWriteUnavailable, "resource_unavailable"}, - {agent.ErrWorkspaceWriteBusy, "write_capacity"}, - {agent.ErrWorkspaceWriteInvalid, "invalid_request"}, - {agent.ErrWorkspaceWriteRejected, "write_rejected"}, + {ErrWorkspaceWriteUnavailable, "resource_unavailable"}, + {ErrWorkspaceWriteBusy, "write_capacity"}, + {ErrWorkspaceWriteInvalid, "invalid_request"}, + {ErrWorkspaceWriteRejected, "write_rejected"}, } { if errors.Is(err, failure.err) { return rejectedWorkspaceWrite(failure.code) diff --git a/apps/daemon/internal/dispatch/workspace_write_test.go b/apps/daemon/internal/dispatch/workspace_write_test.go index f4964ad97..88b4360ad 100644 --- a/apps/daemon/internal/dispatch/workspace_write_test.go +++ b/apps/daemon/internal/dispatch/workspace_write_test.go @@ -26,7 +26,7 @@ func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.Worksp t.Fatal(err) } sender := &recSender{} - r, err := dispatch.New(dispatch.Config{Registry: agent.NewRegistry(), Sender: sender, Environments: dispatch.LocalEnvironments(binding)}) + r, err := dispatch.New(dispatch.Config{Registry: agent.NewRegistry(), Sender: sender, Environments: binding.Resolve}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index c3110e3d2..f62ab53fc 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -7,8 +7,8 @@ import ( "strings" "sync" + "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/agentnetwork" ) @@ -86,6 +86,15 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa return r, 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 +} + func (b *Binding) Matches(environment, session string) bool { return b != nil && b.environment == environment && b.stateKey == "agents-api-"+session } diff --git a/apps/daemon/internal/localworkspace/directory.go b/apps/daemon/internal/localworkspace/directory.go index 3104ed8ef..ba0501978 100644 --- a/apps/daemon/internal/localworkspace/directory.go +++ b/apps/daemon/internal/localworkspace/directory.go @@ -5,13 +5,13 @@ import ( "io/fs" "strings" - "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" ) -func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { +func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit int) (dispatch.WorkspaceDirectoryResult, error) { if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || len(path) > 4096 || strings.ContainsAny(path, "\\\x00\r\n") || (path != "" && (path == "." || !fs.ValidPath(path))) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadInvalid + 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 index 1aecaae54..645e73d7a 100644 --- a/apps/daemon/internal/localworkspace/directory_native_test.go +++ b/apps/daemon/internal/localworkspace/directory_native_test.go @@ -2,10 +2,11 @@ package localworkspace import ( "errors" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "os" "path/filepath" "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" ) func TestNativeDirectoryAPI(t *testing.T) { @@ -21,7 +22,7 @@ func TestNativeDirectoryAPI(t *testing.T) { t.Fatal(got, err) } for _, path := range []string{"missing", "nested/data.bin"} { - if _, err := b.ListWorkspaceDirectory(t.Context(), path, 10); !errors.Is(err, agent.ErrWorkspaceNotDirectory) { + if _, err := b.ListWorkspaceDirectory(t.Context(), path, 10); !errors.Is(err, dispatch.ErrWorkspaceNotDirectory) { t.Fatal(path, err) } } diff --git a/apps/daemon/internal/localworkspace/export.go b/apps/daemon/internal/localworkspace/export.go deleted file mode 100644 index c4237105e..000000000 --- a/apps/daemon/internal/localworkspace/export.go +++ /dev/null @@ -1,30 +0,0 @@ -package localworkspace - -import ( - "context" - "errors" - "io" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func (b *Binding) ExportOutputs(ctx context.Context, output io.Writer) error { - return b.exportNativeOutputs(ctx, &exportWriter{output: output}) -} - -type exportWriter struct { - output io.Writer - size int64 -} - -func (w *exportWriter) Write(data []byte) (int, error) { - if len(data) == 0 { - return 0, nil - } - if int64(len(data)) > proto.WorkspaceExportMaxBytes-w.size { - return 0, errors.New("workspace export exceeds bound") - } - n, err := w.output.Write(data) - w.size += int64(n) - return n, err -} diff --git a/apps/daemon/internal/localworkspace/native_files.go b/apps/daemon/internal/localworkspace/native_files.go index bd1f2ac4d..b4ba22589 100644 --- a/apps/daemon/internal/localworkspace/native_files.go +++ b/apps/daemon/internal/localworkspace/native_files.go @@ -11,7 +11,7 @@ import ( "sort" "strings" - "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/google/uuid" ) @@ -28,18 +28,18 @@ func nativeAPIPath(path string) (string, error) { return filepath.Localize(path) } -func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { - result := agent.WorkspaceDirectoryResult{Entries: []agent.WorkspaceDirectoryEntry{}} +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, agent.ErrWorkspaceReadUnavailable + return result, dispatch.ErrWorkspaceReadUnavailable } local, err := nativeAPIPath(path) if err != nil { - return result, agent.ErrWorkspaceReadInvalid + return result, dispatch.ErrWorkspaceReadInvalid } root, err := os.OpenRoot(b.workspace) if err != nil { - return result, agent.ErrWorkspaceReadUnavailable + return result, dispatch.ErrWorkspaceReadUnavailable } defer root.Close() if local != "." { @@ -51,7 +51,7 @@ func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit in return result, fs.ErrPermission } if err != nil || !info.IsDir() { - return result, agent.ErrWorkspaceNotDirectory + return result, dispatch.ErrWorkspaceNotDirectory } } } @@ -60,15 +60,15 @@ func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit in return result, fs.ErrPermission } if err != nil { - return result, agent.ErrWorkspaceNotDirectory + return result, dispatch.ErrWorkspaceNotDirectory } defer dir.Close() entries, err := dir.ReadDir(limit + 1) if err != nil && err != io.EOF { - return result, agent.ErrWorkspaceNotDirectory + return result, dispatch.ErrWorkspaceNotDirectory } if ctx.Err() != nil { - return result, agent.ErrWorkspaceReadUnavailable + return result, dispatch.ErrWorkspaceReadUnavailable } result.Truncated = len(entries) > limit if result.Truncated { @@ -79,7 +79,7 @@ func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit in for _, entry := range entries { info, err := entry.Info() if err != nil { - return result, agent.ErrWorkspaceReadUncertain + return result, dispatch.ErrWorkspaceReadUncertain } item := proto.WorkspaceDirectoryEntry{Name: entry.Name(), Kind: "other"} switch { @@ -95,32 +95,32 @@ func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit in wire.Entries = append(wire.Entries, item) } if !proto.ValidWorkspaceDirectory(wire, limit) { - return result, agent.ErrWorkspaceReadInvalid + return result, dispatch.ErrWorkspaceReadInvalid } for _, item := range wire.Entries { - result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: item.Name, Kind: item.Kind, SizeBytes: item.SizeBytes}) + 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) (agent.WorkspaceWriteResult, error) { - result := agent.WorkspaceWriteResult{} +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, agent.ErrWorkspaceWriteInvalid + return result, dispatch.ErrWorkspaceWriteInvalid } root, err := os.OpenRoot(b.workspace) if err != nil { - return result, agent.ErrWorkspaceWriteUnavailable + return result, dispatch.ErrWorkspaceWriteUnavailable } defer root.Close() if err = root.MkdirAll(filepath.Dir(local), 0700); err != nil { - return result, agent.ErrWorkspaceWriteRejected + 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, agent.ErrWorkspaceWriteRejected + return result, dispatch.ErrWorkspaceWriteRejected } defer root.Remove(temporary) _, err = file.Write(data) @@ -129,19 +129,19 @@ func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) } closeErr := file.Close() if err != nil || closeErr != nil { - return result, agent.ErrWorkspaceWriteRejected + 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, agent.ErrWorkspaceWriteDirectory + return result, dispatch.ErrWorkspaceWriteDirectory } - return result, agent.ErrWorkspaceWriteUnsafe + return result, dispatch.ErrWorkspaceWriteUnsafe } - return result, agent.ErrWorkspaceWriteRejected + return result, dispatch.ErrWorkspaceWriteRejected } - return agent.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil + return dispatch.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil } const artifactFileBytes int64 = 200 << 20 @@ -156,7 +156,7 @@ type nativeExport struct { bytes int64 } -func (b *Binding) exportNativeOutputs(ctx context.Context, output io.Writer) error { +func (b *Binding) ExportOutputs(ctx context.Context, output io.Writer) error { if err := ctx.Err(); err != nil { return err } diff --git a/apps/daemon/internal/localworkspace/runtime_initialization.go b/apps/daemon/internal/localworkspace/runtime_initialization.go index 2db03a780..35df13ce1 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization.go @@ -10,16 +10,12 @@ import ( "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" ) -// InitializationFailure contains only a confirmed step's safe exit status. -type InitializationFailure struct{ ExitCode *int } - -func (*InitializationFailure) Error() string { return "Runtime initialization failed" } - var ErrInitializationUnconfirmed = errors.New("Runtime initialization unconfirmed") func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInitialization) error { @@ -42,11 +38,11 @@ func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInit } values, err := ReadToolEnvironment() if err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } packages, err := PackageDirectory() if err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } var binary string var args []string @@ -67,7 +63,7 @@ func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInit } } if err = os.MkdirAll(packages, 0700); err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } if input.Action == "npm" { binary, args, err = initializationNPM() @@ -104,36 +100,36 @@ func configureRuntime(values map[string]string) error { } path, err := initializedToolEnvironmentPath() if err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } packages, err := PackageDirectory() if err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } if os.MkdirAll(filepath.Dir(path), 0700) != nil || os.MkdirAll(packages, 0700) != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } root, err := os.OpenRoot(filepath.Dir(path)) if err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } defer root.Close() unlock, err := runtimefs.LockDirectory(root) if err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } defer unlock() if _, err = root.Stat(filepath.Base(path)); !errors.Is(err, os.ErrNotExist) { - return &InitializationFailure{} + 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 &InitializationFailure{} + return &dispatch.InitializationFailure{} } local, err := readToolEnvironmentFile(source) if err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } for key, value := range local { if runtime.GOOS == "windows" { @@ -198,15 +194,15 @@ func (b *Binding) installInitialFile(ctx context.Context, input proto.RuntimeIni defer func() { w.uncertain = errors.Is(err, ErrInitializationUnconfirmed) }() root, err := os.OpenRoot(b.workspace) if err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } defer root.Close() if root.MkdirAll(filepath.Dir(relative), 0700) != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } parent, err := root.OpenRoot(filepath.Dir(relative)) if err != nil { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } defer parent.Close() // Initial files replace existing contents, unlike public Files create. Finish @@ -218,5 +214,5 @@ func (b *Binding) installInitialFile(ctx context.Context, input proto.RuntimeIni } func initializationDependency(name string) error { - return fmt.Errorf("Runtime initialization requires %s: %w", name, &InitializationFailure{}) + 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 index 68174485d..0af92043d 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_process.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_process.go @@ -12,6 +12,7 @@ import ( "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" ) @@ -107,7 +108,7 @@ func runInitializationProcess(ctx context.Context, binary string, args []string, return ErrInitializationUnconfirmed } if info, err := os.Stat(directory); err != nil || !info.IsDir() { - return &InitializationFailure{} + return &dispatch.InitializationFailure{} } process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, Dir: directory, Env: env, KillTimeout: 250 * time.Millisecond}) @@ -137,5 +138,5 @@ func runInitializationProcess(ctx context.Context, binary string, args []string, if code < 1 || code > 255 { return ErrInitializationUnconfirmed } - return &InitializationFailure{ExitCode: &code} + 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 index e16e46787..58ec5bbd5 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_test.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_test.go @@ -15,6 +15,7 @@ import ( "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" ) @@ -262,7 +263,7 @@ func TestRuntimeInitializationMissingDependenciesAndInvalidRequests(t *testing.T t.Fatal(err) } for _, input := range []proto.RuntimeInitialization{{Action: "setup", Command: "true"}, {Action: "npm", Packages: []string{"valid"}}, {Action: "python", Packages: []string{"valid"}}} { - var failed *InitializationFailure + 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) } @@ -287,7 +288,7 @@ func TestRuntimeInitializationProcessSettlesAndDiscardsOutput(t *testing.T) { } env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=fail") err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) - var failed *InitializationFailure + var failed *dispatch.InitializationFailure if !errors.As(err, &failed) || failed.ExitCode == nil || *failed.ExitCode != 7 { t.Fatal("exit status", err) } diff --git a/apps/daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go index 8e14d0654..e158e9533 100644 --- a/apps/daemon/internal/localworkspace/write.go +++ b/apps/daemon/internal/localworkspace/write.go @@ -6,7 +6,7 @@ import ( "io/fs" "strings" - "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" ) @@ -15,22 +15,22 @@ 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 agent.WorkspaceWriteResult, err error) { +func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []byte) (result dispatch.WorkspaceWriteResult, err error) { if len(data) > WriteMaxBytes || len(path) > 4096 || path == "." || !fs.ValidPath(path) || strings.ContainsAny(path, "\\\x00\r\n") { - return result, agent.ErrWorkspaceWriteInvalid + return result, dispatch.ErrWorkspaceWriteInvalid } if ctx.Err() != nil { - return result, agent.ErrWorkspaceWriteUnavailable + return result, dispatch.ErrWorkspaceWriteUnavailable } w := b.writer if !w.mu.TryLock() { - return result, agent.ErrWorkspaceWriteBusy + return result, dispatch.ErrWorkspaceWriteBusy } defer w.mu.Unlock() if w.uncertain { - return result, agent.ErrWorkspaceWriteUncertain + return result, dispatch.ErrWorkspaceWriteUncertain } - defer func() { w.uncertain = errors.Is(err, agent.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_test.go b/apps/daemon/internal/localworkspace/write_test.go index fe15e99f9..f34ddc8bd 100644 --- a/apps/daemon/internal/localworkspace/write_test.go +++ b/apps/daemon/internal/localworkspace/write_test.go @@ -5,10 +5,11 @@ import ( "context" "errors" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "os" "path/filepath" "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" ) func nativeFileBinding(t *testing.T) *Binding { @@ -24,7 +25,7 @@ func TestNativeFileCreateAndNoReplace(t *testing.T) { if err != nil || got.SizeBytes != int64(size) { t.Fatal(size, got, err) } - if _, err = b.WriteWorkspaceFile(t.Context(), name, []byte("overwrite")); !errors.Is(err, agent.ErrWorkspaceWriteUnsafe) { + 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))) @@ -32,7 +33,7 @@ func TestNativeFileCreateAndNoReplace(t *testing.T) { t.Fatal("file content changed", err) } } - if _, err := b.WriteWorkspaceFile(t.Context(), "nested", nil); !errors.Is(err, agent.ErrWorkspaceWriteDirectory) { + 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 { @@ -51,16 +52,16 @@ func TestNativeFileCreateAndNoReplace(t *testing.T) { 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, agent.ErrWorkspaceWriteInvalid) { + 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, agent.ErrWorkspaceWriteInvalid) { + 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, agent.ErrWorkspaceWriteUnavailable) { + if _, err := b.WriteWorkspaceFile(ctx, "cancelled", nil); !errors.Is(err, dispatch.ErrWorkspaceWriteUnavailable) { t.Fatal(err) } if _, err := os.Stat(filepath.Join(b.workspace, "cancelled")); !os.IsNotExist(err) { @@ -69,11 +70,11 @@ func TestNativeFileAdmission(t *testing.T) { b.writer.mu.Lock() _, err := b.WriteWorkspaceFile(t.Context(), "busy", nil) b.writer.mu.Unlock() - if !errors.Is(err, agent.ErrWorkspaceWriteBusy) { + if !errors.Is(err, dispatch.ErrWorkspaceWriteBusy) { t.Fatal(err) } b.writer.uncertain = true - if _, err = b.WriteWorkspaceFile(t.Context(), "uncertain", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { + if _, err = b.WriteWorkspaceFile(t.Context(), "uncertain", nil); !errors.Is(err, dispatch.ErrWorkspaceWriteUncertain) { t.Fatal(err) } } From 81294a9b952fff2443b529043bc93ffc90f7b930 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 18:39:08 +0000 Subject: [PATCH 02/13] Scope Environment admission to the Session and drain owners Busy checks for runtime_prepare, workspace_write, workspace_export and execution_prepare now look only at the Session's own work, so another Session's Executor never blocks an Environment; the aggregate slots stay. Shutdown and quiescence drain the owners of unreleased assignments, and release cleanups of one assignment run one at a time, so a retry never closes an owner concurrently. --- apps/daemon/internal/dispatch/assignment.go | 8 ++- .../internal/dispatch/assignment_test.go | 55 +++++++++++++++++++ apps/daemon/internal/dispatch/executor.go | 2 +- apps/daemon/internal/dispatch/preparation.go | 2 +- apps/daemon/internal/dispatch/router.go | 8 +-- .../internal/dispatch/runtime_preparation.go | 40 ++++++++++++-- .../dispatch/runtime_preparation_test.go | 24 +++++--- apps/daemon/internal/dispatch/shutdown.go | 26 +++++++++ apps/daemon/internal/dispatch/suspend.go | 6 +- apps/daemon/internal/dispatch/suspend_test.go | 2 +- .../internal/dispatch/workspace_export.go | 2 +- .../internal/dispatch/workspace_read.go | 4 +- .../internal/dispatch/workspace_write.go | 18 ++---- 13 files changed, 159 insertions(+), 38 deletions(-) diff --git a/apps/daemon/internal/dispatch/assignment.go b/apps/daemon/internal/dispatch/assignment.go index 63e0e16f1..edebffd51 100644 --- a/apps/daemon/internal/dispatch/assignment.go +++ b/apps/daemon/internal/dispatch/assignment.go @@ -26,6 +26,9 @@ type assignmentState struct { // preparations until each has sent its terminal result. A release waits // for it, and the released assignment admits no more. work sync.WaitGroup + // cleanup serializes release cleanups, so a retried release never closes + // the owner while an earlier Close runs. + cleanup sync.Mutex } // admitLocked returns why ref admits no new work of sessionID in @@ -139,7 +142,8 @@ func (r *Router) handleAssignmentRelease(ctx context.Context, env proto.Envelope return r.reply(ctx, env, proto.TypeAssignmentStatus, assignmentStatus("", code)) } preparations := r.fenceSessionWorkLocked(ref.SessionID) - work, environment := &r.assignments[ref.SessionID].work, r.assignments[ref.SessionID].environment + a = r.assignments[ref.SessionID] + work, environment := &a.work, a.environment r.shutdownWG.Add(1) r.mu.Unlock() go func() { @@ -150,6 +154,8 @@ func (r *Router) handleAssignmentRelease(ctx context.Context, env proto.Envelope r.releasePreparation(p, "failed", proto.AssignmentStale, true) } work.Wait() + a.cleanup.Lock() + defer a.cleanup.Unlock() state, code := proto.AssignmentReleased, "" err := r.closeSessionExecutor(ref.SessionID) if err == nil && environment != nil { diff --git a/apps/daemon/internal/dispatch/assignment_test.go b/apps/daemon/internal/dispatch/assignment_test.go index c6d585e6a..930348dae 100644 --- a/apps/daemon/internal/dispatch/assignment_test.go +++ b/apps/daemon/internal/dispatch/assignment_test.go @@ -207,3 +207,58 @@ func TestAssignmentBindCarriesLink(t *testing.T) { t.Fatalf("bind with another grant = %+v", got) } } + +// closingOwner counts its Closes and fails if two overlap. The first Close +// waits for release. +type closingOwner struct { + dispatch.Environment + closes, active atomic.Int32 + overlapped atomic.Bool + entered chan struct{} + release chan struct{} +} + +func (o *closingOwner) Close(context.Context) error { + if o.active.Add(1) > 1 { + o.overlapped.Store(true) + } + defer o.active.Add(-1) + if o.closes.Add(1) == 1 && o.entered != nil { + close(o.entered) + <-o.release + } + return nil +} + +func TestReleaseRetryAndShutdownCloseOwnersOnce(t *testing.T) { + const other = "33333333-3333-4333-8333-333333333333" + released := &closingOwner{entered: make(chan struct{}), release: make(chan struct{})} + unreleased := &closingOwner{} + sender := &recSender{} + r, err := dispatch.New(dispatch.Config{Registry: agent.NewRegistry(), Sender: sender, Environments: func(ref proto.AssignmentRef, _ proto.AssignmentBindPayload) dispatch.Environment { + if ref.SessionID == other { + return unreleased + } + return released + }}) + if err != nil { + t.Fatal(err) + } + assign(t, r, preparationSessionID, preparationEnvironmentID) + assign(t, r, other, preparationEnvironmentID) + release(t, r, preparationSessionID, "release", 2, false) + <-released.entered + release(t, r, preparationSessionID, "retry", 2, false) + close(released.release) + for _, id := range []string{"release", "retry"} { + if got := waitAssignmentStatus(t, sender, id); got.State != proto.AssignmentReleased { + t.Fatalf("%s = %+v", id, got) + } + } + if err := r.Shutdown(t.Context()); err != nil { + t.Fatal(err) + } + if released.closes.Load() != 2 || released.overlapped.Load() || unreleased.closes.Load() != 1 { + t.Fatalf("released owner closes = %d (overlapped %t), unreleased owner closes = %d", released.closes.Load(), released.overlapped.Load(), unreleased.closes.Load()) + } +} diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 1a3f25c40..39b2a1aae 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -91,7 +91,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, r.mu.Unlock() return r.rejectPreparation(env, code) } - if r.workspaceWrite != nil || r.workspaceExport != nil || r.runtimePreparation != nil { + if r.environmentTransferLocked(input.SessionID) { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } diff --git a/apps/daemon/internal/dispatch/preparation.go b/apps/daemon/internal/dispatch/preparation.go index d8a0db769..151e41f41 100644 --- a/apps/daemon/internal/dispatch/preparation.go +++ b/apps/daemon/internal/dispatch/preparation.go @@ -83,7 +83,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) r.mu.Unlock() return r.rejectPreparation(env, code) } - if r.runtimePreparation != nil { + if u := r.runtimePreparation; u != nil && u.envelope.Assignment.SessionID == input.SessionID { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } diff --git a/apps/daemon/internal/dispatch/router.go b/apps/daemon/internal/dispatch/router.go index 3e0f68066..d2a8a0009 100644 --- a/apps/daemon/internal/dispatch/router.go +++ b/apps/daemon/internal/dispatch/router.go @@ -51,7 +51,7 @@ type Router struct { runtimePreparation *runtimePreparationTransfer workspaceWrite *workspaceUpload workspaceExport *workspaceExport - workspaceReads map[string]struct{} + workspaceReads map[string]string // read ID → SessionID environments func(proto.AssignmentRef, proto.AssignmentBindPayload) Environment sessionEnvironments bool removeHome func(sessionID string) error @@ -92,9 +92,9 @@ type Config struct { IdleTimeout time.Duration PreparationTimeout time.Duration // Environments resolves the Environment owner of a Session's first bind on - // this Router, under the Router's lock. A nil owner rejects the bind: the - // Runtime does not serve that Session. Nil Environments leaves every - // Session without an owner. + // this Router, under the Router's lock, without I/O. A nil owner rejects + // the bind: the Runtime does not serve that Session. Nil Environments + // leaves every Session without an owner. Environments func(proto.AssignmentRef, proto.AssignmentBindPayload) Environment // SessionEnvironments says that the Executor factory binds a prepared // execution's LocalEnvironment itself, without an owner. It excludes diff --git a/apps/daemon/internal/dispatch/runtime_preparation.go b/apps/daemon/internal/dispatch/runtime_preparation.go index e120cf423..0ad8c1586 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation.go +++ b/apps/daemon/internal/dispatch/runtime_preparation.go @@ -69,7 +69,7 @@ func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) e r.mu.Unlock() return r.sendRuntimePrepareResult(ctx, env, rejectedRuntimePreparation("runtime_preparation_unsupported")) } - if r.runtimePreparationResourcesBusyLocked() { + if r.runtimePreparationResourcesBusyLocked(request.SessionID) { r.mu.Unlock() return r.sendRuntimePrepareResult(ctx, env, rejectedRuntimePreparation("resource_unavailable")) } @@ -122,12 +122,44 @@ func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) e return nil } -func (r *Router) runtimePreparationResourcesBusyLocked() bool { - if r.workspaceWrite != nil || r.workspaceExport != nil || len(r.workspaceReads) != 0 || len(r.sessions) != 0 || len(r.executors) != 0 { +// runtimePreparationResourcesBusyLocked reports whether the Session has a +// transfer, a read, a Run, an Executor or an owned preparation. Router.mu must +// be held. +func (r *Router) runtimePreparationResourcesBusyLocked(sessionID string) bool { + if r.environmentTransferLocked(sessionID) || r.executors[sessionID] != nil || r.sessionWorkLocked(sessionID) { return true } for _, p := range r.preparations { - if p.owns || p.busy { + if p.busy && p.request.Assignment.SessionID == sessionID { + return true + } + } + return false +} + +// environmentTransferLocked reports whether the Session has a workspace write, +// a workspace export or a Runtime preparation. Router.mu must be held. +func (r *Router) environmentTransferLocked(sessionID string) bool { + return r.workspaceWrite != nil && r.workspaceWrite.envelope.Assignment.SessionID == sessionID || + r.workspaceExport != nil && r.workspaceExport.request.Assignment.SessionID == sessionID || + r.runtimePreparation != nil && r.runtimePreparation.envelope.Assignment.SessionID == sessionID +} + +// sessionWorkLocked reports whether the Session has a Run, a workspace read or +// an owned preparation. Router.mu must be held. +func (r *Router) sessionWorkLocked(sessionID string) bool { + for _, state := range r.sessions { + if state.assignment.SessionID == sessionID { + return true + } + } + for _, session := range r.workspaceReads { + if session == sessionID { + return true + } + } + for _, p := range r.preparations { + if p.owns && p.request.Assignment.SessionID == sessionID { return true } } diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go index 08c836b25..0d0fd721b 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -171,28 +171,38 @@ func TestRuntimePreparationBeginRequiresExactBindingAndBounds(t *testing.T) { } func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { - for _, mode := range []string{"write", "export", "read", "run", "executor", "preparation"} { + for _, mode := range []string{"write", "export", "read", "run", "executor", "preparation", "other session"} { t.Run(mode, func(t *testing.T) { r, sender, environment, session := capabilitiesTestRouter(t) + own := proto.Envelope{Assignment: capabilityRef} switch mode { case "write": - r.workspaceWrite = &workspaceUpload{} + r.workspaceWrite = &workspaceUpload{envelope: own} case "export": - r.workspaceExport = &workspaceExport{} + r.workspaceExport = &workspaceExport{request: own} case "read": - r.workspaceReads = map[string]struct{}{"read": {}} + r.workspaceReads = map[string]string{"read": session} case "run": - r.sessions["run"] = &sessionState{} + r.sessions["run"] = &sessionState{assignment: capabilityRef} case "executor": r.executors[session] = &executorState{} case "preparation": - r.preparations["p"] = &preparationState{owns: true} + r.preparations["p"] = &preparationState{owns: true, request: own} + case "other session": + // Another Session's Executor never blocks this Environment. + r.executors[uuid.NewString()] = &executorState{preparing: true} } id := uuid.NewString() if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, session, []byte("abc")))); err != nil { t.Fatal(err) } - if got := capabilitiesReceipt(t, sender, id, "rejected"); got.ErrorCode != "resource_unavailable" { + if mode == "other session" { + capabilitiesReceipt(t, sender, id, "ready") + r.mu.Lock() + r.finishRuntimePreparationTransferLocked(r.runtimePreparation, false) + r.mu.Unlock() + capabilitiesReceipt(t, sender, id, "rejected") + } else if got := capabilitiesReceipt(t, sender, id, "rejected"); got.ErrorCode != "resource_unavailable" { t.Fatal(got) } if r.runtimePreparation != nil { diff --git a/apps/daemon/internal/dispatch/shutdown.go b/apps/daemon/internal/dispatch/shutdown.go index b09de4c70..1c0e96836 100644 --- a/apps/daemon/internal/dispatch/shutdown.go +++ b/apps/daemon/internal/dispatch/shutdown.go @@ -68,6 +68,9 @@ func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionC } r.shutdownWG.Wait() + if err := r.closeEnvironments(context.Background(), ""); err != nil { + attempt.err = errors.Join(attempt.err, err) + } r.mu.Lock() if r.runtimePreparation != nil && r.runtimePreparation.uncertain { attempt.err = errors.Join(attempt.err, errors.New("dispatch: capability preparation remains uncertain")) @@ -95,6 +98,29 @@ func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionC r.mu.Unlock() } +// closeEnvironments drains the owners of the unreleased assignments in +// environmentID, or in every Environment when it is empty, once their work +// has settled. A drained owner serves its Session again on the next bind. +func (r *Router) closeEnvironments(ctx context.Context, environmentID string) error { + r.mu.Lock() + var owners []*assignmentState + for _, a := range r.assignments { + if !a.released && a.environment != nil && (environmentID == "" || a.environmentID == environmentID) { + owners = append(owners, a) + } + } + r.mu.Unlock() + var errs []error + for _, a := range owners { + a.cleanup.Lock() + if err := a.environment.Close(ctx); err != nil { + errs = append(errs, fmt.Errorf("dispatch: Environment of Session %s: %w", a.ref.SessionID, err)) + } + a.cleanup.Unlock() + } + return errors.Join(errs...) +} + func (r *Router) finishShutdownAttempt(attempt *shutdownAttempt, err error) { r.mu.Lock() attempt.err = errors.Join(attempt.err, err) diff --git a/apps/daemon/internal/dispatch/suspend.go b/apps/daemon/internal/dispatch/suspend.go index fccb8d4ee..6ff583c86 100644 --- a/apps/daemon/internal/dispatch/suspend.go +++ b/apps/daemon/internal/dispatch/suspend.go @@ -18,7 +18,7 @@ type AssignmentError string func (e AssignmentError) Error() string { return "dispatch: " + string(e) } // Quiesce serializes against admission, then drains every admitted output and -// receipt before acknowledging suspension. Busy rejection leaves admission open; +// receipt and the Environment's owners before acknowledging suspension. Busy rejection leaves admission open; // a drain timeout keeps it closed until the caller shuts the connection down. // ref must admit work in the suspended Environment. func (r *Router) Quiesce(ctx context.Context, ref proto.AssignmentRef, request proto.EnvironmentSuspendPayload) error { @@ -72,11 +72,13 @@ func (r *Router) Quiesce(ctx context.Context, ref proto.AssignmentRef, request p } r.mu.Unlock() err := r.shutdownWG.waitContext(ctx) + if err == nil { + err = r.closeEnvironments(ctx, request.EnvironmentID) + } r.mu.Lock() if r.closed { err = ErrRouterClosed } - r.mu.Unlock() return err } diff --git a/apps/daemon/internal/dispatch/suspend_test.go b/apps/daemon/internal/dispatch/suspend_test.go index 80e957c30..efe1d5714 100644 --- a/apps/daemon/internal/dispatch/suspend_test.go +++ b/apps/daemon/internal/dispatch/suspend_test.go @@ -56,7 +56,7 @@ func TestQuiesceRejectsEveryUnsettledResource(t *testing.T) { "active": func(r *Router) { r.sessions["run"] = &sessionState{} }, "preparing": func(r *Router) { r.preparations["p"] = &preparationState{owns: true} }, "receipt": func(r *Router) { r.preparations["p"] = &preparationState{busy: true} }, - "read": func(r *Router) { r.workspaceReads = map[string]struct{}{"read": {}} }, + "read": func(r *Router) { r.workspaceReads = map[string]string{"read": suspendRef.SessionID} }, "write": func(r *Router) { r.workspaceWrite = &workspaceUpload{} }, "export": func(r *Router) { r.workspaceExport = &workspaceExport{} }, } diff --git a/apps/daemon/internal/dispatch/workspace_export.go b/apps/daemon/internal/dispatch/workspace_export.go index 08de6fc7a..eeb2d912c 100644 --- a/apps/daemon/internal/dispatch/workspace_export.go +++ b/apps/daemon/internal/dispatch/workspace_export.go @@ -49,7 +49,7 @@ func (r *Router) handleWorkspaceExport(ctx context.Context, env proto.Envelope) } environment, code := r.workspaceResourceLocked(env.Assignment, proto.WorkspaceReadPayload{Handle: request.Handle, EnvironmentID: request.EnvironmentID}) p := r.preparations[request.Handle] - if code == "" && (u != nil || r.workspaceWrite != nil || p.executor != nil) { + if code == "" && (u != nil || r.workspaceWrite != nil && r.workspaceWrite.envelope.Assignment.SessionID == env.Assignment.SessionID || p.executor != nil) { code = "resource_unavailable" } if code != "" { diff --git a/apps/daemon/internal/dispatch/workspace_read.go b/apps/daemon/internal/dispatch/workspace_read.go index 924a4c59b..ad0b3f357 100644 --- a/apps/daemon/internal/dispatch/workspace_read.go +++ b/apps/daemon/internal/dispatch/workspace_read.go @@ -41,9 +41,9 @@ func (r *Router) handleWorkspaceRead(ctx context.Context, env proto.Envelope) er return r.sendWorkspaceRead(ctx, env, rejectedWorkspaceRead(code)) } if r.workspaceReads == nil { - r.workspaceReads = make(map[string]struct{}) + r.workspaceReads = make(map[string]string) } - r.workspaceReads[env.ID] = struct{}{} + r.workspaceReads[env.ID] = env.Assignment.SessionID done := r.trackWorkLocked(env.Assignment) r.shutdownWG.Add(1) r.mu.Unlock() diff --git a/apps/daemon/internal/dispatch/workspace_write.go b/apps/daemon/internal/dispatch/workspace_write.go index 452d39286..afa9def78 100644 --- a/apps/daemon/internal/dispatch/workspace_write.go +++ b/apps/daemon/internal/dispatch/workspace_write.go @@ -45,7 +45,8 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e return ErrRouterClosed } if request.Step == "begin" { - if r.workspaceExport != nil || r.runtimePreparation != nil { + if r.workspaceExport != nil && r.workspaceExport.request.Assignment.SessionID == request.SessionID || + r.runtimePreparation != nil && r.runtimePreparation.envelope.Assignment.SessionID == request.SessionID { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env, rejectedWorkspaceWrite("resource_unavailable")) } @@ -66,22 +67,11 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env, rejectedWorkspaceWrite("write_unsupported")) } - if len(r.sessions) != 0 || len(r.workspaceReads) != 0 { + if owner := r.executors[request.SessionID]; r.sessionWorkLocked(request.SessionID) || + owner != nil && (owner.preparing || owner.admission != nil || owner.run != nil || owner.invalid) { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env, rejectedWorkspaceWrite("resource_unavailable")) } - for _, owner := range r.executors { - if owner.preparing || owner.admission != nil || owner.run != nil || owner.invalid { - r.mu.Unlock() - return r.sendWorkspaceWrite(ctx, env, rejectedWorkspaceWrite("resource_unavailable")) - } - } - for _, p := range r.preparations { - if p.owns { - r.mu.Unlock() - return r.sendWorkspaceWrite(ctx, env, rejectedWorkspaceWrite("resource_unavailable")) - } - } u := &workspaceUpload{envelope: env, request: request, data: make([]byte, 0, request.SizeBytes), ready: make(chan struct{})} r.workspaceWrite = u done := r.trackWorkLocked(env.Assignment) From 5e7afc0d298e2e30380ea8654394daf118ec4fce Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 18:49:30 +0000 Subject: [PATCH 03/13] Compose environment capabilities once at registration The Runtime's Environment owner declares which Environments it serves, and agent.Registry.Register composes that with the Harness's own declaration. Views no longer carry a second capability declaration, adapters no longer load the local binding to declare capabilities, and the CLI no longer derives read preparation and export at heartbeat. --- .../internal/agent/claudesdk/declaration.go | 35 +++----- .../agent/claudesdk/declaration_test.go | 4 +- apps/daemon/internal/agent/claudesdk/view.go | 8 -- .../internal/agent/claudesdk/view_test.go | 2 +- .../internal/agent/codex/declaration.go | 3 +- .../internal/agent/codex/declaration_test.go | 2 +- .../internal/agent/codex/environment_local.go | 16 ---- apps/daemon/internal/agent/codex/view.go | 8 -- apps/daemon/internal/agent/codex/view_test.go | 2 +- apps/daemon/internal/agent/harness.go | 63 +++++++------- .../internal/agent/mcode/declaration.go | 1 + .../internal/agent/mcode/declaration_test.go | 4 +- .../agent/mcode/discovery_workspace.go | 5 -- apps/daemon/internal/agent/mcode/view.go | 10 +-- apps/daemon/internal/agent/mcode/view_test.go | 2 +- apps/daemon/internal/agent/registry_test.go | 31 ++++++- apps/daemon/internal/agent/view_test.go | 8 +- .../agent/viewloader/loader_linux_test.go | 3 - apps/daemon/internal/agenthost/admit.go | 12 +-- .../internal/agenthost/admit_linux_test.go | 87 +++++-------------- .../agenthost/agenthost_linux_test.go | 9 +- apps/daemon/internal/agenthost/doc.go | 11 ++- .../internal/agenthost/executor_linux.go | 21 ++--- .../internal/agenthost/view_linux_test.go | 15 ++-- .../agenthostqualify/qualify_linux_test.go | 13 ++- .../daemon/internal/cli/agent_registration.go | 4 +- .../cli/claude_sdk_live_linux_test.go | 2 +- apps/daemon/internal/cli/connect.go | 31 ++----- .../internal/cli/connect_cleanup_test.go | 4 +- apps/daemon/internal/cli/connect_suspend.go | 15 ++-- .../internal/cli/connect_suspend_test.go | 6 +- .../internal/cli/native_discovery_test.go | 2 +- .../daemon/internal/localworkspace/binding.go | 7 ++ contracts/agents-api/harness-onboarding.md | 19 +--- contracts/agents-api/zh/harness-onboarding.md | 21 ++--- docs/runtime-protocol.md | 2 +- docs/zh/runtime-protocol.md | 4 +- 37 files changed, 178 insertions(+), 314 deletions(-) delete mode 100644 apps/daemon/internal/agent/codex/environment_local.go diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index c296940a9..4c4334ec1 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -100,34 +100,27 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d if err != nil { return fail(err) } + caps := &out.Info.Capabilities if config.Workspace != nil { if !info.SupportsLocalRuntime() { return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) } - caps := &out.Info.Capabilities - caps.EnvironmentNone, caps.FunctionTools = proto.CapabilityUnsupported, proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()) - caps.LocalEnvironment = proto.CapabilitySupported caps.NativeSessionRecovery = proto.CapabilitySupported } + // 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. out.Info.Available, out.Info.Version = true, info.SDK - out.Info.Capabilities.MessageImages = proto.CapabilityFromBool(info.SupportsMessageImages()) - out.Info.Capabilities.FunctionResultImages = proto.CapabilityFromBool(info.SupportsFunctionResultImages()) - out.Info.Capabilities.ToolSearch = proto.CapabilityFromBool(info.SupportsToolSearch()) - if config.Workspace != nil { - out.Info.Capabilities.ToolSearch = proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()) - } - out.Info.Capabilities.StructuredOutput = proto.CapabilityFromBool(info.SupportsStructuredOutput()) - if config.Workspace != nil { - out.Info.Capabilities.StructuredOutput = proto.CapabilityFromBool(info.SupportsWorkspaceStructuredOutput()) - } - out.Info.Capabilities.SubagentObservations = proto.CapabilityFromBool(info.SupportsSubagents()) - out.Info.Capabilities.MCPHTTPTools = proto.CapabilityFromBool(info.SupportsHTTPMCP()) - out.Info.Capabilities.MCPHTTPBearerAuth = proto.CapabilityFromBool(info.SupportsHTTPMCPBearer()) - out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityFromBool(info.SupportsHTTPMCPRequired()) - if config.Workspace != nil && !info.SupportsWorkspaceMCP() { - out.Info.Capabilities.MCPHTTPTools, out.Info.Capabilities.MCPHTTPBearerAuth = proto.CapabilityUnsupported, proto.CapabilityUnsupported - out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityUnsupported - } + caps.LocalEnvironment = proto.CapabilityFromBool(info.SupportsLocalRuntime()) + caps.FunctionTools = proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()) + caps.MessageImages = proto.CapabilityFromBool(info.SupportsMessageImages()) + caps.FunctionResultImages = proto.CapabilityFromBool(info.SupportsFunctionResultImages()) + caps.ToolSearch = proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()) + caps.StructuredOutput = proto.CapabilityFromBool(info.SupportsWorkspaceStructuredOutput()) + caps.SubagentObservations = proto.CapabilityFromBool(info.SupportsSubagents()) + 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 { diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index 4d04b8074..330aa96fd 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -48,7 +48,9 @@ func TestClaudeSDKFeatureDiscovery(t *testing.T) { 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) { - info := RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} + // 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...)} return info, nil }) supported := len(features) > 0 && features[0] == "mcp_http_tools" diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index 8f0146c78..88046d0ed 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -83,14 +83,6 @@ func declareView(probe Config, info RuntimeInfo, node, root, bridge string, load Shims: []string{"bash", "rg", "git"}, ForwardEnv: []string{"CLAUDECODE", "GIT_EDITOR"}, Proxy: agent.ViewProxyEnv, - Capabilities: agent.ViewCapabilities{ - EnvironmentNone: proto.CapabilitySupported, - Skills: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()), - FunctionResultImages: proto.CapabilityFromBool(info.SupportsFunctionResultImages()), - ToolSearch: proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()), - StdioMCP: proto.CapabilitySupported, - }, } loader.AddTo(view) view.Executor = newViewExecutorFactory(probe, layout) diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index a7123f622..5d7a25197 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -180,7 +180,7 @@ func resolveTestView(t *testing.T) agent.View { 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) registry := agent.NewRegistry() - registry.Register(Declaration, agent.Runtime{Info: Declaration.Info, View: declared}) + registry.Register(Declaration, agent.Runtime{Info: Declaration.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/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index b5d0c1684..07dc44dc6 100644 --- a/apps/daemon/internal/agent/codex/declaration.go +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -50,7 +50,8 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, i runtime.Info.Available, runtime.Info.Version = true, version caps := &runtime.Info.Capabilities caps.NativeSessionRecovery = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) - caps.LocalEnvironment = proto.CapabilityFromBool(SupportsLocalEnvironment(version)) + // A local Environment requires native Session recovery. + caps.LocalEnvironment = caps.NativeSessionRecovery caps.MCPHTTPRequired = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) runtime.Executor = NewExecutorFactory() runtime.View = discoverView(version) diff --git a/apps/daemon/internal/agent/codex/declaration_test.go b/apps/daemon/internal/agent/codex/declaration_test.go index 6090695da..c0623229f 100644 --- a/apps/daemon/internal/agent/codex/declaration_test.go +++ b/apps/daemon/internal/agent/codex/declaration_test.go @@ -15,7 +15,7 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return version, nil }) - if !runtime.Info.Available || runtime.Executor == nil || runtime.Info.Capabilities.LocalEnvironment.IsSupported() != SupportsLocalEnvironment(version) { + if !runtime.Info.Available || runtime.Executor == nil { t.Fatalf("factories: %+v", runtime) } if runtime.Info.Capabilities.MCPHTTPRequired.IsSupported() != (version == "codex-cli 0.153.4") { diff --git a/apps/daemon/internal/agent/codex/environment_local.go b/apps/daemon/internal/agent/codex/environment_local.go deleted file mode 100644 index 9f59aada0..000000000 --- a/apps/daemon/internal/agent/codex/environment_local.go +++ /dev/null @@ -1,16 +0,0 @@ -package codex - -import ( - "runtime" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" -) - -// SupportsLocalEnvironment checks deployment prerequisites, not public admission. -func SupportsLocalEnvironment(version string) bool { - if !SupportsNativeSessionRecovery(version) || (runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows") { - return false - } - binding, err := localworkspace.Load() - return err == nil && binding != nil -} diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go index b64e0c499..d8f905cc4 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -86,14 +86,6 @@ func newView(binary string, codeModeHost bool) agent.View { ShimPaths: []string{"/bin/bash"}, ForwardEnv: slices.Clone(viewForwardEnv), Proxy: agent.ViewProxyEnv, - Capabilities: agent.ViewCapabilities{ - EnvironmentNone: proto.CapabilitySupported, - Skills: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilitySupported, - FunctionResultImages: proto.CapabilitySupported, - ToolSearch: proto.CapabilityUnsupported, - StdioMCP: proto.CapabilitySupported, - }, Executor: func(ctx context.Context, req proto.PromptRequestPayload, session agent.ViewSession) (agent.Executor, error) { cfg := defaultSessionConfig() cfg.codexBinary = binary diff --git a/apps/daemon/internal/agent/codex/view_test.go b/apps/daemon/internal/agent/codex/view_test.go index fda7a6bc3..51605ea7f 100644 --- a/apps/daemon/internal/agent/codex/view_test.go +++ b/apps/daemon/internal/agent/codex/view_test.go @@ -24,7 +24,7 @@ func TestViewExecutorLaunchesInTheSessionView(t *testing.T) { info := Declaration.Info info.Available = true registry := agent.NewRegistry() - registry.Register(Declaration, agent.Runtime{Info: info, View: &declared}) + registry.Register(Declaration, agent.Runtime{Info: info, View: &declared}, agent.EnvironmentSupport{Local: true, None: true}) view, err := registry.ResolveView("codex") if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 565a95816..e089eefed 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -14,7 +14,9 @@ // 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 workspace operations. +// owner, not the adapter, serves and declares the Environments a kind runs +// in: Register composes its EnvironmentSupport with the Harness's own +// declaration once. // // Runtime registration and Core service qualification remain separate. A public // Harness also needs a profile in services/core/internal/engine; advertising @@ -70,14 +72,36 @@ type Runtime struct { View *View } -// Register installs a discovered Runtime with its declaration's configuration. -func (r *Registry) Register(declaration Declaration, runtime Runtime) { +// EnvironmentSupport is what the Runtime's Environment owner serves. The +// Harness's own declaration states LocalEnvironment and EnvironmentNone as +// what its Executors can run; the owner decides which of them the Runtime +// offers, and serves read-only preparation and output export wherever it +// offers a local Environment. +type EnvironmentSupport struct { + // Local serves executions in a local Environment. + Local bool + // None serves executions with environment none. + None bool +} + +// Compose narrows caps, a Harness's own declaration, to what s serves. +func (s EnvironmentSupport) Compose(caps proto.AgentKindCapabilities) proto.AgentKindCapabilities { + caps.LocalEnvironment = proto.CapabilityFromBool(s.Local && caps.LocalEnvironment.IsSupported()) + caps.WorkspaceReadPreparation, caps.WorkspaceOutputExport = caps.LocalEnvironment, caps.LocalEnvironment + caps.EnvironmentNone = proto.CapabilityFromBool(s.None && caps.EnvironmentNone.IsSupported()) + return caps +} + +// Register installs a discovered Runtime with its declaration's configuration, +// in the Environments that environments serves. +func (r *Registry) Register(declaration Declaration, runtime Runtime, environments EnvironmentSupport) { 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") } + 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) @@ -91,8 +115,8 @@ func (r *Registry) Register(declaration Declaration, runtime Runtime) { // view: the sandbox world at /, the closure, home and shims under // ViewPrivateRoot, and a loopback-only network whose model, MCP and proxy // endpoints belong to the Session's credential gateway. The declaration is -// data; the agent host builds each view from it and the Session, and admits a -// request only when the view declares each capability the request uses. +// data; the agent host builds each view from it and the Session. A view runs +// every request that the Runtime's declaration admits. // // Environment none. A request with DisableExecutionEnvironment runs in an // empty-root view: a read-only, noexec tmpfs root that holds only the @@ -207,28 +231,7 @@ type View struct { // environment wins over a forwarded variable of the same name. ForwardEnv []string Proxy ViewProxy - // Capabilities declares what the view supports. - Capabilities ViewCapabilities - Executor ViewExecutorFactory -} - -// ViewCapabilities declares, field by field, what a view supports. Each field -// is set explicitly. -type ViewCapabilities struct { - // EnvironmentNone runs a request with DisableExecutionEnvironment in an - // empty-root view. - EnvironmentNone proto.CapabilitySupport - // Skills runs a request with resolved Skills (LocalEnvironment.Skills). - Skills proto.CapabilitySupport - // FunctionTools, FunctionResultImages and ToolSearch mean what the - // proto.AgentKindCapabilities fields of the same names mean. - FunctionTools proto.CapabilitySupport - FunctionResultImages proto.CapabilitySupport - ToolSearch proto.CapabilitySupport - // StdioMCP runs stdio MCP bindings under their aliases. A stdio binding - // whose CredentialAuthority is not "none" is rejected with ErrViewHandoff - // whatever the view declares. - StdioMCP proto.CapabilitySupport + Executor ViewExecutorFactory } // ViewMount presents HostDir at ViewPrivateRoot/. @@ -372,12 +375,6 @@ func (v View) Validate() error { if v.Proxy != ViewProxyNone && v.Proxy != ViewProxyEnv { return invalidView("proxy %d", v.Proxy) } - c := reflect.ValueOf(v.Capabilities) - for i := range c.NumField() { - if s := c.Field(i).Interface().(proto.CapabilitySupport); s != proto.CapabilitySupported && s != proto.CapabilityUnsupported { - return invalidView("capability %s is not declared", c.Type().Field(i).Name) - } - } names := map[string]bool{ViewShimName: true, ViewHomeName: true, ViewRunName: true} for _, m := range v.Closure { if !isPathComponent(m.Name) || names[m.Name] { diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index 3ed63b4e6..d39d232e9 100644 --- a/apps/daemon/internal/agent/mcode/declaration.go +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -51,6 +51,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, r // Native preparation verifies the applied admission/tool profile before input. result.Capabilities.SubagentObservations = proto.CapabilitySupported result.Capabilities.EnvironmentNone = proto.CapabilitySupported + result.Capabilities.LocalEnvironment = proto.CapabilitySupported result.Capabilities.MCPHTTPTools = proto.CapabilitySupported result.Capabilities.MCPHTTPBearerAuth = proto.CapabilitySupported runtime.Info = result diff --git a/apps/daemon/internal/agent/mcode/declaration_test.go b/apps/daemon/internal/agent/mcode/declaration_test.go index b9d2dacaa..4393e7503 100644 --- a/apps/daemon/internal/agent/mcode/declaration_test.go +++ b/apps/daemon/internal/agent/mcode/declaration_test.go @@ -24,10 +24,10 @@ func TestMCodeExecutionFollowsAvailability(t *testing.T) { if (runtime.Executor != nil) != available { t.Fatalf("factories: %+v", runtime) } - if info.Available != available || info.Capabilities.EnvironmentNone.IsSupported() != available || info.Capabilities.SubagentObservations.IsSupported() != available { + if info.Available != available || info.Capabilities.EnvironmentNone.IsSupported() != available || info.Capabilities.LocalEnvironment.IsSupported() != available || info.Capabilities.SubagentObservations.IsSupported() != available { t.Fatalf("capabilities=%+v", info.Capabilities) } - if info.Capabilities.NativeSessionRecovery.IsSupported() || info.Capabilities.LocalEnvironment.IsSupported() || info.Capabilities.FunctionTools.IsSupported() { + if info.Capabilities.NativeSessionRecovery.IsSupported() || info.Capabilities.FunctionTools.IsSupported() { t.Fatal("unqualified capability advertised") } }) diff --git a/apps/daemon/internal/agent/mcode/discovery_workspace.go b/apps/daemon/internal/agent/mcode/discovery_workspace.go index 7c177ec92..62de3696b 100644 --- a/apps/daemon/internal/agent/mcode/discovery_workspace.go +++ b/apps/daemon/internal/agent/mcode/discovery_workspace.go @@ -11,7 +11,6 @@ import ( "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" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, runtime *agent.Runtime) *WorkspaceConfig { @@ -45,10 +44,6 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r fail(err) return nil } - - caps := &runtime.Info.Capabilities - caps.EnvironmentNone = proto.CapabilityUnsupported - caps.LocalEnvironment = proto.CapabilitySupported return &c } diff --git a/apps/daemon/internal/agent/mcode/view.go b/apps/daemon/internal/agent/mcode/view.go index f6058f1f2..2a3e5db28 100644 --- a/apps/daemon/internal/agent/mcode/view.go +++ b/apps/daemon/internal/agent/mcode/view.go @@ -130,15 +130,7 @@ func (i viewInstall) view() agent.View { Shims: []string{"git", "rg"}, ShimPaths: []string{"/bin/bash"}, Proxy: agent.ViewProxyNone, - Capabilities: agent.ViewCapabilities{ - EnvironmentNone: proto.CapabilitySupported, - Skills: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - StdioMCP: proto.CapabilitySupported, - }, - Executor: i.executor, + Executor: i.executor, } i.loader.AddTo(&view) return view diff --git a/apps/daemon/internal/agent/mcode/view_test.go b/apps/daemon/internal/agent/mcode/view_test.go index f21a17dcb..5333c4498 100644 --- a/apps/daemon/internal/agent/mcode/view_test.go +++ b/apps/daemon/internal/agent/mcode/view_test.go @@ -48,7 +48,7 @@ func viewFixture(t *testing.T) (viewInstall, agent.View, proto.PromptRequestPayl registry := agent.NewRegistry() info := Declaration.Info info.Available = true - registry.Register(Declaration, agent.Runtime{Info: info, View: &declared}) + registry.Register(Declaration, agent.Runtime{Info: info, View: &declared}, agent.EnvironmentSupport{Local: true, None: true}) view, err := registry.ResolveView("mcode") if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/registry_test.go b/apps/daemon/internal/agent/registry_test.go index 82f228169..4c9c1ef6d 100644 --- a/apps/daemon/internal/agent/registry_test.go +++ b/apps/daemon/internal/agent/registry_test.go @@ -24,6 +24,35 @@ func TestRegistryRegisterOverwritesDescriptor(t *testing.T) { } } +// The heartbeat declares what the Harness runs within what the Environment +// owner serves, and the owner serves read preparation and export with every +// local Environment. +func TestRegisterComposesEnvironmentSupport(t *testing.T) { + s, u := proto.CapabilitySupported, proto.CapabilityUnsupported + for _, c := range []struct { + environments agent.EnvironmentSupport + local, none bool + wantLocal, wantNone proto.CapabilitySupport + }{ + {agent.EnvironmentSupport{Local: true, None: true}, true, true, s, s}, + {agent.EnvironmentSupport{Local: true}, true, true, s, u}, + {agent.EnvironmentSupport{None: true}, true, true, u, s}, + {agent.EnvironmentSupport{Local: true, None: true}, false, false, u, u}, + } { + info := proto.SupportedAgentKind{Kind: "k", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ + LocalEnvironment: proto.CapabilityFromBool(c.local), EnvironmentNone: proto.CapabilityFromBool(c.none)})} + registry := agent.NewRegistry() + registry.Register(agent.Declaration{Info: info}, agent.Runtime{Info: info}, c.environments) + got := registry.SupportedAgentKinds()[0].Capabilities + want := info.Capabilities + want.LocalEnvironment, want.EnvironmentNone = c.wantLocal, c.wantNone + want.WorkspaceReadPreparation, want.WorkspaceOutputExport = c.wantLocal, c.wantLocal + if got != want { + t.Errorf("%+v over %+v: declared %+v, want %+v", c.environments, info.Capabilities, got, want) + } + } +} + func TestRegistryRegisterPanicsOnEmptyKind(t *testing.T) { defer func() { if r := recover(); r == nil { @@ -45,7 +74,7 @@ func TestRegistryRegisterRejectsFactoriesForUnavailableRuntime(t *testing.T) { t.Fatal("rejected runtime changed registry") } }() - registry.Register(agent.Declaration{Info: info}, agent.Runtime{Info: info, Executor: executor}) + registry.Register(agent.Declaration{Info: info}, agent.Runtime{Info: info, Executor: executor}, agent.EnvironmentSupport{Local: true}) } func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { diff --git a/apps/daemon/internal/agent/view_test.go b/apps/daemon/internal/agent/view_test.go index 04f1332e9..f9108eddb 100644 --- a/apps/daemon/internal/agent/view_test.go +++ b/apps/daemon/internal/agent/view_test.go @@ -36,7 +36,6 @@ func TestViewValidate(t *testing.T) { "duplicate shim": func(v *agent.View) { v.Shims = append(v.Shims, "git") }, "shim named as the relay": func(v *agent.View) { v.Shims = append(v.Shims, agent.ViewRelayName) }, "shim named as an alias": func(v *agent.View) { v.Shims = append(v.Shims, path.Base(agent.ViewAlias(0))) }, - "undeclared capability": func(v *agent.View) { v.Capabilities.StdioMCP = proto.CapabilityUnspecified }, "forwarded assignment": func(v *agent.View) { v.ForwardEnv = append(v.ForwardEnv, "A=B") }, "forwarded broker variable": func(v *agent.View) { v.ForwardEnv = append(v.ForwardEnv, "PATH") }, "forwarded proxy variable": func(v *agent.View) { v.ForwardEnv = append(v.ForwardEnv, "https_proxy") }, @@ -54,7 +53,7 @@ func TestRegistryResolvesOnlyDeclaredViews(t *testing.T) { declared := validView(t) for kind, view := range map[string]*agent.View{"with_view": &declared, "without_view": nil} { info := proto.SupportedAgentKind{Kind: kind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})} - reg.Register(agent.Declaration{Info: info}, agent.Runtime{Info: info, View: view}) + reg.Register(agent.Declaration{Info: info}, agent.Runtime{Info: info, View: view}, agent.EnvironmentSupport{}) } if _, err := reg.ResolveView("without_view"); !errors.Is(err, agent.ErrUnsupportedOperation) { t.Fatalf("ResolveView without a view = %v, want ErrUnsupportedOperation", err) @@ -76,7 +75,7 @@ func TestViewExecutorReceivesOnlyGatewayConnections(t *testing.T) { Info: info, Configuration: harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: string(modelprovider.Responses)}}}, } - reg.Register(declaration, agent.Runtime{Info: info, View: &declared}) + reg.Register(declaration, agent.Runtime{Info: info, View: &declared}, agent.EnvironmentSupport{}) view, err := reg.ResolveView("viewed") if err != nil { t.Fatal(err) @@ -139,9 +138,6 @@ func validView(t *testing.T) agent.View { ShimPaths: []string{"/bin/sh"}, ForwardEnv: []string{"GIT_EDITOR"}, Proxy: agent.ViewProxyEnv, - Capabilities: agent.ViewCapabilities{EnvironmentNone: proto.CapabilityUnsupported, Skills: proto.CapabilitySupported, - FunctionTools: proto.CapabilitySupported, FunctionResultImages: proto.CapabilityUnsupported, ToolSearch: proto.CapabilityUnsupported, - StdioMCP: proto.CapabilityUnsupported}, Executor: func(context.Context, proto.PromptRequestPayload, agent.ViewSession) (agent.Executor, error) { return nil, errors.New("not started") }, diff --git a/apps/daemon/internal/agent/viewloader/loader_linux_test.go b/apps/daemon/internal/agent/viewloader/loader_linux_test.go index b44d1cbd6..23832f780 100644 --- a/apps/daemon/internal/agent/viewloader/loader_linux_test.go +++ b/apps/daemon/internal/agent/viewloader/loader_linux_test.go @@ -35,10 +35,7 @@ func TestForPresentsTheHostLoader(t *testing.T) { if !slices.Equal(fragment.Masks, []agent.ViewMask{{Path: "/etc/ld.so.preload"}, {Path: "/etc/ld.so.cache"}}) { t.Fatalf("masks = %+v", fragment.Masks) } - unsupported := proto.CapabilityUnsupported view := agent.View{Proxy: agent.ViewProxyNone, LocalExec: []string{fragment.Overlays[0].Path}, - Capabilities: agent.ViewCapabilities{EnvironmentNone: unsupported, Skills: unsupported, FunctionTools: unsupported, - FunctionResultImages: unsupported, ToolSearch: unsupported, StdioMCP: unsupported}, Executor: func(context.Context, proto.PromptRequestPayload, agent.ViewSession) (agent.Executor, error) { return nil, nil }} diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index df8363535..34d30d75e 100644 --- a/apps/daemon/internal/agenthost/admit.go +++ b/apps/daemon/internal/agenthost/admit.go @@ -97,24 +97,18 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env if err != nil { return nil, fmt.Errorf("%w: admit: %w", ErrUnsupported, err) } - caps, local, none := view.Capabilities, req.LocalEnvironment, req.DisableExecutionEnvironment + local, none := req.LocalEnvironment, req.DisableExecutionEnvironment switch { case local == nil && !none: return nil, invalidSession("a Session with neither a workspace nor environment none is an incomplete binding") - case none && !caps.EnvironmentNone.IsSupported(): - return nil, unsupported("environment none") case local != nil && !isViewPath(local.WorkspaceDirectory): return nil, invalidSession("workspace %q is not absolute and clean", local.WorkspaceDirectory) case local != nil && local.Capabilities && local.CapabilityRoot == "": return nil, unsupported("installed Capabilities that no preparation resolved") - case local != nil && len(local.Skills) > 0 && !caps.Skills.IsSupported(): + case local != nil && len(local.Skills) > 0: return nil, unsupported("Skills") case local != nil && (local.NetworkAccess != "enabled" || len(local.AllowedDomains) > 0): return nil, unsupported("a restricted workspace network") - case len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported(): - return nil, unsupported("function tools") - case req.ToolSearch && !caps.ToolSearch.IsSupported(): - return nil, unsupported("tool search") case !none && len(view.Shims) > 0 && !hasPATH(env): return nil, invalidSession("the view's shims run names on the sandbox PATH, and the Environment sets no PATH") } @@ -147,8 +141,6 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env switch { case b.CredentialAuthority != "none": return nil, fmt.Errorf("%w: admit: %w: stdio MCP server %q needs a credential", ErrUnsupported, agent.ErrViewHandoff, b.ServerLabel) - case !caps.StdioMCP.IsSupported(): - return nil, unsupported("stdio MCP server %q", b.ServerLabel) case !path.IsAbs(dir): return nil, invalidSession("stdio MCP server %q has no absolute working directory", b.ServerLabel) case !strings.Contains(server.Command, "/") && !hasPATH(env): diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index 55f69464b..6c5321f9a 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -26,11 +26,10 @@ import ( var errFactory = errors.New("factory reached") -// viewFixture registers "viewed", whose factory records what it receives and -// whose view supports no capability, "supporting", whose view supports every -// capability, "masked", whose view masks an /etc file the agent host writes, -// "shimmed", whose view runs a shim name on the sandbox PATH, and "plain", -// which declares no view. +// viewFixture registers "viewed", whose factory records what it receives, +// "masked", whose view masks an /etc file the agent host writes, "shimmed", +// whose view runs a shim name on the sandbox PATH, and "plain", which declares +// no view. type viewFixture struct { cfg Config req proto.PromptRequestPayload @@ -44,10 +43,9 @@ func newViewFixture(t *testing.T) *viewFixture { f := &viewFixture{} reg := agent.NewRegistry() view := agent.View{ - Closure: []agent.ViewMount{{Name: "harness", HostDir: t.TempDir()}}, - LocalExec: []string{"/.oac/harness/harness"}, - Proxy: agent.ViewProxyEnv, - Capabilities: declared(proto.CapabilityUnsupported), + Closure: []agent.ViewMount{{Name: "harness", HostDir: t.TempDir()}}, + LocalExec: []string{"/.oac/harness/harness"}, + Proxy: agent.ViewProxyEnv, Executor: func(_ context.Context, req proto.PromptRequestPayload, s agent.ViewSession) (agent.Executor, error) { f.req, f.session = req, s info, err := os.Stat(s.Home.Host) @@ -56,9 +54,6 @@ func newViewFixture(t *testing.T) *viewFixture { }, } register(reg, "viewed", &view) - supporting := view - supporting.Capabilities = declared(proto.CapabilitySupported) - register(reg, "supporting", &supporting) masked := view masked.Masks = []agent.ViewMask{{Path: "/etc/passwd"}} register(reg, "masked", &masked) @@ -80,21 +75,24 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { }{ "kind without a view": {"plain", func(*proto.PromptRequestPayload) {}, unsupported}, "view meeting the agent host's /etc": {"masked", func(*proto.PromptRequestPayload) {}, []error{ErrUnsupported, agent.ErrInvalidView}}, - "incomplete binding": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment = nil }, []error{ErrInvalidSession}}, + "incomplete binding": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment = nil }, []error{ErrInvalidSession}}, "shim name without PATH": {"shimmed", func(*proto.PromptRequestPayload) {}, []error{ErrInvalidSession}}, "relative workspace": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.WorkspaceDirectory = "workspace" }, []error{ErrInvalidSession}}, "no model provider": {"viewed", func(r *proto.PromptRequestPayload) { r.ModelProvider = nil }, []error{ErrUnsupported}}, - // The typed rejections that hold whatever the view declares. - "restricted network": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.NetworkAccess = "disabled" }, unsupported}, - "allowed domains only": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.AllowedDomains = []string{"example.com"} }, unsupported}, - "unprepared Capabilities": {"supporting", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities = true }, unsupported}, - "credentialed stdio MCP": {"supporting", func(r *proto.PromptRequestPayload) { + "restricted network": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.NetworkAccess = "disabled" }, unsupported}, + "allowed domains only": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.AllowedDomains = []string{"example.com"} }, unsupported}, + "unprepared Capabilities": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities = true }, unsupported}, + "Skills": {"viewed", func(r *proto.PromptRequestPayload) { + r.LocalEnvironment.Capabilities, r.LocalEnvironment.CapabilityRoot = true, "/capabilities" + r.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "skills/review"}} + }, unsupported}, + "credentialed stdio MCP": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools", EnvVars: []string{"TOKEN"}}}} }, []error{ErrUnsupported, agent.ErrViewHandoff}}, - "stdio MCP without an absolute directory": {"supporting", func(r *proto.PromptRequestPayload) { + "stdio MCP without an absolute directory": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{PackageRoot: "pkg", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "/bin/tools"}}} }, []error{ErrInvalidSession}}, - "stdio MCP name without PATH": {"supporting", func(r *proto.PromptRequestPayload) { + "stdio MCP name without PATH": {"viewed", func(r *proto.PromptRequestPayload) { r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{InstallationRoot: "/capabilities", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools"}}} }, []error{ErrInvalidSession}}, } { @@ -133,43 +131,6 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { } } -func TestAdmissionFollowsDeclarations(t *testing.T) { - f := newViewFixture(t) - roots, err := checkConfig(f.cfg) - if err != nil { - t.Fatal(err) - } - network := func(context.Context) (sandboxlink.Stream, error) { return nil, errors.New("not dialled") } - for name, c := range map[string]struct { - use func(*proto.PromptRequestPayload) - viewed, supporting bool - }{ - "environment none": {func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment, r.LocalEnvironment = true, nil }, false, true}, - "Skills": {func(r *proto.PromptRequestPayload) { - r.LocalEnvironment.Capabilities, r.LocalEnvironment.CapabilityRoot = true, "/capabilities" - r.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "skills/review"}} - }, false, true}, - "function tools": {func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "lookup"}} }, false, true}, - "tool search": {func(r *proto.PromptRequestPayload) { r.ToolSearch = true }, false, true}, - "stdio MCP": {func(r *proto.PromptRequestPayload) { - r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{InstallationRoot: "/capabilities", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "/bin/tools"}}} - }, false, true}, - // An installation with only HTTP MCP needs no Skill support. - "installed HTTP MCP": {func(r *proto.PromptRequestPayload) { - r.LocalEnvironment.Capabilities, r.LocalEnvironment.CapabilityRoot = true, "/capabilities" - r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "docs", Type: "http", URL: "https://mcp.test/docs"}}} - }, true, true}, - } { - for kind, admitted := range map[string]bool{"viewed": c.viewed, "supporting": c.supporting} { - req := request(kind, "/workspace", "https://model.test", "sk-test") - c.use(&req) - if _, err := admit(f.cfg, roots, req, Environment{}, network); admitted != (err == nil) || (err != nil && !errors.Is(err, ErrUnsupported)) { - t.Errorf("%s on %s: admit = %v, want admitted %v or ErrUnsupported", name, kind, err, admitted) - } - } - } -} - // The binding at index i runs under alias i, which the process broker maps to // the frozen command; only HTTP bindings reach the gateway. func TestStdioMCPRunsUnderItsAlias(t *testing.T) { @@ -178,7 +139,7 @@ func TestStdioMCPRunsUnderItsAlias(t *testing.T) { if err != nil { t.Fatal(err) } - req := request("supporting", "/workspace", "https://model.test", "sk-test") + req := request("viewed", "/workspace", "https://model.test", "sk-test") req.LocalEnvironment.MCP = []proto.EnvironmentMCP{ {Server: agentplugin.MCPServer{Name: "docs", Type: "http", URL: "https://mcp.test/docs"}}, {InstallationRoot: "/capabilities", PackageRoot: "pkg", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "bin/tools", Args: []string{"--stdio"}, CWD: "run"}}, @@ -198,20 +159,14 @@ func TestStdioMCPRunsUnderItsAlias(t *testing.T) { } } -func TestRegistryDescribesTheViewPath(t *testing.T) { +func TestRegistryRunsKindsWithViews(t *testing.T) { f := newViewFixture(t) var kinds []string for _, info := range (&Host{cfg: f.cfg}).Registry(nil).SupportedAgentKinds() { kinds = append(kinds, info.Kind) - c, declared := info.Capabilities, info.Kind == "supporting" - if !c.LocalEnvironment.IsSupported() || !c.MCPHTTPTools.IsSupported() || c.EnvironmentNone.IsSupported() != declared || - c.FunctionTools.IsSupported() != declared || c.FunctionResultImages.IsSupported() != declared || c.ToolSearch.IsSupported() != declared || - c.WorkspaceOutputExport.IsSupported() || c.WorkspaceReadPreparation.IsSupported() { - t.Errorf("%s: capabilities %+v do not describe the view path", info.Kind, c) - } } slices.Sort(kinds) - if !slices.Equal(kinds, []string{"masked", "shimmed", "supporting", "viewed"}) { + if !slices.Equal(kinds, []string{"masked", "shimmed", "viewed"}) { t.Errorf("kinds %v, want those that declare a view", kinds) } } diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index 2cce3512f..5bfaf0621 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -71,15 +71,10 @@ func newConfig(t *testing.T, reg *agent.Registry, ca *x509.Certificate) Config { // register declares kind with view, or without one when view is nil. func register(reg *agent.Registry, kind string, view *agent.View) { info := proto.SupportedAgentKind{Kind: kind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})} + LocalEnvironment: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported})} declaration := agent.Declaration{Info: info, Configuration: harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: string(modelprovider.Anthropic)}}}} - reg.Register(declaration, agent.Runtime{Info: info, View: view}) -} - -// declared declares every view capability as s. -func declared(s proto.CapabilitySupport) agent.ViewCapabilities { - return agent.ViewCapabilities{EnvironmentNone: s, Skills: s, FunctionTools: s, FunctionResultImages: s, ToolSearch: s, StdioMCP: s} + reg.Register(declaration, agent.Runtime{Info: info, View: view}, agent.EnvironmentSupport{Local: true, None: true}) } // request is a Session request the agent host admits. diff --git a/apps/daemon/internal/agenthost/doc.go b/apps/daemon/internal/agenthost/doc.go index 20484ce98..fa5ddc66a 100644 --- a/apps/daemon/internal/agenthost/doc.go +++ b/apps/daemon/internal/agenthost/doc.go @@ -33,14 +33,13 @@ // // Host.Registry's Executor factory prepares an Executor of the Session that // its bind function binds the request to. It admits the request before any -// effect: the kind must declare an agent.View, the request must use only -// what the view's agent.ViewCapabilities declare, and when the view declares +// effect: the kind must declare an agent.View, and when the view declares // shim names or a stdio MCP server's command is a bare name, both of which // run on the sandbox PATH, the Session's Environment must set PATH. A -// Session without strict resume, with a restricted network, or with a stdio -// MCP server that needs a credential is rejected whatever the view declares. -// The registry's Info follows the declarations and marks what needs a local -// workspace unsupported. The factory then allocates the +// Session with Skills, with a restricted network, or with a stdio MCP server +// that needs a credential is rejected. The registry declares each kind in a +// local Environment and with environment none, as agent.EnvironmentSupport +// composes them. The factory then allocates the // Executor's uid, skipping each uid that a running thread holds as its real, // effective, saved or file-system uid; this check only detects a conflict // and never ends a process. It prepares the Session directory under diff --git a/apps/daemon/internal/agenthost/executor_linux.go b/apps/daemon/internal/agenthost/executor_linux.go index ce6555c9c..956e8deaf 100644 --- a/apps/daemon/internal/agenthost/executor_linux.go +++ b/apps/daemon/internal/agenthost/executor_linux.go @@ -27,7 +27,7 @@ type deps struct { // Registry returns the kinds the agent host runs, for the daemon's dispatch // and heartbeat: each kind in Config.Harnesses that declares an agent.View, -// with Info that describes how views run it. Its Executor factory prepares an +// in the Environments the agent host serves. Its Executor factory prepares an // Executor of the Session that bind binds the request to, as the package // documentation describes, and bind's error fails the preparation. The Router // that runs it sets dispatch.Config.SessionEnvironments. @@ -39,31 +39,20 @@ func (h *Host) Registry(bind func(proto.PromptRequestPayload) (Binding, Environm } // registry registers each kind in harnesses that declares a view, with -// factory as its Executor factory. +// factory as its Executor factory, in a local Environment and with +// environment none. func registry(harnesses *agent.Registry, factory agent.ExecutorFactory) *agent.Registry { reg := agent.NewRegistry() for _, info := range harnesses.SupportedAgentKinds() { configuration, err := harnesses.Configuration(info.Kind) - view, viewErr := harnesses.ResolveView(info.Kind) - if err != nil || viewErr != nil { + if _, viewErr := harnesses.ResolveView(info.Kind); err != nil || viewErr != nil { continue } - reg.RegisterKind(viewInfo(info, view.Capabilities), configuration) - reg.RegisterExecutor(info.Kind, factory) + reg.Register(agent.Declaration{Info: info, Configuration: configuration}, agent.Runtime{Info: info, Executor: factory}, agent.EnvironmentSupport{Local: true, None: true}) } return reg } -// viewInfo is info as views run the kind: in the Session's Environment, with -// what caps admits, and without what needs a local workspace. -func viewInfo(info proto.SupportedAgentKind, caps agent.ViewCapabilities) proto.SupportedAgentKind { - c := &info.Capabilities - c.LocalEnvironment = proto.CapabilitySupported - c.WorkspaceReadPreparation, c.WorkspaceOutputExport = proto.CapabilityUnsupported, proto.CapabilityUnsupported - c.EnvironmentNone, c.FunctionTools, c.FunctionResultImages, c.ToolSearch = caps.EnvironmentNone, caps.FunctionTools, caps.FunctionResultImages, caps.ToolSearch - return info -} - // session is an Executor of a Session on the agent host: the view Executor // with the views, the Link attachment, the transient entries, the uid and the // Session's claim that Close releases. diff --git a/apps/daemon/internal/agenthost/view_linux_test.go b/apps/daemon/internal/agenthost/view_linux_test.go index 0848b38b8..202020759 100644 --- a/apps/daemon/internal/agenthost/view_linux_test.go +++ b/apps/daemon/internal/agenthost/view_linux_test.go @@ -100,16 +100,13 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Fatal(err) } copyExecutable(t, filepath.Join(closure, "harness")) - caps := declared(proto.CapabilityUnsupported) - caps.EnvironmentNone, caps.StdioMCP = proto.CapabilitySupported, proto.CapabilitySupported register(reg, "test", &agent.View{ - Closure: []agent.ViewMount{{Name: "harness", HostDir: closure}}, - Masks: []agent.ViewMask{{Path: "/etc/ld.so.preload"}, {Path: "/etc/hostname"}, {Path: "/etc/apt", Dir: true}}, - LocalExec: []string{harnessPath}, - ShimPaths: []string{"/bin/sh"}, - ForwardEnv: []string{"KEEP"}, - Proxy: agent.ViewProxyEnv, - Capabilities: caps, + Closure: []agent.ViewMount{{Name: "harness", HostDir: closure}}, + Masks: []agent.ViewMask{{Path: "/etc/ld.so.preload"}, {Path: "/etc/hostname"}, {Path: "/etc/apt", Dir: true}}, + LocalExec: []string{harnessPath}, + ShimPaths: []string{"/bin/sh"}, + ForwardEnv: []string{"KEEP"}, + Proxy: agent.ViewProxyEnv, Executor: func(_ context.Context, req proto.PromptRequestPayload, s agent.ViewSession) (agent.Executor, error) { e := &testExecutor{session: s, dir: workDir, env: []string{harnessEnv + "=1", modelEnv + "=" + req.ModelProvider.BaseURL, caEnv + "=" + cfg.CADir, proxyEnv + "=" + s.Proxy}} diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index 5ff2562b2..16ddd3abe 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -112,8 +112,8 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { if runtime == nil || runtime.View == nil { t.Fatalf("%s declares no agent-host view; discovery reported why above", kind) } - reg.Register(declaration, *runtime) - qualify(t, h, cfg, sb, kind, runtime.View.Capabilities, sessionModel(t, raw, key)) + reg.Register(declaration, *runtime, agent.EnvironmentSupport{Local: true, None: true}) + qualify(t, h, cfg, sb, kind, runtime.Info.Capabilities, sessionModel(t, raw, key)) }) } } @@ -126,10 +126,9 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { // calls a function there. A view that declares tool search runs a Turn in // another Session that finds the function, deferred, with tool search. A view // that declares environment none answers a Turn in a Session without an -// Environment, and its native state names the work directory. -// A view that declares stdio MCP calls a tool of a stdio MCP server that runs -// in the sandbox. -func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, caps agent.ViewCapabilities, model proto.PromptRequestPayload) { +// Environment, and its native state names the work directory. Every view +// calls a tool of a stdio MCP server that runs in the sandbox. +func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, caps proto.AgentKindCapabilities, model proto.PromptRequestPayload) { name := "qualify-" + kind + ".txt" value, content := strings.ToLower(rand.Text()), "qualified "+strings.ToLower(rand.Text()[:12]) code, _ := rand.Int(rand.Reader, big.NewInt(90)) @@ -191,7 +190,7 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, } s.checkCwd(t, agent.ViewPrivateRoot+"/"+agent.ViewHomeName+"/"+agent.ViewWorkName) } - if caps.StdioMCP.IsSupported() { + { code := strings.ToLower(rand.Text()[:12]) stdio := configuration stdio.FunctionTools, stdio.ToolSearch = nil, false diff --git a/apps/daemon/internal/cli/agent_registration.go b/apps/daemon/internal/cli/agent_registration.go index 09472bf6e..b1230fb98 100644 --- a/apps/daemon/internal/cli/agent_registration.go +++ b/apps/daemon/internal/cli/agent_registration.go @@ -2,8 +2,8 @@ package cli import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -func registerAgentKinds(registry *agent.Registry, discovery agentCLIDiscovery) { +func registerAgentKinds(registry *agent.Registry, discovery agentCLIDiscovery, environments agent.EnvironmentSupport) { for _, discovered := range discovery { - registry.Register(discovered.declaration, discovered.runtime) + 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 index 3feac68e6..a85b8beec 100644 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go @@ -75,7 +75,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { run := func(index int, prompt, resume string, callFunction, cancelOnText bool) execution { t.Helper() reg := agent.NewRegistry() - registerAgentKinds(reg, discovery) + registerAgentKinds(reg, discovery, agent.EnvironmentSupport{None: true}) sender := make(registeredSDKSender, 256) router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender}) if err != nil { diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go index d9fe10b8c..157566dde 100644 --- a/apps/daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -223,8 +223,12 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof } 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) + registerAgentKinds(registry, agentCLIs, local.Support()) control, err := newSuspendControl() if err != nil { @@ -257,7 +261,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof } if control != nil { - return runSuspendLoop(rootCtx, dial, registry, boot, agentCLIs, control) + return runSuspendLoop(rootCtx, dial, registry, local, boot, agentCLIs, control) } for { if err := rootCtx.Err(); err != nil { @@ -291,7 +295,7 @@ func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof // pumpConn returns on conn close (peer hangup, transport // error, root ctx cancel). Loop back into Reconnect unless // root ctx is cancelled. - pumpErr := pumpConn(rootCtx, conn, registry, boot, agentCLIs) + pumpErr := pumpConn(rootCtx, conn, registry, local, boot, agentCLIs) if pumpErr != nil { obslog.Bg().Warn("ws session ended", "err", pumpErr) } else { @@ -324,28 +328,11 @@ func localEnvironments(local *localworkspace.Binding) func(proto.AssignmentRef, return local.Resolve } -// localEnvironmentKinds declares, for each kind that supports a local -// Environment, the read-only preparation and output export that the local -// workspace owner serves. -func localEnvironmentKinds(registry *agent.Registry, local *localworkspace.Binding) []proto.SupportedAgentKind { - kinds := registry.SupportedAgentKinds() - for i := range kinds { - caps := &kinds[i].Capabilities - caps.WorkspaceReadPreparation = proto.CapabilityFromBool(local != nil && caps.LocalEnvironment.IsSupported()) - caps.WorkspaceOutputExport = caps.WorkspaceReadPreparation - } - return kinds -} - // pumpConn runs the per-connection workload: a dispatch.Router 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, registry *agent.Registry, boot *transport.BootstrapResponse, agentCLIs agentCLIDiscovery) error { - local, err := localworkspace.Load() - if err != nil { - return err - } +func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.Registry, local *localworkspace.Binding, boot *transport.BootstrapResponse, agentCLIs agentCLIDiscovery) error { router, err := dispatch.New(dispatch.Config{ Registry: registry, Sender: conn, @@ -365,7 +352,7 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R Timestamp: time.Now().Unix(), ActiveRequests: router.ActiveRuns(), DaemonVersion: Version, - SupportedAgentKinds: localEnvironmentKinds(registry, local), + SupportedAgentKinds: registry.SupportedAgentKinds(), HomeRemoval: proto.CapabilityUnsupported, } }, obslog.Bg().With("component", "heartbeat")) diff --git a/apps/daemon/internal/cli/connect_cleanup_test.go b/apps/daemon/internal/cli/connect_cleanup_test.go index 3a20c2e16..d707804f6 100644 --- a/apps/daemon/internal/cli/connect_cleanup_test.go +++ b/apps/daemon/internal/cli/connect_cleanup_test.go @@ -124,7 +124,7 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { boot := &transport.BootstrapResponse{HeartbeatSeconds: 60} if suspend { control := &suspendControl{signal: make(chan os.Signal, 1)} - finished <- runSuspendLoop(ctx, dial, registry, boot, agentCLIDiscovery{}, control) + finished <- runSuspendLoop(ctx, dial, registry, nil, boot, agentCLIDiscovery{}, control) return } conn, err := dial(ctx) @@ -133,7 +133,7 @@ func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { return } defer conn.Close() - finished <- pumpConn(ctx, conn, registry, boot, agentCLIDiscovery{}) + finished <- pumpConn(ctx, conn, registry, nil, boot, agentCLIDiscovery{}) }() var peer *websocket.Conn select { diff --git a/apps/daemon/internal/cli/connect_suspend.go b/apps/daemon/internal/cli/connect_suspend.go index c449ee7c9..6783706fb 100644 --- a/apps/daemon/internal/cli/connect_suspend.go +++ b/apps/daemon/internal/cli/connect_suspend.go @@ -35,20 +35,15 @@ type suspendedRouter struct { router *dispatch.Router sender *reconnectSender registry *agent.Registry - local *localworkspace.Binding } -func newSuspendedRouter(conn *transport.Conn, registry *agent.Registry) (*suspendedRouter, error) { - local, err := localworkspace.Load() - if err != nil { - return nil, err - } +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, local: local}, nil + return &suspendedRouter{router: router, sender: sender, registry: registry}, nil } func (s *suspendedRouter) shutdown() { @@ -57,7 +52,7 @@ func (s *suspendedRouter) shutdown() { // 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, boot *transport.BootstrapResponse, discovery agentCLIDiscovery, control *suspendControl) error { +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 { @@ -66,7 +61,7 @@ func runSuspendLoop(ctx context.Context, dial transport.DialFn, registry *agent. } return err } - state, err := newSuspendedRouter(conn, registry) + state, err := newSuspendedRouter(conn, registry, local) if err != nil { _ = conn.Close() return err @@ -152,7 +147,7 @@ func (s *suspendedRouter) reconnectSuspension(ctx context.Context, dial transpor 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{Timestamp: time.Now().Unix(), ActiveRequests: s.router.ActiveRuns(), DaemonVersion: Version, SupportedAgentKinds: localEnvironmentKinds(s.registry, s.local), HomeRemoval: proto.CapabilityUnsupported} + return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), ActiveRequests: s.router.ActiveRuns(), DaemonVersion: Version, SupportedAgentKinds: s.registry.SupportedAgentKinds(), HomeRemoval: proto.CapabilityUnsupported} }, obslog.Bg()) } diff --git a/apps/daemon/internal/cli/connect_suspend_test.go b/apps/daemon/internal/cli/connect_suspend_test.go index 7c46007b5..5b681ba90 100644 --- a/apps/daemon/internal/cli/connect_suspend_test.go +++ b/apps/daemon/internal/cli/connect_suspend_test.go @@ -52,7 +52,7 @@ func TestPlannedReconnectRequiresAuthenticatedMatchingResume(t *testing.T) { defer cancel() done := make(chan error, 1) go func() { - done <- runSuspendLoop(ctx, dial, registry, &transport.BootstrapResponse{HeartbeatSeconds: 1}, agentCLIDiscovery{}, control) + done <- runSuspendLoop(ctx, dial, registry, nil, &transport.BootstrapResponse{HeartbeatSeconds: 1}, agentCLIDiscovery{}, control) }() var first *websocket.Conn select { @@ -217,7 +217,7 @@ func TestRunningSourceOnlyAcceptsExplicitRollbackOrItsReceipt(t *testing.T) { defer cancel() done := make(chan error, 1) go func() { - done <- runSuspendLoop(ctx, dial, agent.NewRegistry(), &transport.BootstrapResponse{HeartbeatSeconds: 60}, agentCLIDiscovery{}, control) + done <- runSuspendLoop(ctx, dial, agent.NewRegistry(), nil, &transport.BootstrapResponse{HeartbeatSeconds: 60}, agentCLIDiscovery{}, control) }() var peer *websocket.Conn select { @@ -278,7 +278,7 @@ func TestSuspensionReconnectBeforeConfirmation(t *testing.T) { } setup := <-peers defer setup.Close() - state, err := newSuspendedRouter(conn, agent.NewRegistry()) + state, err := newSuspendedRouter(conn, agent.NewRegistry(), nil) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/native_discovery_test.go b/apps/daemon/internal/cli/native_discovery_test.go index 5f3592c9d..d0ea7fade 100644 --- a/apps/daemon/internal/cli/native_discovery_test.go +++ b/apps/daemon/internal/cli/native_discovery_test.go @@ -38,7 +38,7 @@ func TestDiscoveryAndRegistration(t *testing.T) { t.Fatalf("probes %v, want %v", called, expected) } registry := agent.NewRegistry() - registerAgentKinds(registry, discovery) + registerAgentKinds(registry, discovery, agent.EnvironmentSupport{}) kinds := registry.SupportedAgentKinds() if len(kinds) != len(expected) { t.Fatal(kinds) diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index f62ab53fc..5fbc50f56 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -7,6 +7,7 @@ import ( "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" @@ -95,6 +96,12 @@ func (b *Binding) Resolve(ref proto.AssignmentRef, bind proto.AssignmentBindPayl 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.stateKey == "agents-api-"+session } diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 3b79c74eb..1d7731d55 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -84,7 +84,7 @@ The reason is a fixed safe string, never submitted content, a credential or raw 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 `LocalEnvironment.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 and declares `WorkspaceReadPreparation` and `WorkspaceOutputExport` unsupported; the Runtime sets both from its owner. +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 and declares `WorkspaceReadPreparation` and `WorkspaceOutputExport` unsupported. It declares `LocalEnvironment` and `EnvironmentNone` as what its Executors run, and `agent.Registry.Register` composes the declaration once with what the Runtime's owner serves (`agent.EnvironmentSupport`): it keeps each only where the owner serves it and sets both export fields to the composed `LocalEnvironment`. One declaration holds for every Executor of the install, including its [view](#run-in-an-agent-host-view). The service profile qualifies public combinations and the Runtime advertises the installed combination; 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. @@ -269,7 +269,6 @@ An agent host runs the Harness outside the sandbox, in a per-Session view. The v | `ShimPaths` | View paths the shim is bound over; each runs the same path in the sandbox | | `ForwardEnv` | Harness variables that a process run in the sandbox keeps | | `Proxy` | `ViewProxyEnv` or `ViewProxyNone` | -| `Capabilities` | What the view runs ([Capabilities](#capabilities)) | | `Executor` | The `ViewExecutorFactory` that prepares the Session's Executor in its view | `View.Validate` checks the declaration without touching the host: @@ -279,23 +278,13 @@ An agent host runs the Harness outside the sandbox, in a per-Session view. The v - shim paths, overlays and masks do not overlap each other or `/`, and stay out of the trees the view builds itself: `/.oac`, `/proc` and `/dev` (`ViewReserved`); - each `LocalExec` entry lies in a closure directory or an `Exec` overlay; - shim names and `ForwardEnv` names are unique, no shim is named `oac-process-shim`, which is the process relay's, or starts with `oac-mcp-`, which [stdio aliases](#stdio-mcp) use, a variable name contains no `=`, and `ForwardEnv` names no variable the view or the broker sets ([Environment](#environment)); -- every `Capabilities` field is `proto.CapabilitySupported` or `proto.CapabilityUnsupported`; - `Proxy` is one of the two values and `Executor` is non-nil. `harness.go` defines the view layout once, and `sessionview` builds views from it. The agent host checks its own overlays, such as `/etc/passwd`, against the declaration when it builds the view. ### Capabilities -`View.Capabilities` declares each feature the view runs, and the agent host admits a request before any effect only when the view supports each feature the request uses. The registry the agent host gives dispatch derives `EnvironmentNone`, `FunctionTools`, `FunctionResultImages` and `ToolSearch` from it. - -| Field | A request that uses it | -| --- | --- | -| `EnvironmentNone` | Sets `DisableExecutionEnvironment` ([Environment none](#environment-none)) | -| `Skills` | Has resolved Skills (`LocalEnvironment.Skills`) | -| `FunctionTools`, `FunctionResultImages`, `ToolSearch` | Uses the feature of the same `AgentKindCapabilities` name | -| `StdioMCP` | Has a stdio MCP binding ([Stdio MCP](#stdio-mcp)) | - -Whatever the view declares, the agent host rejects with `ErrUnsupportedOperation` a request without strict resume, one whose installed Capabilities no preparation resolved, and one with a restricted network, because only the Provider's workload network boundary can contain a process's own sockets. It rejects a stdio binding that needs a credential with `ErrViewHandoff`. +A view runs every request that the kind's declaration admits, so the adapter declares only what both its local Executor and its view run, and dispatch checks each request against that declaration. The agent host serves a local Environment and environment none, and every view runs [stdio MCP](#stdio-mcp). Whatever the kind declares, the agent host rejects with `ErrUnsupportedOperation` a request with Skills, one whose installed Capabilities no preparation resolved, and one with a restricted network, because only the Provider's workload network boundary can contain a process's own sockets. It rejects a stdio binding that needs a credential with `ErrViewHandoff`. ### Environment none @@ -362,9 +351,9 @@ Run the adapter's Turns, cancellation and continuation in a view, then qualify e | `ForwardEnv` | A process run in the sandbox keeps each declared variable and no other Harness variable. | | `Proxy` | With `ViewProxyEnv`, every local request, such as web fetches, downloads and update checks, goes through the proxy. With `ViewProxyNone`, a request enabling a feature that needs it is rejected. | | `Home` | Native history and configuration stay under `/.oac/home`, and a later Executor in the same Session continues from them. | -| `Capabilities` | Each supported feature runs a Turn through dispatch: environment none in the empty-root view, Skills, function calls and results, tool search, and each stdio binding under its alias. | +| Declaration | Each declared feature runs a Turn through dispatch: environment none in the empty-root view, function calls and results, tool search, and each stdio binding under its alias. | -`scripts/qualify-agent-host.sh` runs each Harness's Turns through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. The first Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. When the view declares function tools, a second Turn runs in a new Executor that resumes the Session's native history and calls a function; the test returns a text, image and text result through dispatch, and the answer must report both texts. When the view declares tool search, a Turn in another Session finds the deferred function with tool search and calls it. When the view declares environment none, a Turn in a Session without an Environment answers through the model, and the Harness's native state in the Session home must name its working directory, `/.oac/home/work`. When the view declares stdio MCP, the test gives a Session's Environment one installed stdio MCP server, a script that runs in the sandbox, and the answer must report the code its one tool returns. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. +`scripts/qualify-agent-host.sh` runs each Harness's Turns through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. The first Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. When the kind declares function tools, a second Turn runs in a new Executor that resumes the Session's native history and calls a function; the test returns a text, image and text result through dispatch, and the answer must report both texts. When the kind declares tool search, a Turn in another Session finds the deferred function with tool search and calls it. When the kind declares environment none, a Turn in a Session without an Environment answers through the model, and the Harness's native state in the Session home must name its working directory, `/.oac/home/work`. The test gives a Session's Environment one installed stdio MCP server, a script that runs in the sandbox, and the answer must report the code its one tool returns. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. ## Native references diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3d650435d..0db52f180 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: c8ffdd603108bace70451b09b123eb250e3a957358dc2d8c6b5af9601c044148 +source_hash: bd60eb63b07d823a0b44502bcbe1a329100a063d05a1b7ebdda979fa152eb60a --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -86,7 +86,7 @@ func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayl 线协议请求不携带工作目录。Runtime 将 `local_environment.workspace_directory` 与其绑定进行核对,并通过 `LocalEnvironment.WorkspaceRoot` 向 Harness 提供其绑定的工作区目录;必须在该目录中运行原生 Harness。 -工作区读取、写入、输出导出和只读 preparation 属于 Session 的 [Environment owner](../../../docs/zh/runtime-protocol.md#session-assignments),不属于 adapter。adapter 不实现其中任何操作,并将 `WorkspaceReadPreparation` 和 `WorkspaceOutputExport` 声明为不支持;Runtime 根据其 owner 设置二者。 +工作区读取、写入、输出导出和只读 preparation 属于 Session 的 [Environment owner](../../../docs/zh/runtime-protocol.md#session-assignments),不属于 adapter。adapter 不实现其中任何操作,并将 `WorkspaceReadPreparation` 和 `WorkspaceOutputExport` 声明为不支持。它将 `LocalEnvironment` 和 `EnvironmentNone` 声明为其 Executor 能运行的内容,`agent.Registry.Register` 将该声明与 Runtime 的 owner 所提供的内容(`agent.EnvironmentSupport`)组合一次:仅在 owner 提供时保留二者,并将两个导出字段设为组合后的 `LocalEnvironment`。一份声明适用于该安装的每个 Executor,包括其[视图](#run-in-an-agent-host-view)。 服务 profile 对公共组合进行资格认定,Runtime 宣称已安装的组合;二者都不能替代 schema 验证或 Project 授权。原生行为测试必须与声明一致。已宣称但返回 Unsupported 的操作属于契约违规,既不是成功,也不能作为重放的依据。 @@ -271,7 +271,6 @@ agent host 在沙箱之外、在每个 Session 一个的视图中运行 Harness | `ShimPaths` | 绑定 shim 的视图路径;每个路径在沙箱中运行相同路径 | | `ForwardEnv` | 在沙箱中运行的进程保留的 Harness 变量 | | `Proxy` | `ViewProxyEnv` 或 `ViewProxyNone` | -| `Capabilities` | 视图运行的功能([能力](#capabilities)) | | `Executor` | 在 Session 的视图中准备其 Executor 的 `ViewExecutorFactory` | `View.Validate` 在不访问主机的情况下检查声明: @@ -281,23 +280,13 @@ agent host 在沙箱之外、在每个 Session 一个的视图中运行 Harness - shim 路径、overlay 和 mask 互不重叠,也不与 `/` 重叠,并且不进入视图自己构建的树:`/.oac`、`/proc` 和 `/dev`(`ViewReserved`); - 每个 `LocalExec` 条目都位于某个 closure 目录或某个 `Exec` overlay 中; - shim 名称和 `ForwardEnv` 名称各自唯一,没有 shim 名为 `oac-process-shim`(该名称属于进程 relay)或以 `oac-mcp-` 开头(该前缀属于 [stdio 别名](#stdio-mcp)),变量名不含 `=`,且 `ForwardEnv` 不指定视图或 broker 设置的变量([环境](#environment)); -- 每个 `Capabilities` 字段都是 `proto.CapabilitySupported` 或 `proto.CapabilityUnsupported`; - `Proxy` 是两个取值之一,且 `Executor` 非 nil。 `harness.go` 只定义一次视图布局,`sessionview` 据此构建视图。agent host 在构建视图时,用声明检查它自己的 overlay,例如 `/etc/passwd`。 ### 能力 {#capabilities} -`View.Capabilities` 声明视图运行的每项功能。只有当视图支持请求使用的每项功能时,agent host 才会在产生任何副作用之前准入该请求。agent host 交给 dispatch 的 registry 据此推导 `EnvironmentNone`、`FunctionTools`、`FunctionResultImages` 和 `ToolSearch`。 - -| 字段 | 使用该功能的请求 | -| --- | --- | -| `EnvironmentNone` | 设置了 `DisableExecutionEnvironment`([Environment none](#environment-none)) | -| `Skills` | 带有已解析的 Skills(`LocalEnvironment.Skills`) | -| `FunctionTools`、`FunctionResultImages`、`ToolSearch` | 使用 `AgentKindCapabilities` 中同名的功能 | -| `StdioMCP` | 带有 stdio MCP 绑定([Stdio MCP](#stdio-mcp)) | - -无论视图如何声明,agent host 都以 `ErrUnsupportedOperation` 拒绝没有严格恢复的请求、已安装的 Capabilities 未经任何准备解析的请求,以及带受限网络的请求,因为只有 Provider 的工作负载网络边界才能约束进程自己的 socket。它以 `ErrViewHandoff` 拒绝需要凭据的 stdio 绑定。 +视图运行该 kind 的声明所准入的每个请求,因此 adapter 只声明其本地 Executor 和视图都能运行的内容,dispatch 按该声明检查每个请求。agent host 提供本地 Environment 和 environment none,且每个视图都运行 [stdio MCP](#stdio-mcp)。无论 kind 如何声明,agent host 都以 `ErrUnsupportedOperation` 拒绝带 Skills 的请求、已安装的 Capabilities 未经任何准备解析的请求,以及带受限网络的请求,因为只有 Provider 的工作负载网络边界才能约束进程自己的 socket。它以 `ErrViewHandoff` 拒绝需要凭据的 stdio 绑定。 ### Environment none {#environment-none} @@ -364,9 +353,9 @@ stdio 绑定在沙箱中以其别名运行。`ViewSession.MCP` 中索引为 `i` | `ForwardEnv` | 在沙箱中运行的进程保留每个声明的变量,且不保留任何其他 Harness 变量。 | | `Proxy` | 使用 `ViewProxyEnv` 时,每个本地请求(例如网页抓取、下载和更新检查)都经过代理。使用 `ViewProxyNone` 时,启用需要代理的功能的请求会被拒绝。 | | `Home` | 原生历史和配置保存在 `/.oac/home` 下,同一 Session 中后续的 Executor 从中继续。 | -| `Capabilities` | 每项受支持的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、Skills、函数调用及其结果、工具搜索,以及每个以别名运行的 stdio 绑定。 | +| 声明 | 每项声明的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、函数调用及其结果、工具搜索,以及每个以别名运行的 stdio 绑定。 | -`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 运行每个 Harness 的 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。视图声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。视图声明工具搜索时,另一个 Session 中的 Turn 用工具搜索找到延迟加载的函数并调用它。视图声明 environment none 时,一个没有 Environment 的 Session 中的 Turn 通过模型作答,且 Session home 中 Harness 的原生状态必须写明其工作目录 `/.oac/home/work`。视图声明 stdio MCP 时,测试为一个 Session 的 Environment 提供一个已安装的 stdio MCP 服务器,即在沙箱中运行的脚本,回答必须报告其唯一工具返回的代码。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 +`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 运行每个 Harness 的 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。kind 声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。kind 声明工具搜索时,另一个 Session 中的 Turn 用工具搜索找到延迟加载的函数并调用它。kind 声明 environment none 时,一个没有 Environment 的 Session 中的 Turn 通过模型作答,且 Session home 中 Harness 的原生状态必须写明其工作目录 `/.oac/home/work`。测试为一个 Session 的 Environment 提供一个已安装的 stdio MCP 服务器,即在沙箱中运行的脚本,回答必须报告其唯一工具返回的代码。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 ## 原生参考 {#native-references} diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 7819fb632..2898d95e6 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -28,7 +28,7 @@ Each physical connection has fresh routing, admission handles and transfer state On the wire each field is a JSON boolean, and every field is present, including `false`. Encoding an incomplete declaration fails. Decoding rejects omitted, null, invalid and unknown fields, and a missing capability object. An invalid heartbeat clears the connection's admission snapshot and closes its transport; that establishes no native completion or cancellation result. -Each admitted Executor and Turn keeps the declaration it was admitted with. A later heartbeat cannot add operations to an existing owner. Optional operations check this snapshot before any native call; the presence of a Go interface never grants support. A declared operation that returns `agent.ErrUnsupportedOperation` is a contract violation, distinct from unavailability, a failed native call or an uncertain write. Uncertain operations keep their receipts and ownership and are never replayed automatically. A Runtime declares `workspace_read_preparation` and `workspace_output_export` from its [Environment owner](#session-assignments), never from a Harness adapter. +Each admitted Executor and Turn keeps the declaration it was admitted with. A later heartbeat cannot add operations to an existing owner. Optional operations check this snapshot before any native call; the presence of a Go interface never grants support. A declared operation that returns `agent.ErrUnsupportedOperation` is a contract violation, distinct from unavailability, a failed native call or an uncertain write. Uncertain operations keep their receipts and ownership and are never replayed automatically. A Runtime declares `local_environment` and `environment_none` only where both the Harness and its [Environment owner](#session-assignments) support them, and `workspace_read_preparation` and `workspace_output_export` exactly where it declares `local_environment`, never from a Harness adapter. A new field requires an explicit decision in every production declaration. Contract tests enumerate every field for registration and wire round trips; the shared test fixture lists fields individually and supplies no defaults for future ones. [Harness onboarding](../contracts/agents-api/harness-onboarding.md) owns the adapter side of each declaration. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index b16b588ae..1349a54e5 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: 0890cc82a6ab41096a81aebfee6024b22d8c298e82104b34f7ab194a54b05a55 +source_hash: 6139b91a175c1d3e746bafc15a062421615bd10541918f2dceaac0e4db9b3dfc --- 此协议在 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)。 @@ -30,7 +30,7 @@ wire 版本为 [`proto.Version`](https://github.com/MiniMax-AI/OpenAgentCore/blo wire 上每个字段都是 JSON boolean,所有字段都必须出现,包括 `false`。不完整声明编码失败。解码拒绝省略、null、无效和未知字段,以及缺失的 capability 对象。无效 heartbeat 会清空连接的 admission snapshot 并关闭 transport;这不证明原生完成或取消结果。 -每个已准入的 Executor 和 Turn 保留准入时的声明。后续 heartbeat 不能给已有 owner 增加操作。可选操作在任何原生调用前检查此快照;存在 Go interface 不代表支持。已声明操作返回 `agent.ErrUnsupportedOperation` 属于契约违规,与不可用、原生调用失败或不确定写入不同。不确定操作保留回执与所有权,绝不自动重放。Runtime 根据其 [Environment owner](#session-assignments) 声明 `workspace_read_preparation` 和 `workspace_output_export`,从不由 Harness adapter 声明。 +每个已准入的 Executor 和 Turn 保留准入时的声明。后续 heartbeat 不能给已有 owner 增加操作。可选操作在任何原生调用前检查此快照;存在 Go interface 不代表支持。已声明操作返回 `agent.ErrUnsupportedOperation` 属于契约违规,与不可用、原生调用失败或不确定写入不同。不确定操作保留回执与所有权,绝不自动重放。Runtime 仅在 Harness 及其 [Environment owner](#session-assignments) 都支持时声明 `local_environment` 和 `environment_none`,并恰好在声明 `local_environment` 时声明 `workspace_read_preparation` 和 `workspace_output_export`,从不由 Harness adapter 声明后两者。 新增字段要求每个生产声明都作出明确决定。契约测试为注册和 wire 往返逐一枚举字段;共享测试 fixture 单独列出字段,不为未来字段提供默认值。[Harness 接入](../../contracts/agents-api/zh/harness-onboarding.md)负责各声明的 adapter 侧规则。 From 4961bc74ed702c03636fc135a1e0cab1ba084a91 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 19:12:29 +0000 Subject: [PATCH 04/13] Separate capability installation rules from local directories The installation, snapshot, manifest, marker, tool-environment, setup argument and MCP credential rules become functions over file lists and values, so an owner that reaches the installation through File applies the same rules. The os.Root helpers stay as thin wrappers for the guest. --- .../internal/localworkspace/initialization.go | 20 +- apps/daemon/internal/localworkspace/mcp.go | 30 +-- .../localworkspace/runtime_initialization.go | 35 +-- .../localworkspace/snapshot_marker.go | 29 +-- internal/agentcapabilities/initialization.go | 88 +++++++ internal/agentcapabilities/install.go | 220 +++++++++--------- internal/agentcapabilities/manifest.go | 41 +++- .../agentcapabilities/{tree.go => root.go} | 99 ++++++++ 8 files changed, 354 insertions(+), 208 deletions(-) create mode 100644 internal/agentcapabilities/initialization.go rename internal/agentcapabilities/{tree.go => root.go} (55%) diff --git a/apps/daemon/internal/localworkspace/initialization.go b/apps/daemon/internal/localworkspace/initialization.go index 4d99c1d40..33aafd694 100644 --- a/apps/daemon/internal/localworkspace/initialization.go +++ b/apps/daemon/internal/localworkspace/initialization.go @@ -6,9 +6,9 @@ import ( "os" "path/filepath" "runtime" - "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) @@ -46,27 +46,11 @@ func ReadToolEnvironment() (map[string]string, error) { 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 || !validToolEnvironment(values) { + 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 } -func validToolEnvironment(values map[string]string) bool { - seen := map[string]bool{} - for key, value := range values { - if runtime.GOOS == "windows" { - folded := strings.ToUpper(key) - if seen[folded] { - return false - } - seen[folded] = true - } - if key == "" || strings.ContainsAny(key, "=\x00\r\n") || strings.ContainsRune(value, 0) { - return false - } - } - return true -} // ReadOptionalToolEnvironment permits a Runtime without explicit user variables. func ReadOptionalToolEnvironment() (map[string]string, error) { diff --git a/apps/daemon/internal/localworkspace/mcp.go b/apps/daemon/internal/localworkspace/mcp.go index 820c852e4..ec63016ae 100644 --- a/apps/daemon/internal/localworkspace/mcp.go +++ b/apps/daemon/internal/localworkspace/mcp.go @@ -1,9 +1,7 @@ package localworkspace import ( - "errors" "os" - "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -19,29 +17,13 @@ func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { } func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values map[string]string) ([]proto.EnvironmentMCP, error) { + tokens, err := agentcapabilities.ResolveMCP(installed, values) + if err != nil { + return nil, err + } result := make([]proto.EnvironmentMCP, 0, len(installed)) - names := map[string]bool{} - for _, item := range installed { - server := item.Server - if names[server.Name] { - return nil, errors.New("ambiguous environment MCP server identity") - } - names[server.Name] = true - resolved := proto.EnvironmentMCP{PackageRoot: item.PackageRoot, Server: server} - variables := append([]string{}, server.EnvVars...) - if server.BearerTokenEnvVar != "" { - variables = append(variables, server.BearerTokenEnvVar) - } - for _, name := range variables { - value, exists := values[name] - if !exists || strings.ContainsRune(value, 0) { - return nil, errors.New("declared environment MCP variable unavailable") - } - if name == server.BearerTokenEnvVar { - resolved.BearerToken = &value - } - } - result = append(result, resolved) + for i, item := range installed { + result = append(result, proto.EnvironmentMCP{PackageRoot: item.PackageRoot, Server: item.Server, BearerToken: tokens[i]}) } return result, nil } diff --git a/apps/daemon/internal/localworkspace/runtime_initialization.go b/apps/daemon/internal/localworkspace/runtime_initialization.go index 35df13ce1..687a0adb6 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization.go @@ -44,40 +44,29 @@ func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInit 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": - if input.Command == "" || strings.ContainsRune(input.Command, 0) { - return agentcapabilities.ErrInvalid - } binary, err = initializationBash() - args = []string{"--noprofile", "--norc", "-c", input.Command} case "npm", "python": - if len(input.Packages) == 0 { - return agentcapabilities.ErrInvalid - } - for _, value := range input.Packages { - if value == "" || strings.HasPrefix(value, "-") || strings.ContainsAny(value, "\x00\r\n") { - return agentcapabilities.ErrInvalid - } - } if err = os.MkdirAll(packages, 0700); err != nil { return &dispatch.InitializationFailure{} } if input.Action == "npm" { binary, args, err = initializationNPM() - args = append(args, "install", "--global", "--prefix", filepath.Join(packages, "npm"), "--") } else { binary, err = initializationPython() - args = []string{"-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--target", filepath.Join(packages, "python"), "--"} } - args = append(args, input.Packages...) } if err != nil { return err } - return runInitializationProcess(ctx, binary, args, directory, initializationEnvironment(values)) + return runInitializationProcess(ctx, binary, append(args, tail...), directory, initializationEnvironment(values)) } func (b *Binding) initializationCWD(value string) (string, error) { @@ -95,7 +84,7 @@ func (b *Binding) initializationCWD(value string) (string, error) { } func configureRuntime(values map[string]string) error { - if !validToolEnvironment(values) { + if !agentcapabilities.ValidToolEnvironment(values, runtime.GOOS == "windows") { return agentcapabilities.ErrInvalid } path, err := initializedToolEnvironmentPath() @@ -145,23 +134,13 @@ func configureRuntime(values map[string]string) error { } configured[key] = value } - separator := string(os.PathListSeparator) 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") } - pathValue, exists := configured["PATH"] - if !exists { - pathValue = os.Getenv("PATH") - } - configured["PATH"] = npmBin + separator + pythonBin + separator + pathValue - pythonPath := configured["PYTHONPATH"] - configured["PYTHONPATH"] = filepath.Join(packages, "python") - if pythonPath != "" { - configured["PYTHONPATH"] += separator + pythonPath - } + 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 diff --git a/apps/daemon/internal/localworkspace/snapshot_marker.go b/apps/daemon/internal/localworkspace/snapshot_marker.go index fb95e9de1..2e2cf4f4a 100644 --- a/apps/daemon/internal/localworkspace/snapshot_marker.go +++ b/apps/daemon/internal/localworkspace/snapshot_marker.go @@ -1,7 +1,7 @@ package localworkspace import ( - "encoding/json" + "bytes" "errors" "io" "os" @@ -9,7 +9,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" - "github.com/google/uuid" ) // snapshotMarker remembers completion outside the installed tree. It contains @@ -18,17 +17,14 @@ type snapshotMarker struct { directory *os.Root unlock func() name string - root string + body []byte completed bool } func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { - identity := b.capabilityIdentity() - for _, id := range []string{identity.EnvironmentID, identity.SessionID} { - parsed, err := uuid.Parse(id) - if err != nil || parsed == uuid.Nil || parsed.String() != id { - return nil, agentcapabilities.ErrInvalid - } + 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 { @@ -63,7 +59,7 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { current.Close() return nil, agentcapabilities.ErrInvalid } - marker := &snapshotMarker{directory: current, unlock: unlock, name: identity.EnvironmentID + "-" + identity.SessionID + ".json", root: b.capabilityRoot} + marker := &snapshotMarker{directory: current, unlock: unlock, name: name, body: body} completed, err := marker.read() if err != nil { marker.close() @@ -92,11 +88,8 @@ func (m *snapshotMarker) read() (bool, error) { return false, agentcapabilities.ErrInvalid } // Exact bytes also reject duplicate members, trailing data and extra fields. - expected, _ := json.Marshal(struct { - Root string `json:"capability_root"` - }{m.root}) actual, err := io.ReadAll(io.LimitReader(file, 8193)) - if err != nil || string(actual) != string(expected)+"\n" { + if err != nil || !bytes.Equal(actual, m.body) { return false, agentcapabilities.ErrInvalid } return true, nil @@ -106,17 +99,11 @@ func (m *snapshotMarker) complete() error { if m.completed { return nil } - data, err := json.Marshal(struct { - Root string `json:"capability_root"` - }{m.root}) - if err != nil { - return agentcapabilities.ErrInvalid - } 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(append(data, '\n')) + _, writeErr := file.Write(m.body) syncErr := file.Sync() closeErr := file.Close() if writeErr != nil || syncErr != nil || closeErr != nil || runtimefs.SyncDirectory(m.directory) != nil { diff --git a/internal/agentcapabilities/initialization.go b/internal/agentcapabilities/initialization.go new file mode 100644 index 000000000..43aaa147a --- /dev/null +++ b/internal/agentcapabilities/initialization.go @@ -0,0 +1,88 @@ +package agentcapabilities + +import ( + "encoding/json" + "maps" + "strings" +) + +// Marker is the completion marker of identity's installation at root: its +// file name and exact contents. It lives outside the installation, so once +// it exists a missing manifest is corruption, not a reason to capture the +// sources again. +func Marker(identity Identity, root string) (string, []byte, error) { + if validateIdentity(identity) != nil { + return "", nil, ErrInvalid + } + body, err := json.Marshal(struct { + Root string `json:"capability_root"` + }{root}) + if err != nil { + return "", nil, ErrInvalid + } + return identity.EnvironmentID + "-" + identity.SessionID + ".json", append(body, '\n'), nil +} + +// ToolEnvironment is the tool environment preparation freezes: values, with +// the package commands, npmBin and pythonBin, ahead of PATH, or of path when +// values sets none, and the Python packages, pythonLib, ahead of PYTHONPATH. +// separator joins list entries. +func ToolEnvironment(values map[string]string, path, npmBin, pythonBin, pythonLib, separator string) map[string]string { + result := make(map[string]string, len(values)+2) + maps.Copy(result, values) + if configured, ok := values["PATH"]; ok { + path = configured + } + result["PATH"] = npmBin + separator + pythonBin + separator + path + result["PYTHONPATH"] = pythonLib + if values["PYTHONPATH"] != "" { + result["PYTHONPATH"] += separator + values["PYTHONPATH"] + } + return result +} + +// ValidToolEnvironment reports whether values are tool variables; foldCase +// rejects names that differ only in case, as Windows does. +func ValidToolEnvironment(values map[string]string, foldCase bool) bool { + seen := map[string]bool{} + for key, value := range values { + if foldCase { + folded := strings.ToUpper(key) + if seen[folded] { + return false + } + seen[folded] = true + } + if key == "" || strings.ContainsAny(key, "=\x00\r\n") || strings.ContainsRune(value, 0) { + return false + } + } + return true +} + +// InitializationArgs returns the arguments that follow the program of a +// setup, npm or python initialization step: Bash runs command, npm installs +// packages into npmPrefix, and Python's pip into pythonTarget. +func InitializationArgs(action, command string, packages []string, npmPrefix, pythonTarget string) ([]string, error) { + switch action { + case "setup": + if command == "" || strings.ContainsRune(command, 0) { + return nil, ErrInvalid + } + return []string{"--noprofile", "--norc", "-c", command}, nil + case "npm", "python": + if len(packages) == 0 { + return nil, ErrInvalid + } + for _, value := range packages { + if value == "" || strings.HasPrefix(value, "-") || strings.ContainsAny(value, "\x00\r\n") { + return nil, ErrInvalid + } + } + if action == "npm" { + return append([]string{"install", "--global", "--prefix", npmPrefix, "--"}, packages...), nil + } + return append([]string{"-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--target", pythonTarget, "--"}, packages...), nil + } + return nil, ErrInvalid +} diff --git a/internal/agentcapabilities/install.go b/internal/agentcapabilities/install.go index 785de407f..0a6daa07b 100644 --- a/internal/agentcapabilities/install.go +++ b/internal/agentcapabilities/install.go @@ -2,7 +2,7 @@ package agentcapabilities import ( "encoding/json" - "os" + "io/fs" "path" "strconv" "strings" @@ -12,103 +12,119 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" ) -func InstallSkill(root *os.Root, archive []byte, metadata agentskill.Metadata) error { - if unfinished(root) != nil { - return ErrInvalid - } +// Tree is one directory of an installation, relative to the installation, +// and the files it holds. +type Tree struct { + Root string + Files []agentbundle.File +} + +// SkillTree is the tree that stages a Skill archive. +func SkillTree(archive []byte, metadata agentskill.Metadata) (Tree, error) { files, err := agentskill.Read(archive, metadata) - if err != nil { - return ErrInvalid + if err != nil || !validRelative(metadata.Name) || strings.Contains(metadata.Name, "/") { + return Tree{}, ErrInvalid } - return writeTree(root, "skills/"+metadata.Name, files) + return Tree{Root: "skills/" + metadata.Name, Files: files}, nil } -func InstallPlugin(root *os.Root, slot int, archive []byte, metadata agentplugin.Metadata) error { - if slot < 0 || slot >= 50 || unfinished(root) != nil { - return ErrInvalid +// PluginTree is the tree that stages a Plugin archive in slot. +func PluginTree(slot int, archive []byte, metadata agentplugin.Metadata) (Tree, error) { + if slot < 0 || slot >= 50 { + return Tree{}, ErrInvalid } bundle, err := agentplugin.Read(archive, metadata) if err != nil { - return ErrInvalid + return Tree{}, ErrInvalid } - return writeTree(root, "plugins/"+strconv.Itoa(slot), bundle.Files) + return Tree{Root: "plugins/" + strconv.Itoa(slot), Files: bundle.Files}, nil } -// Finalize captures declared directories once after setup and binds the snapshot. -// It emits an installation artifact, not another lifecycle or public catalog. -func Finalize(installed *os.Root, input Input, identity Identity, resolve DirectoryResolver) error { +// Snapshot completes a staged installation once setup has run: it captures +// the declared directories and binds the manifest to the selection. It is an +// installation artifact, not another lifecycle or public catalog. +type Snapshot struct { + manifest Manifest + directories int + captured int + total int +} + +// NewSnapshot checks the staged installation against input. list lists one +// of the installation's directories, and read reads one of its trees, whose +// files must be read-only. Only selected, fully imported bundles may precede +// finalization: a leftover directory capture or temporary manifest is an +// incomplete installation, not a reason to silently recapture mutable +// sources. +func NewSnapshot(input Input, identity Identity, list func(string) ([]fs.DirEntry, error), read func(string) ([]agentbundle.File, error)) (*Snapshot, error) { digest, err := selectionHash(input) - if err != nil || validateIdentity(identity) != nil || (len(input.Directories) > 0 && resolve == nil) { - return ErrInvalid - } - if _, err := installed.Lstat(ManifestName); !os.IsNotExist(err) { - return ErrInvalid - } - if validateStaged(installed, input) != nil { - return ErrInvalid - } - manifest := Manifest{Version: 1, Identity: identity, SelectionSHA256: digest, Skills: []InstalledSkill{}} - total := 0 - count := func(files []agentbundle.File) error { - for _, file := range files { - total += len(file.Data) - } - if total > MaxSnapshotBytes { - return ErrInvalid - } - return nil + if err != nil || validateIdentity(identity) != nil || validateStaged(list, input) != nil { + return nil, ErrInvalid } + s := &Snapshot{manifest: Manifest{Version: 1, Identity: identity, SelectionSHA256: digest, Skills: []InstalledSkill{}}, directories: len(input.Directories)} for _, metadata := range input.Skills { if !validRelative(metadata.Name) || strings.Contains(metadata.Name, "/") { - return ErrInvalid + return nil, ErrInvalid } root := "skills/" + metadata.Name - files, err := ReadTree(installed, root, true) - if err != nil || count(files) != nil { - return ErrInvalid + files, err := read(root) + if err != nil || s.count(files) != nil { + return nil, ErrInvalid } - body, err := installed.ReadFile(root + "/SKILL.md") - if err != nil || agentskill.ValidateManifest(body, metadata) != nil || manifest.add(metadata, root, root) != nil { - return ErrInvalid + body, ok := fileData(files, "SKILL.md") + if !ok || agentskill.ValidateManifest(body, metadata) != nil || s.manifest.add(metadata, root, root) != nil { + return nil, ErrInvalid } } for slot, expected := range input.Plugins { root := "plugins/" + strconv.Itoa(slot) - files, err := ReadTree(installed, root, true) - if err != nil || count(files) != nil { - return ErrInvalid + files, err := read(root) + if err != nil || s.count(files) != nil { + return nil, ErrInvalid } bundle, err := agentplugin.Inspect(files) - if err != nil || bundle.Metadata != expected || addPlugin(&manifest, root, bundle) != nil { - return ErrInvalid + if err != nil || bundle.Metadata != expected || addPlugin(&s.manifest, root, bundle) != nil { + return nil, ErrInvalid } } - for slot, source := range input.Directories { - directory, err := resolve(source) - if err != nil || directory == nil { - if directory != nil { - directory.Close() - } - return ErrInvalid - } - files, err := ReadTree(directory, ".", false) - closeErr := directory.Close() - if closeErr != nil { - return ErrInvalid - } - if err != nil || count(files) != nil { - return ErrInvalid - } - root := "directories/" + strconv.Itoa(slot) - if discover(&manifest, root, files) != nil || writeTree(installed, root, files) != nil { - return ErrInvalid - } + return s, nil +} + +// Capture records the files of the next selected directory, in input order, +// and returns the tree to write before the next capture. +func (s *Snapshot) Capture(files []agentbundle.File) (Tree, error) { + if s.captured >= s.directories || s.count(files) != nil { + return Tree{}, ErrInvalid + } + root := "directories/" + strconv.Itoa(s.captured) + if discover(&s.manifest, root, files) != nil { + return Tree{}, ErrInvalid + } + s.captured++ + return Tree{Root: root, Files: files}, nil +} + +// Manifest is the manifest to publish, last, as ManifestName once every +// directory is captured. +func (s *Snapshot) Manifest() ([]byte, error) { + if s.captured != s.directories { + return nil, ErrInvalid + } + body, err := json.Marshal(s.manifest) + if err != nil || len(body) > MaxManifestBytes { + return nil, ErrInvalid } - body, err := json.Marshal(manifest) - if err != nil || len(body) > 256<<10 || writeFile(installed, ManifestName+".tmp", body, 0400) != nil || installed.Rename(ManifestName+".tmp", ManifestName) != nil { + return body, nil +} + +func (s *Snapshot) count(files []agentbundle.File) error { + for _, file := range files { + s.total += len(file.Data) + } + if s.total > MaxSnapshotBytes { return ErrInvalid } - return syncDirectory(installed, ".") + return nil } func addPlugin(manifest *Manifest, root string, bundle agentplugin.Bundle) error { @@ -149,30 +165,24 @@ func discover(manifest *Manifest, root string, files []agentbundle.File) error { return nil } -// Load checks the protected installation; it never reads the original workspace -// directories, even after a native/Core restart. -func Load(root *os.Root) (Manifest, error) { - info, err := root.Lstat(ManifestName) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 256<<10 { - return Manifest{}, ErrInvalid - } - body, err := root.ReadFile(ManifestName) - if err != nil { - return Manifest{}, ErrInvalid - } +// Check decodes a published manifest and checks the packages it names, which +// read reads as NewSnapshot's read does. It never reads the original sources, +// even after a native or Core restart. The manifest it returns carries the +// MCP servers its plugin packages declare. +func Check(body []byte, read func(string) ([]agentbundle.File, error)) (Manifest, error) { manifest, err := decodeManifest(body) if err != nil { return Manifest{}, err } packages := map[string][]agentbundle.File{} total := 0 - loadPackage := func(name string) ([]agentbundle.File, error) { + load := func(name string) ([]agentbundle.File, error) { if files, ok := packages[name]; ok { return files, nil } - files, err := ReadTree(root, name, true) + files, err := read(name) if err != nil { - return nil, err + return nil, ErrInvalid } for _, file := range files { total += len(file.Data) @@ -184,16 +194,19 @@ func Load(root *os.Root) (Manifest, error) { return files, nil } for _, skill := range manifest.Skills { - if _, err := loadPackage(skill.PackageRoot); err != nil { + files, err := load(skill.PackageRoot) + if err != nil { return Manifest{}, err } - body, err := root.ReadFile(skill.RelativeRoot + "/SKILL.md") - if err != nil || agentskill.ValidateManifest(body, skill.Metadata) != nil { + // decodeManifest checked that the Skill lies in its package. + inner := strings.TrimPrefix(strings.TrimPrefix(skill.RelativeRoot, skill.PackageRoot), "/") + body, ok := fileData(files, path.Join(inner, "SKILL.md")) + if !ok || agentskill.ValidateManifest(body, skill.Metadata) != nil { return Manifest{}, ErrInvalid } } for _, name := range manifest.Plugins { - files, err := loadPackage(name) + files, err := load(name) if err != nil { return Manifest{}, err } @@ -208,10 +221,7 @@ func Load(root *os.Root) (Manifest, error) { return manifest, nil } -// Only selected, fully imported bundles may precede finalization. A leftover -// directory capture or temporary manifest is an incomplete installation, not a -// reason to silently recapture mutable sources. -func validateStaged(root *os.Root, input Input) error { +func validateStaged(list func(string) ([]fs.DirEntry, error), input Input) error { expected := map[string]map[string]bool{"skills": {}, "plugins": {}} for _, skill := range input.Skills { expected["skills"][skill.Name] = true @@ -219,27 +229,17 @@ func validateStaged(root *os.Root, input Input) error { for slot := range input.Plugins { expected["plugins"][strconv.Itoa(slot)] = true } - file, err := root.Open(".") + entries, err := list(".") if err != nil { return ErrInvalid } - entries, err := file.ReadDir(-1) - closeErr := file.Close() - if err != nil || closeErr != nil { - return ErrInvalid - } for _, entry := range entries { selected, ok := expected[entry.Name()] if !ok || !entry.IsDir() { return ErrInvalid } - directory, err := root.Open(entry.Name()) - if err != nil { - return ErrInvalid - } - children, err := directory.ReadDir(-1) - closeErr := directory.Close() - if err != nil || closeErr != nil || len(children) != len(selected) { + children, err := list(entry.Name()) + if err != nil || len(children) != len(selected) { return ErrInvalid } for _, child := range children { @@ -251,11 +251,11 @@ func validateStaged(root *os.Root, input Input) error { return nil } -func unfinished(root *os.Root) error { - for _, name := range []string{ManifestName, ManifestName + ".tmp"} { - if _, err := root.Lstat(name); !os.IsNotExist(err) { - return ErrInvalid +func fileData(files []agentbundle.File, name string) ([]byte, bool) { + for _, file := range files { + if file.Path == name { + return file.Data, true } } - return nil + return nil, false } diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 555f93afb..cfe35d80f 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -6,9 +6,7 @@ import ( "encoding/hex" "encoding/json" "errors" - "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" "io/fs" - "os" "strings" "unicode/utf8" @@ -16,12 +14,14 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" + "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) const Directory = "/environment/initialization/capabilities" const ManifestName = "installed.json" const MaxSkills = 50 const MaxSnapshotBytes = 50 << 20 +const MaxManifestBytes = 256 << 10 var ErrInvalid = errors.New("capability installation unavailable or unsupported") @@ -40,10 +40,6 @@ type Identity struct { SessionID string `json:"session_id"` } -// DirectoryResolver opens a declared source after local authorization and path -// checks. Finalize owns and closes each returned root; callers retain no handle. -type DirectoryResolver func(string) (*os.Root, error) - type Manifest struct { Identity Identity `json:"identity"` SelectionSHA256 string `json:"selection_sha256"` @@ -60,6 +56,37 @@ type InstalledMCP struct { Server agentplugin.MCPServer } +// ResolveMCP checks that values set every variable the installed servers +// declare and returns each server's bearer token, nil when it declares none. +// Server names must be unique. +func ResolveMCP(installed []InstalledMCP, values map[string]string) ([]*string, error) { + tokens := make([]*string, 0, len(installed)) + names := map[string]bool{} + for _, item := range installed { + server := item.Server + if names[server.Name] { + return nil, errors.New("ambiguous environment MCP server identity") + } + names[server.Name] = true + var token *string + variables := append([]string{}, server.EnvVars...) + if server.BearerTokenEnvVar != "" { + variables = append(variables, server.BearerTokenEnvVar) + } + for _, name := range variables { + value, exists := values[name] + if !exists || strings.ContainsRune(value, 0) { + return nil, errors.New("declared environment MCP variable unavailable") + } + if name == server.BearerTokenEnvVar { + token = &value + } + } + tokens = append(tokens, token) + } + return tokens, nil +} + // Input describes frozen sources; directory contents are observed after setup. type Input struct { Skills []agentskill.Metadata `json:"skills,omitempty"` @@ -116,7 +143,7 @@ func (m *Manifest) add(metadata agentskill.Metadata, root, pkg string) error { func decodeManifest(body []byte) (Manifest, error) { var result Manifest - if len(body) > 256<<10 || json.Unmarshal(body, &result) != nil || result.Version != 1 || validateIdentity(result.Identity) != nil || !validSelectionHash(result.SelectionSHA256) || len(result.Skills) > MaxSkills { + if len(body) > MaxManifestBytes || json.Unmarshal(body, &result) != nil || result.Version != 1 || validateIdentity(result.Identity) != nil || !validSelectionHash(result.SelectionSHA256) || len(result.Skills) > MaxSkills { return Manifest{}, ErrInvalid } checked := Manifest{Version: 1} diff --git a/internal/agentcapabilities/tree.go b/internal/agentcapabilities/root.go similarity index 55% rename from internal/agentcapabilities/tree.go rename to internal/agentcapabilities/root.go index 8260e000e..14170d84a 100644 --- a/internal/agentcapabilities/tree.go +++ b/internal/agentcapabilities/root.go @@ -8,9 +8,108 @@ import ( "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" "github.com/MiniMax-AI/OpenAgentCore/internal/runtimefs" ) +// The rest of this file applies the installation rules to a local directory, +// as an os.Root. + +// DirectoryResolver opens a declared source after local authorization and path +// checks. Finalize owns and closes each returned root; callers retain no handle. +type DirectoryResolver func(string) (*os.Root, error) + +// InstallSkill stages a Skill archive in root. +func InstallSkill(root *os.Root, archive []byte, metadata agentskill.Metadata) error { + tree, err := SkillTree(archive, metadata) + if err != nil || unfinished(root) != nil { + return ErrInvalid + } + return writeTree(root, tree.Root, tree.Files) +} + +// InstallPlugin stages a Plugin archive in slot of root. +func InstallPlugin(root *os.Root, slot int, archive []byte, metadata agentplugin.Metadata) error { + tree, err := PluginTree(slot, archive, metadata) + if err != nil || unfinished(root) != nil { + return ErrInvalid + } + return writeTree(root, tree.Root, tree.Files) +} + +// Finalize completes the installation in installed with a Snapshot, +// resolving each declared directory with resolve. +func Finalize(installed *os.Root, input Input, identity Identity, resolve DirectoryResolver) error { + if len(input.Directories) > 0 && resolve == nil { + return ErrInvalid + } + if _, err := installed.Lstat(ManifestName); !os.IsNotExist(err) { + return ErrInvalid + } + snapshot, err := NewSnapshot(input, identity, func(name string) ([]fs.DirEntry, error) { return readDir(installed, name) }, + func(name string) ([]agentbundle.File, error) { return ReadTree(installed, name, true) }) + if err != nil { + return err + } + for _, source := range input.Directories { + directory, err := resolve(source) + if err != nil || directory == nil { + if directory != nil { + directory.Close() + } + return ErrInvalid + } + files, err := ReadTree(directory, ".", false) + if closeErr := directory.Close(); err != nil || closeErr != nil { + return ErrInvalid + } + tree, err := snapshot.Capture(files) + if err != nil || writeTree(installed, tree.Root, tree.Files) != nil { + return ErrInvalid + } + } + body, err := snapshot.Manifest() + if err != nil || writeFile(installed, ManifestName+".tmp", body, 0400) != nil || installed.Rename(ManifestName+".tmp", ManifestName) != nil { + return ErrInvalid + } + return syncDirectory(installed, ".") +} + +// Load checks the installation in root, as Check does. +func Load(root *os.Root) (Manifest, error) { + info, err := root.Lstat(ManifestName) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > MaxManifestBytes { + return Manifest{}, ErrInvalid + } + body, err := root.ReadFile(ManifestName) + if err != nil { + return Manifest{}, ErrInvalid + } + return Check(body, func(name string) ([]agentbundle.File, error) { return ReadTree(root, name, true) }) +} + +func readDir(root *os.Root, name string) ([]fs.DirEntry, error) { + file, err := root.Open(name) + if err != nil { + return nil, ErrInvalid + } + entries, err := file.ReadDir(-1) + if closeErr := file.Close(); err != nil || closeErr != nil { + return nil, ErrInvalid + } + return entries, nil +} + +func unfinished(root *os.Root) error { + for _, name := range []string{ManifestName, ManifestName + ".tmp"} { + if _, err := root.Lstat(name); !os.IsNotExist(err) { + return ErrInvalid + } + } + return nil +} + // ReadTree stays inside an already-owned root and rejects aliases/special files. // Installation sources may be writable; retained snapshots must be read-only. func ReadTree(root *os.Root, name string, immutable bool) ([]agentbundle.File, error) { From 6aa77cd979161b1aeb6144f054efb18661016fbb Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 19:32:52 +0000 Subject: [PATCH 05/13] Pass the runtime_prepare transfer ID to the Environment owner --- apps/daemon/internal/dispatch/environment.go | 9 ++++++--- apps/daemon/internal/dispatch/runtime_preparation.go | 7 ++++--- .../internal/dispatch/runtime_preparation_test.go | 12 +++++++----- .../internal/dispatch/workspace_export_test.go | 2 +- apps/daemon/internal/localworkspace/capabilities.go | 3 ++- .../localworkspace/runtime_initialization_test.go | 5 +++-- .../internal/localworkspace/snapshot_marker_test.go | 7 ++++--- 7 files changed, 27 insertions(+), 18 deletions(-) diff --git a/apps/daemon/internal/dispatch/environment.go b/apps/daemon/internal/dispatch/environment.go index bc9cb4150..d76adc830 100644 --- a/apps/daemon/internal/dispatch/environment.go +++ b/apps/daemon/internal/dispatch/environment.go @@ -7,6 +7,7 @@ import ( "io" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" ) // Environment owns one Session's Environment: its resources and every effect on @@ -21,9 +22,11 @@ type Environment interface { // Prepare fills the configured execution's installed capabilities before // the Executor factory runs. Prepare(context.Context, proto.PromptRequestPayload) (proto.PromptRequestPayload, error) - // ApplyRuntimePreparation applies one complete runtime_prepare transfer and - // returns only after its mutations stop. - ApplyRuntimePreparation(context.Context, proto.RuntimePreparePayload, []byte) error + // ApplyRuntimePreparation applies one complete runtime_prepare transfer, + // whose envelope ID is transfer, and returns only after its mutations + // stop. Core never sends a transfer ID twice, so transfer may name the + // effects the transfer starts. + ApplyRuntimePreparation(ctx context.Context, transfer uuid.UUID, payload proto.RuntimePreparePayload, data []byte) error ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (WorkspaceDirectoryResult, error) WriteWorkspaceFile(ctx context.Context, path string, data []byte) (WorkspaceWriteResult, error) ExportOutputs(context.Context, io.Writer) error diff --git a/apps/daemon/internal/dispatch/runtime_preparation.go b/apps/daemon/internal/dispatch/runtime_preparation.go index 0ad8c1586..4d65d5496 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation.go +++ b/apps/daemon/internal/dispatch/runtime_preparation.go @@ -17,6 +17,7 @@ const runtimePreparationTimeout = 120 * time.Second // Router.mu protects one connection-local transfer. Partial installation data // belongs to the bound Environment and is never removed by transfer cleanup. type runtimePreparationTransfer struct { + id uuid.UUID // the envelope's ID envelope proto.Envelope request proto.RuntimePreparePayload data []byte @@ -75,7 +76,7 @@ func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) e } owner, cancel := context.WithTimeout(context.WithoutCancel(ctx), runtimePreparationTimeout) u := &runtimePreparationTransfer{ - envelope: env, request: request, data: make([]byte, 0, request.SizeBytes), + id: id, envelope: env, request: request, data: make([]byte, 0, request.SizeBytes), ready: make(chan struct{}), cancel: cancel, } r.runtimePreparation = u @@ -174,7 +175,7 @@ func (r *Router) finishRuntimePreparationTransferLocked(u *runtimePreparationTra // apply must return only after its local mutations stop. Cancellation requests // shutdown, but cannot release ownership while that call is still running. // done runs once the result is sent. -func (r *Router) runRuntimePreparationTransfer(ctx context.Context, u *runtimePreparationTransfer, apply func(context.Context, proto.RuntimePreparePayload, []byte) error, done func()) { +func (r *Router) runRuntimePreparationTransfer(ctx context.Context, u *runtimePreparationTransfer, apply func(context.Context, uuid.UUID, proto.RuntimePreparePayload, []byte) error, done func()) { defer r.shutdownWG.Done() defer done() defer u.cancel() @@ -196,7 +197,7 @@ func (r *Router) runRuntimePreparationTransfer(ctx context.Context, u *runtimePr result = rejectedRuntimePreparation(fenced) } if admitted { - result = runtimePreparationResult(apply(ctx, u.request, data), u.request.SizeBytes) + result = runtimePreparationResult(apply(ctx, u.id, u.request, data), u.request.SizeBytes) } // Release the potentially large body before waiting on transport delivery. data = nil diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go index 0d0fd721b..456a0eec2 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -261,14 +261,14 @@ func TestRuntimePreparationCancellationKeepsOwnershipUntilApplyStops(t *testing. ctx, cancel := context.WithCancel(context.Background()) id := uuid.NewString() request := proto.RuntimePreparePayload{Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &agentcapabilities.Input{}} - owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} + owner := &runtimePreparationTransfer{id: uuid.MustParse(id), envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} close(owner.ready) r.runtimePreparation = owner r.shutdownWG.Add(1) started, interrupted, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) retained := filepath.Join(t.TempDir(), "installed.json") - go r.runRuntimePreparationTransfer(ctx, owner, func(ctx context.Context, got proto.RuntimePreparePayload, data []byte) error { - if got.Action != "finalize" || len(data) != 0 { + go r.runRuntimePreparationTransfer(ctx, owner, func(ctx context.Context, transfer uuid.UUID, got proto.RuntimePreparePayload, data []byte) error { + if transfer.String() != id || got.Action != "finalize" || len(data) != 0 { return agentcapabilities.ErrInvalid } close(started) @@ -341,11 +341,13 @@ func TestRuntimePreparationResultCategoriesAndUnknownOwnership(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) id := uuid.NewString() request := capabilityBegin(environment, session, []byte("abc")) - owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, data: []byte("abc"), ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} + owner := &runtimePreparationTransfer{id: uuid.MustParse(id), envelope: capabilityEnvelope(t, id, request), request: request, data: []byte("abc"), ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} close(owner.ready) r.runtimePreparation = owner r.shutdownWG.Add(1) - go r.runRuntimePreparationTransfer(ctx, owner, func(context.Context, proto.RuntimePreparePayload, []byte) error { return context.DeadlineExceeded }, func() {}) + go r.runRuntimePreparationTransfer(ctx, owner, func(context.Context, uuid.UUID, proto.RuntimePreparePayload, []byte) error { + return context.DeadlineExceeded + }, func() {}) capabilitiesReceipt(t, sender, id, "unknown") r.mu.Lock() owned := r.runtimePreparation == owner && owner.uncertain && owner.data == nil diff --git a/apps/daemon/internal/dispatch/workspace_export_test.go b/apps/daemon/internal/dispatch/workspace_export_test.go index 30f84e23e..42c02bc6a 100644 --- a/apps/daemon/internal/dispatch/workspace_export_test.go +++ b/apps/daemon/internal/dispatch/workspace_export_test.go @@ -39,7 +39,7 @@ func (stubEnvironment) Configure(r proto.PromptRequestPayload) (proto.PromptRequ func (stubEnvironment) Prepare(_ context.Context, r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { return r, nil } -func (stubEnvironment) ApplyRuntimePreparation(context.Context, proto.RuntimePreparePayload, []byte) error { +func (stubEnvironment) ApplyRuntimePreparation(context.Context, uuid.UUID, proto.RuntimePreparePayload, []byte) error { return errors.New("stub") } func (stubEnvironment) ListWorkspaceDirectory(context.Context, string, int) (WorkspaceDirectoryResult, error) { diff --git a/apps/daemon/internal/localworkspace/capabilities.go b/apps/daemon/internal/localworkspace/capabilities.go index 3dd410248..cd3ca2d84 100644 --- a/apps/daemon/internal/localworkspace/capabilities.go +++ b/apps/daemon/internal/localworkspace/capabilities.go @@ -10,6 +10,7 @@ import ( "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. @@ -79,7 +80,7 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr // ApplyRuntimePreparation settles every filesystem operation before returning. A // cancelled caller never leaves an unowned installation goroutine behind. -func (b *Binding) ApplyRuntimePreparation(ctx context.Context, input proto.RuntimePreparePayload, data []byte) error { +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 } diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_test.go b/apps/daemon/internal/localworkspace/runtime_initialization_test.go index 58ec5bbd5..dea90b121 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_test.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_test.go @@ -18,6 +18,7 @@ import ( "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. @@ -351,14 +352,14 @@ func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { 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(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + 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(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + 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_test.go b/apps/daemon/internal/localworkspace/snapshot_marker_test.go index b57484af7..769c2ffed 100644 --- a/apps/daemon/internal/localworkspace/snapshot_marker_test.go +++ b/apps/daemon/internal/localworkspace/snapshot_marker_test.go @@ -11,6 +11,7 @@ import ( "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, proto.PromptRequestPayload) { @@ -187,7 +188,7 @@ 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(), finalize, nil); err != nil { + if err := b.ApplyRuntimePreparation(t.Context(), uuid.New(), finalize, nil); err != nil { t.Fatal(err) } if _, err := os.Stat(markerPath(b)); err != nil { @@ -198,10 +199,10 @@ func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) 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(), skill, []byte("x")); err == nil { + 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(), finalize, nil); err == nil { + 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) { From 675a1e67eb01e1855d8fa15b121c15fe6cffe216 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 20:13:59 +0000 Subject: [PATCH 06/13] Serve the agent host's Environments over the Link Each Session bound to the agent host gets one Environment owner with its own Link attachment. It applies runtime_prepare to the sandbox through File and Process, prepares each Executor's request with sandbox paths, serves workspace reads, Files create and outputs export, and quarantines itself after a mutation whose effect is unknown. Dispatch loses the SessionEnvironments mode, which no owner backed. --- .../internal/agenthost/admit_linux_test.go | 21 +- apps/daemon/internal/agenthost/agenthost.go | 1 + .../agenthost/agenthost_linux_test.go | 99 +- apps/daemon/internal/agenthost/doc.go | 30 +- .../internal/agenthost/environment_linux.go | 927 ++++++++++++++++++ .../internal/agenthost/executor_linux.go | 19 +- apps/daemon/internal/agenthost/host_linux.go | 2 +- apps/daemon/internal/agenthost/host_other.go | 10 +- .../daemon/internal/agenthost/launch_linux.go | 7 +- .../internal/agenthost/sessiondir_linux.go | 12 +- apps/daemon/internal/agenthost/setup_linux.go | 133 +++ .../internal/agenthost/view_linux_test.go | 22 +- apps/daemon/internal/agenthost/world_linux.go | 494 ++++++++++ .../agenthostqualify/qualify_linux_test.go | 189 +++- apps/daemon/internal/dispatch/executor.go | 2 +- apps/daemon/internal/dispatch/router.go | 9 - deploy/distribution/AgentHost.Dockerfile | 3 +- 17 files changed, 1848 insertions(+), 132 deletions(-) create mode 100644 apps/daemon/internal/agenthost/environment_linux.go create mode 100644 apps/daemon/internal/agenthost/setup_linux.go create mode 100644 apps/daemon/internal/agenthost/world_linux.go diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index 6c5321f9a..db329b709 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -162,7 +162,7 @@ func TestStdioMCPRunsUnderItsAlias(t *testing.T) { func TestRegistryRunsKindsWithViews(t *testing.T) { f := newViewFixture(t) var kinds []string - for _, info := range (&Host{cfg: f.cfg}).Registry(nil).SupportedAgentKinds() { + for _, info := range (&Host{cfg: f.cfg}).Registry().SupportedAgentKinds() { kinds = append(kinds, info.Kind) } slices.Sort(kinds) @@ -178,9 +178,9 @@ func TestViewExecutorReceivesTheGatewayRequest(t *testing.T) { req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "docs", ServerURL: "https://mcp.test/docs?tenant=a", BearerToken: &bearer}} original := *req.ModelProvider var dials atomic.Int32 - d := newDaemon(t, f.cfg, deps{dial: countingDial(&dials), tasks: noTasks}) - if _, p := d.prepare(t, newBinding(newResource()), req); p.State != "failed" || p.ErrorCode != "preparation_failed" { - t.Fatalf("the preparation is %s (%s), want failed with the factory", p.State, p.ErrorCode) + e, err := open(context.Background(), f.cfg, req, bindTo(newBinding(newResource())), deps{dial: countingDial(&dials), tasks: noTasks}) + if !errors.Is(err, errFactory) { + t.Fatalf("open = %v, want the factory's error", err) } if provider := f.req.ModelProvider; provider == nil || provider.BaseURL != "http://127.0.0.1:17101" || provider.APIKey != modelprovider.Placeholder || provider.Protocol != modelprovider.Anthropic { t.Errorf("model provider %+v; want the gateway with the placeholder", provider) @@ -201,9 +201,12 @@ func TestViewExecutorReceivesTheGatewayRequest(t *testing.T) { if !strings.HasPrefix(f.session.Home.Host, sessionsDir(f.cfg.StateDir)+string(filepath.Separator)) { t.Errorf("home %s is outside the Session directories", f.session.Home.Host) } - // The failed preparation closed its Executor, which keeps only the home. + // Closing the failed Executor keeps only the home. + if err := e.Close(context.Background()); err != nil { + t.Fatal(err) + } if _, err := os.Stat(f.session.Home.Host); dials.Load() != 0 || err != nil || len(leftEntries(t, f.cfg)) != 0 { - t.Errorf("%d dials, home %v and transient entries %v after the preparation", dials.Load(), err, leftEntries(t, f.cfg)) + t.Errorf("%d dials, home %v and transient entries %v after the Executor closed", dials.Load(), err, leftEntries(t, f.cfg)) } } @@ -213,8 +216,10 @@ func TestReleaseRemovesTheHome(t *testing.T) { f := newViewFixture(t) var dials atomic.Int32 d := newDaemon(t, f.cfg, deps{dial: countingDial(&dials), tasks: noTasks}) - b := newBinding(newResource()) - if _, p := d.prepare(t, b, request("viewed", "/workspace", "https://model.test", "sk-test")); p.State != "failed" { + b := newBinding(sandboxlink.ResourceRef{}) + none := request("viewed", "", "https://model.test", "sk-test") + none.LocalEnvironment, none.DisableExecutionEnvironment = nil, true + if _, p := d.prepare(t, b, none); p.State != "failed" { t.Fatalf("the preparation is %s, want failed with the factory", p.State) } if _, err := os.Stat(f.session.Home.Host); err != nil { diff --git a/apps/daemon/internal/agenthost/agenthost.go b/apps/daemon/internal/agenthost/agenthost.go index 5326261b8..c4e024d21 100644 --- a/apps/daemon/internal/agenthost/agenthost.go +++ b/apps/daemon/internal/agenthost/agenthost.go @@ -120,6 +120,7 @@ type Host struct { cfg Config // state and views hold the locks; nil until taken. state, views *os.File + owners owners } // Close releases the installation locks. Call it once every Executor has diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index 5bfaf0621..ea5de3fd8 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -7,7 +7,6 @@ import ( "crypto/x509" "encoding/pem" "errors" - "fmt" "log/slog" "os" "path/filepath" @@ -21,12 +20,15 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processshim" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" + "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/harnessconfig" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" + "github.com/google/uuid" ) // The test binary is also the privileged suite's Harness inside the view, @@ -83,7 +85,7 @@ func request(kind, workspace, baseURL, key string) proto.PromptRequestPayload { AgentKind: kind, Model: "m", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: baseURL, APIKey: key}, - LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}, + LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}}, } } @@ -134,31 +136,30 @@ func leftEntries(t *testing.T, cfg Config) []string { return left } -// daemon drives Sessions through a dispatch Router, as the daemon does. It -// binds each request to the Session of the assignment the Router admitted it -// under, records the latest Executor the agent host opened for each Session, -// and removes a released Session's home. +// daemon drives Sessions through a dispatch Router, as the daemon does, with +// a Host's Environment owners and Executor factory. It records the latest +// Executor the agent host opened for each Session. type daemon struct { + host *Host router *dispatch.Router // mcp is the installed MCP that the Environment's preparation resolves // into each request; the wire does not carry it. - mcp []proto.EnvironmentMCP - mu sync.Mutex - frames map[string]chan proto.Envelope // by envelope ID - bindings map[string]Binding // by Session ID - opened map[string]*session // by Session ID + mcp []proto.EnvironmentMCP + mu sync.Mutex + frames map[string]chan proto.Envelope // by envelope ID + opened map[string]*session // by Session ID } func newDaemon(t *testing.T, cfg Config, d deps) *daemon { t.Helper() - dm := &daemon{frames: map[string]chan proto.Envelope{}, bindings: map[string]Binding{}, opened: map[string]*session{}} + dm := &daemon{host: &Host{cfg: cfg, owners: owners{d: d}}, frames: map[string]chan proto.Envelope{}, opened: map[string]*session{}} reg := registry(cfg.Harnesses, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { if dm.mcp != nil { local := *req.LocalEnvironment local.MCP = dm.mcp req.LocalEnvironment = &local } - e, err := open(ctx, cfg, req, dm.bind, d) + e, err := dm.host.openExecutor(ctx, req) if s, ok := e.(*session); ok { dm.mu.Lock() dm.opened[strings.TrimPrefix(req.AgentStateKey, stateKeyPrefix)] = s @@ -166,17 +167,15 @@ func newDaemon(t *testing.T, cfg Config, d deps) *daemon { } return e, err }) - var err error removeHome := func(session string) error { - dm.mu.Lock() - b, ok := dm.bindings[session] - dm.mu.Unlock() - if !ok { - return fmt.Errorf("%w: no binding", ErrInvalidSession) + id, err := canonicalID(session) + if err != nil { + return err } - return (&Host{cfg: cfg}).RemoveHome(b.SessionID) + return dm.host.RemoveHome(id) } - if dm.router, err = dispatch.New(dispatch.Config{Registry: reg, Sender: dm, SessionEnvironments: true, RemoveHome: removeHome, Log: slog.New(slog.DiscardHandler)}); err != nil { + var err error + if dm.router, err = dispatch.New(dispatch.Config{Registry: reg, Sender: dm, Environments: dm.host.Environments, RemoveHome: removeHome, Log: slog.New(slog.DiscardHandler)}); err != nil { t.Fatal(err) } t.Cleanup(func() { dm.shutdown() }) @@ -186,19 +185,31 @@ func newDaemon(t *testing.T, cfg Config, d deps) *daemon { // stateKeyPrefix and the Session ID make the state key dispatch requires. const stateKeyPrefix = "agents-api-" -func (dm *daemon) bind(req proto.PromptRequestPayload) (Binding, Environment, error) { - dm.mu.Lock() - defer dm.mu.Unlock() - b, ok := dm.bindings[req.Assignment.SessionID] - if !ok || ref(b) != req.Assignment { - return Binding{}, Environment{}, fmt.Errorf("%w: no binding", ErrInvalidSession) +// ref is the reference of b's assignment. +func ref(b Binding) proto.AssignmentRef { + return proto.AssignmentRef{SessionID: uuid.UUID(b.SessionID).String(), AssignmentID: uuid.UUID(b.AssignmentID).String(), Epoch: b.AssignmentEpoch} +} + +// environmentID is the Environment of b's resource; empty for environment +// none, whose binding has no resource. +func environmentID(b Binding) string { + if b.Resource == (sandboxlink.ResourceRef{}) { + return "" } - return b, Environment{}, nil + return uuid.UUID(b.Resource.EnvironmentID).String() } -// ref is the reference of b's assignment. -func ref(b Binding) proto.AssignmentRef { - return proto.AssignmentRef{SessionID: b.SessionID.String(), AssignmentID: b.AssignmentID.String(), Epoch: b.AssignmentEpoch} +// bindPayload is the assignment_bind that binds b's Session. +func bindPayload(b Binding) proto.AssignmentBindPayload { + p := proto.AssignmentBindPayload{EnvironmentID: environmentID(b)} + if p.EnvironmentID != "" { + r := b.Resource + kind := map[sandboxlink.ResourceKind]string{sandboxlink.ResourceAllocation: "allocation", sandboxlink.ResourceEnrollment: "enrollment"}[r.Kind] + p.Resource = &sandboxbootstrap.Resource{TenantID: uuid.UUID(r.TenantID).String(), EnvironmentID: p.EnvironmentID, Kind: kind, + ID: uuid.UUID(r.ID).String(), Generation: r.Generation} + p.AttachGrant = b.AttachGrant + } + return p } func (dm *daemon) Send(_ context.Context, e proto.Envelope) error { @@ -242,14 +253,11 @@ func (dm *daemon) next(t *testing.T, id string) proto.Envelope { } } -// assign binds b's Session to the Router in environment. -func (dm *daemon) assign(t *testing.T, b Binding, environment string) { +// assign binds b's Session to the Router. +func (dm *daemon) assign(t *testing.T, b Binding) { t.Helper() - dm.mu.Lock() - dm.bindings[b.SessionID.String()] = b - dm.mu.Unlock() id := sandboxwire.NewID().String() - dm.handle(t, ref(b), proto.TypeAssignmentBind, id, proto.AssignmentBindPayload{EnvironmentID: environment}) + dm.handle(t, ref(b), proto.TypeAssignmentBind, id, bindPayload(b)) if status := dm.status(t, id); status.State != proto.AssignmentBound { t.Fatalf("the bind is %s (%s), want bound", status.State, status.ErrorCode) } @@ -265,13 +273,18 @@ func (dm *daemon) status(t *testing.T, id string) proto.AssignmentStatusPayload return status } -// prepare binds b's Session and prepares an Executor of it for req. It -// returns the request ID and the preparation's first status other than -// preparing. +// prepare binds b's Session and prepares an Executor of it for req in the +// Environment of b's resource. It returns the request ID and the +// preparation's first status other than preparing. func (dm *daemon) prepare(t *testing.T, b Binding, req proto.PromptRequestPayload) (string, proto.PreparationStatusPayload) { t.Helper() - dm.assign(t, b, req.EnvironmentID()) - session := b.SessionID.String() + dm.assign(t, b) + session := ref(b).SessionID + if req.LocalEnvironment != nil { + local := *req.LocalEnvironment + local.ID = environmentID(b) + req.LocalEnvironment = &local + } req.AgentStateKey = stateKeyPrefix + session id := sandboxwire.NewID().String() dm.handle(t, ref(b), proto.TypeExecutionPrepare, id, proto.ExecutionPreparePayload{SessionID: session, Configuration: req}) @@ -326,7 +339,7 @@ func (dm *daemon) done(t *testing.T, run string) []proto.Envelope { func (dm *daemon) session(b Binding) *session { dm.mu.Lock() defer dm.mu.Unlock() - return dm.opened[b.SessionID.String()] + return dm.opened[ref(b).SessionID] } // shutdown shuts the Router down, which closes every Executor, and returns diff --git a/apps/daemon/internal/agenthost/doc.go b/apps/daemon/internal/agenthost/doc.go index fa5ddc66a..42ff8f5c5 100644 --- a/apps/daemon/internal/agenthost/doc.go +++ b/apps/daemon/internal/agenthost/doc.go @@ -4,8 +4,8 @@ // ErrUnsupported. // // The process that runs the agent host calls Open once at startup, hands -// Host.Registry to the daemon's dispatch, which drives each Turn of each -// Executor, and calls Close once every Executor has closed. No production +// Host.Registry and Host.Environments to the daemon's dispatch, which drives +// each Turn of each Executor, and calls Close once every Executor has closed. No production // caller constructs Host.Registry yet; oac-daemon connect still runs // Harnesses in the sandbox. Open takes two installation locks, which the // Host holds until Close: a flock on StateDir/lock for the Session @@ -31,9 +31,29 @@ // a delegation that fails them fails each view's launch with ErrLaunch and // sessionview.ErrLauncher. // -// Host.Registry's Executor factory prepares an Executor of the Session that -// its bind function binds the request to. It admits the request before any -// effect: the kind must declare an agent.View, and when the view declares +// Host.Environments gives each Session bound to the agent host one +// Environment owner, a dispatch.Environment, from its first assignment_bind +// until Host.RemoveHome, so the owner outlives Executors and Routers. It +// opens its own Link attachment on first use and closes it when dispatch +// closes the owner at release, quiescence or shutdown; its next operation +// opens a new one. Over the attachment's File service it applies +// runtime_prepare to the sandbox: initial files in /workspace, the frozen +// tool environment and the completion marker in /environment/initialization, +// and Skills, plugins and the declared directories' snapshot in +// agentcapabilities.Directory. A setup, npm or python step runs over the +// Process service as the operation the transfer's ID names. Before each +// Executor factory runs, the owner checks the installation, without +// initializing a completed one again, and fills the request's Skills, MCP and +// capability root as sandbox paths. It also serves workspace reads, Files +// create and outputs export. A File mutation or setup step whose effect is +// unknown quarantines the owner: it sends no mutation again until +// Host.RemoveHome forgets it. A Session with environment none gets an owner +// without an Environment, which never attaches. +// +// Host.Registry's Executor factory prepares an Executor of the Session whose +// owner prepared the request, with the sandbox's baseline environment and the +// frozen tool environment as its Environment. It admits the request before +// any effect: the kind must declare an agent.View, and when the view declares // shim names or a stdio MCP server's command is a bare name, both of which // run on the sandbox PATH, the Session's Environment must set PATH. A // Session with Skills, with a restricted network, or with a stdio MCP server diff --git a/apps/daemon/internal/agenthost/environment_linux.go b/apps/daemon/internal/agenthost/environment_linux.go new file mode 100644 index 000000000..cd0e7a848 --- /dev/null +++ b/apps/daemon/internal/agenthost/environment_linux.go @@ -0,0 +1,927 @@ +//go:build linux + +package agenthost + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "maps" + "path" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// The sandbox layout an Environment owner prepares. Providers create the +// initialization and package directories for the sandbox's user. +const ( + sandboxWorkspace = "/workspace" + sandboxInitialization = "/environment/initialization" + sandboxPackages = "/environment/packages" + toolEnvironmentName = "tool-env.json" + markerBytes = 8192 + toolEnvironmentBytes = 1 << 20 + // writeBound bounds a workspace write, which dispatch never cancels. + writeBound = time.Minute + // The export bounds are the guest's. + exportFileBytes = 200 << 20 + exportBatchBytes = 500 << 20 + exportEntries = 4096 +) + +// sandboxBaseline is the environment of the sandbox image that a Session's +// processes in the sandbox start from, never the agent host's own. +var sandboxBaseline = map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/home/runtime", "LANG": "C.UTF-8"} + +// owners holds the Environment owner of each Session bound to the agent +// host, from its first bind until RemoveHome. +type owners struct { + d deps + mu sync.Mutex + m map[sandboxwire.ID]*environment +} + +// environment is a Session's Environment owner on the agent host. It +// prepares and serves the sandbox through File and Process on its own Link +// attachment, which it opens on first use and drains on Close; a later +// operation opens a new one. An uncertain mutation quarantines it: it sends +// no mutation again while it lives, across drains and Routers. +type environment struct { + d deps + session string // the canonical Session ID + id string // the Environment ID; empty for environment none + // sem serializes the owner's operations, Close included. Its holder + // owns every field below; a rebind also holds owners.mu. + sem chan struct{} + binding Binding + link *linkOwner + lost *atomic.Bool // the link reported a failure of the attachment + world *world + uncertain bool + // tool is the tool environment the last preparation read. + tool map[string]string +} + +// Environments resolves the Environment owner of a Session's first bind on a +// Router, as dispatch.Config.Environments: it does no I/O. A Session keeps +// its owner across Routers until RemoveHome. A bind under another assignment +// takes the owner over once it is drained. Environments returns nil for a +// bind the agent host does not serve. +func (h *Host) Environments(ref proto.AssignmentRef, bind proto.AssignmentBindPayload) dispatch.Environment { + session, err := canonicalID(ref.SessionID) + assignment, err2 := canonicalID(ref.AssignmentID) + if err != nil || err2 != nil || bind.Resource == nil && bind.EnvironmentID != "" { + return nil + } + b := Binding{SessionID: session, AssignmentID: assignment, AssignmentEpoch: ref.Epoch, AttachGrant: slices.Clone(bind.AttachGrant)} + if bind.Resource != nil { + b.Resource = bind.Resource.Ref() + } + h.owners.mu.Lock() + defer h.owners.mu.Unlock() + o := h.owners.m[session] + switch { + case o == nil: + o = &environment{d: h.owners.d, session: ref.SessionID, id: bind.EnvironmentID, sem: make(chan struct{}, 1), binding: b} + if h.owners.m == nil { + h.owners.m = map[sandboxwire.ID]*environment{} + } + h.owners.m[session] = o + case o.id != bind.EnvironmentID: + return nil + case !sameBinding(o.binding, b): + select { + case o.sem <- struct{}{}: + default: + return nil + } + drained := o.link == nil + if drained { + o.binding = b + } + <-o.sem + if !drained { + return nil + } + } + return o +} + +func sameBinding(a, b Binding) bool { + return a.Resource == b.Resource && a.SessionID == b.SessionID && a.AssignmentID == b.AssignmentID && + a.AssignmentEpoch == b.AssignmentEpoch && bytes.Equal(a.AttachGrant, b.AttachGrant) +} + +func canonicalID(s string) (sandboxwire.ID, error) { + id, err := uuid.Parse(s) + if err != nil || id == uuid.Nil || id.String() != s { + return sandboxwire.ID{}, fmt.Errorf("%q is not a canonical UUID", s) + } + return sandboxwire.ID(id), nil +} + +// executor returns the binding and Environment of an Executor of req's +// Session, whose preparation the owner prepared. +func (h *Host) executor(ctx context.Context, req proto.PromptRequestPayload) (Binding, Environment, error) { + session, err := canonicalID(req.Assignment.SessionID) + if err != nil { + return Binding{}, Environment{}, invalidSession("binding: %v", err) + } + h.owners.mu.Lock() + o := h.owners.m[session] + h.owners.mu.Unlock() + if o == nil { + return Binding{}, Environment{}, invalidSession("binding: the Session has no Environment owner") + } + if err := o.acquire(ctx); err != nil { + return Binding{}, Environment{}, err + } + defer o.release() + b := o.binding + switch { + case uuid.UUID(b.AssignmentID).String() != req.Assignment.AssignmentID || b.AssignmentEpoch != req.Assignment.Epoch: + return Binding{}, Environment{}, invalidSession("binding: the request's assignment is not the Session's") + case o.id == "": + return b, Environment{}, nil + case o.tool == nil: + return Binding{}, Environment{}, invalidSession("binding: the Environment is not prepared") + } + return b, Environment{Sandbox: maps.Clone(sandboxBaseline), Tool: maps.Clone(o.tool)}, nil +} + +// dropEnvironment drains and forgets the Session's owner. +func (h *Host) dropEnvironment(session sandboxwire.ID) error { + h.owners.mu.Lock() + o := h.owners.m[session] + h.owners.mu.Unlock() + if o == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), closeBound) + defer cancel() + if err := o.Close(ctx); err != nil { + return err + } + h.owners.mu.Lock() + if h.owners.m[session] == o { + delete(h.owners.m, session) + } + h.owners.mu.Unlock() + return nil +} + +func (o *environment) acquire(ctx context.Context) error { + select { + case o.sem <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (o *environment) release() { <-o.sem } + +func (o *environment) identity() agentcapabilities.Identity { + return agentcapabilities.Identity{EnvironmentID: o.id, SessionID: o.session} +} + +// attach returns the owner's world, opening an attachment when it has none +// or the last one failed. The holder of sem calls it. +func (o *environment) attach(ctx context.Context) (*world, error) { + if o.world != nil && !o.world.ended() && !o.lost.Load() { + return o.world, nil + } + if err := o.drain(); err != nil { + return nil, err + } + lost := new(atomic.Bool) + // The link calls fail under its lock, so fail only records the loss. + o.link, o.lost = newLinkOwner(o.d.dial, o.binding, sandboxwire.NewID(), func(error) { lost.Store(true) }), lost + st, err := o.open(ctx, sandboxlink.ServiceFile, sandboxfs.Version) + if err != nil { + return nil, err + } + if o.world, err = attachWorld(ctx, st, &o.uncertain); err != nil { + return nil, fmt.Errorf("%w: attach the world: %w", ErrWorld, err) + } + return o.world, nil +} + +// open opens a stream of service on the owner's attachment. +func (o *environment) open(ctx context.Context, service sandboxlink.Service, version uint16) (io.ReadWriteCloser, error) { + st, err := o.link.open(ctx, service, version) + if err != nil { + return nil, err + } + if o.d.stream != nil { + return o.d.stream(service, st), nil + } + return st, nil +} + +// drain closes the owner's attachment. It keeps the attachment when the +// relay did not confirm its close, so that a later drain retries. +func (o *environment) drain() error { + if o.world != nil { + o.world.c.Close() + o.world = nil + } + if o.link == nil { + return nil + } + if err := o.link.close(); err != nil { + return err + } + o.link, o.lost = nil, nil + return nil +} + +// done ends an operation on w: it forgets the references the operation +// acquired. A stream that fails here is drained on next use. +func (o *environment) done(w *world) { + ctx, cancel := context.WithTimeout(context.Background(), closeBound) + defer cancel() + w.forget(ctx) +} + +// Close drains the owner. It keeps its quarantine and serves again on next +// use. +func (o *environment) Close(ctx context.Context) error { + if err := o.acquire(ctx); err != nil { + return err + } + defer o.release() + return o.drain() +} + +// Configure checks the request against the owner's Session and Environment +// and returns it with the sandbox workspace as its root. +func (o *environment) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { + local := r.LocalEnvironment + switch { + case r.AgentStateKey != "agents-api-"+o.session: + return r, errors.New("the request is not the Session's") + case o.id == "": + if local != nil || !r.DisableExecutionEnvironment { + return r, errors.New("the Session has no Environment") + } + return r, nil + case local == nil || r.DisableExecutionEnvironment || local.ID != o.id: + return r, errors.New("the request does not name the Session's Environment") + case r.WorkspaceReadOnly: + return r, nil + case local.WorkspaceDirectory != sandboxWorkspace: + return r, fmt.Errorf("the workspace is not %s", sandboxWorkspace) + case local.CapabilitySources == nil || agentcapabilities.ValidateInput(*local.CapabilitySources) != nil: + return r, agentcapabilities.ErrInvalid + } + sources := *local.CapabilitySources + if present := len(sources.Skills)+len(sources.Plugins)+len(sources.Directories) > 0; present != local.Capabilities { + return r, agentcapabilities.ErrInvalid + } + configured := *local + configured.Skills, configured.MCP, configured.CapabilityRoot, configured.WorkspaceRoot = nil, nil, "", sandboxWorkspace + r.LocalEnvironment = &configured + return r, nil +} + +// Prepare completes the Session's installation when Core sent no finalize, +// checks it against the frozen selection, and fills the request's Skills, +// MCP and capability root as sandbox paths. +func (o *environment) Prepare(ctx context.Context, r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { + if r.WorkspaceReadOnly || o.id == "" && r.LocalEnvironment == nil { + return r, nil + } + if o.id == "" || r.LocalEnvironment == nil || r.LocalEnvironment.ID != o.id || r.LocalEnvironment.CapabilitySources == nil || + r.LocalEnvironment.WorkspaceRoot != sandboxWorkspace || r.AgentStateKey != "agents-api-"+o.session { + return r, agentcapabilities.ErrInvalid + } + if err := o.acquire(ctx); err != nil { + return r, err + } + defer o.release() + w, err := o.attach(ctx) + if err != nil { + return r, err + } + defer o.done(w) + local := *r.LocalEnvironment + identity := o.identity() + name, body, err := agentcapabilities.Marker(identity, agentcapabilities.Directory) + if err != nil { + return r, err + } + initialization, err := w.directory(ctx, w.root, sandboxInitialization, true) + if err != nil { + return r, err + } + completed, err := w.marker(ctx, initialization, name, body) + if err != nil { + return r, err + } + values, err := o.toolEnvironment(ctx, w, initialization, local.ToolEnvironment, completed) + if err != nil { + return r, err + } + root, err := w.directory(ctx, w.root, agentcapabilities.Directory, !completed) + if err != nil { + return r, err + } + manifest, err := o.snapshot(ctx, w, root, *local.CapabilitySources, identity, completed) + if err != nil { + return r, err + } + if !completed { + if err := w.publish(ctx, initialization, name, 0o600, body, false); err != nil { + return r, err + } + } + local.Skills, local.MCP, local.CapabilityRoot = manifest.Skills, nil, agentcapabilities.Directory + for i := range local.Skills { + local.Skills[i].InstallationRoot = agentcapabilities.Directory + } + if len(manifest.MCP) != 0 { + if local.NetworkAccess != "enabled" { + return r, agentcapabilities.ErrInvalid + } + tokens, err := agentcapabilities.ResolveMCP(manifest.MCP, values) + if err != nil { + return r, err + } + for i, item := range manifest.MCP { + local.MCP = append(local.MCP, proto.EnvironmentMCP{InstallationRoot: agentcapabilities.Directory, WorkspaceRoot: sandboxWorkspace, + PackageRoot: item.PackageRoot, Server: item.Server, BearerToken: tokens[i]}) + } + } + o.tool = values + r.LocalEnvironment = &local + return r, nil +} + +// ApplyRuntimePreparation applies one runtime_prepare transfer to the +// sandbox. A setup step runs as the Process operation named transfer. +func (o *environment) ApplyRuntimePreparation(ctx context.Context, transfer uuid.UUID, input proto.RuntimePreparePayload, data []byte) error { + if o.id == "" || input.EnvironmentID != o.id || input.SessionID != o.session || !proto.ValidRuntimePrepareRequest(input) || input.Step != "begin" { + return agentcapabilities.ErrInvalid + } + if err := o.acquire(ctx); err != nil { + return err + } + defer o.release() + if o.uncertain { + return errUncertain + } + w, err := o.attach(ctx) + if err != nil { + return err + } + defer o.done(w) + identity := o.identity() + name, body, err := agentcapabilities.Marker(identity, agentcapabilities.Directory) + if err != nil { + return err + } + initialization, err := w.directory(ctx, w.root, sandboxInitialization, true) + if err != nil { + return failed(err) + } + if completed, err := w.marker(ctx, initialization, name, body); err != nil || completed { + return agentcapabilities.ErrInvalid + } + switch input.Action { + case "file": + if len(data) != input.SizeBytes { + return agentcapabilities.ErrInvalid + } + return o.installFile(ctx, w, input.File.Path, data) + case "initialize": + if len(data) != 0 { + return agentcapabilities.ErrInvalid + } + return o.initialize(ctx, w, initialization, sandboxwire.ID(transfer), *input.Initialization) + } + root, err := w.directory(ctx, w.root, agentcapabilities.Directory, true) + if err == nil { + switch input.Action { + case "skill": + var tree agentcapabilities.Tree + if tree, err = agentcapabilities.SkillTree(data, *input.Skill); err == nil { + err = w.stage(ctx, root, tree) + } + case "plugin": + var tree agentcapabilities.Tree + if tree, err = agentcapabilities.PluginTree(input.Slot, data, *input.Plugin); err == nil { + err = checkPluginCredentials(tree) + } + if err == nil { + err = w.stage(ctx, root, tree) + } + case "finalize": + if _, err = o.toolEnvironment(ctx, w, initialization, false, false); err == nil { + if _, err = o.snapshot(ctx, w, root, *input.Sources, identity, false); err == nil { + err = w.publish(ctx, initialization, name, 0o600, body, false) + } + } + default: + err = agentcapabilities.ErrInvalid + } + } + return failed(err) +} + +// failed returns err as the outcome of a step that failed: a mutation whose +// effect is unknown stays unknown, and anything else failed. +func failed(err error) error { + if err == nil || errors.Is(err, errUncertain) { + return err + } + return agentcapabilities.ErrInvalid +} + +// initializationFailed is failed for a setup step, which fails with an +// InitializationFailure. +func initializationFailed(err error) error { + if err == nil || errors.Is(err, errUncertain) { + return err + } + return &dispatch.InitializationFailure{} +} + +// checkPluginCredentials keeps a plugin's literal MCP headers out of the +// world: the agent host does not install a plugin that declares them. +func checkPluginCredentials(tree agentcapabilities.Tree) error { + bundle, err := agentplugin.Inspect(tree.Files) + if err != nil { + return agentcapabilities.ErrInvalid + } + for _, server := range bundle.MCP { + if len(server.HTTPHeaders) != 0 { + return agentcapabilities.ErrInvalid + } + } + return nil +} + +func (o *environment) installFile(ctx context.Context, w *world, target string, data []byte) error { + relative, ok := strings.CutPrefix(target, sandboxWorkspace+"/") + if !ok || !validPath(relative) || len(data) > proto.RuntimePrepareMaxBytes { + return agentcapabilities.ErrInvalid + } + workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) + if err != nil { + return initializationFailed(err) + } + parent, err := w.directory(ctx, workspace, path.Dir(relative), true) + if err != nil { + return initializationFailed(err) + } + // Initial files replace what the path holds, unlike Files create. + return initializationFailed(w.publish(ctx, parent, path.Base(relative), 0o600, data, true)) +} + +func (o *environment) initialize(ctx context.Context, w *world, initialization sandboxfs.NodeRef, id sandboxwire.ID, input proto.RuntimeInitialization) error { + raw, err := json.Marshal(input) + if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { + return agentcapabilities.ErrInvalid + } + program := map[string]string{"setup": "bash", "npm": "npm", "python": "python3"}[input.Action] + switch { + case input.Action == "configure": + return o.configure(ctx, w, initialization, input.Env) + case program == "": + return agentcapabilities.ErrInvalid + } + cwd := sandboxWorkspace + if input.CWD != "" && input.CWD != sandboxWorkspace { + relative, ok := strings.CutPrefix(input.CWD, sandboxWorkspace+"/") + if !ok || !validPath(relative) { + return agentcapabilities.ErrInvalid + } + cwd = input.CWD + } + values, err := w.toolEnvironment(ctx, initialization) + if err != nil { + return &dispatch.InitializationFailure{} + } + args, err := agentcapabilities.InitializationArgs(input.Action, input.Command, input.Packages, sandboxPackages+"/npm", sandboxPackages+"/python") + if err != nil { + return err + } + if input.Action != "setup" { + if _, err := w.directory(ctx, w.root, sandboxPackages, true); err != nil { + return initializationFailed(err) + } + } + env := maps.Clone(sandboxBaseline) + maps.Copy(env, values) + return o.run(ctx, id, program, args, env, cwd) +} + +// configure freezes the tool environment: values with the package +// directories ahead of the baseline PATH. +func (o *environment) configure(ctx context.Context, w *world, initialization sandboxfs.NodeRef, values map[string]string) error { + if !agentcapabilities.ValidToolEnvironment(values, false) { + return agentcapabilities.ErrInvalid + } + configured := agentcapabilities.ToolEnvironment(values, sandboxBaseline["PATH"], sandboxPackages+"/npm/bin", sandboxPackages+"/python/bin", + sandboxPackages+"/python", ":") + raw, err := json.Marshal(configured) + if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { + return agentcapabilities.ErrInvalid + } + if _, err := w.directory(ctx, w.root, sandboxPackages, true); err != nil { + return initializationFailed(err) + } + return initializationFailed(w.publish(ctx, initialization, toolEnvironmentName, 0o600, raw, false)) +} + +// toolEnvironment returns the frozen tool environment, freezing the default +// one when no step configured it and nothing requires it. +func (o *environment) toolEnvironment(ctx context.Context, w *world, initialization sandboxfs.NodeRef, required, completed bool) (map[string]string, error) { + values, err := w.toolEnvironment(ctx, initialization) + if !errors.Is(err, fs.ErrNotExist) { + return values, err + } + if required || completed { + return nil, errors.New("the prepared tool environment is unavailable") + } + if err := o.configure(ctx, w, initialization, nil); err != nil { + return nil, err + } + return w.toolEnvironment(ctx, initialization) +} + +// snapshot returns the installation's checked manifest, finalizing the +// installation first when it has none and is not complete. +func (o *environment) snapshot(ctx context.Context, w *world, root sandboxfs.NodeRef, input agentcapabilities.Input, identity agentcapabilities.Identity, completed bool) (agentcapabilities.Manifest, error) { + _, err := w.lookup(ctx, root, agentcapabilities.ManifestName) + switch { + case isErrno(err, sandboxfs.ErrnoNotFound) && !completed: + if err := w.finalize(ctx, root, input, identity); err != nil { + return agentcapabilities.Manifest{}, failed(err) + } + case err != nil: + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid + } + body, attr, err := w.readFile(ctx, root, agentcapabilities.ManifestName, agentcapabilities.MaxManifestBytes) + if err != nil || attr.Mode&0o222 != 0 { + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid + } + manifest, err := agentcapabilities.Check(body, func(name string) ([]agentbundle.File, error) { return w.readTreeAt(ctx, root, name, true) }) + if err != nil || agentcapabilities.ValidateSelection(manifest, input, identity) != nil { + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid + } + return manifest, nil +} + +// ListWorkspaceDirectory lists a directory of the sandbox workspace. +func (o *environment) ListWorkspaceDirectory(ctx context.Context, p string, limit int) (dispatch.WorkspaceDirectoryResult, error) { + result := dispatch.WorkspaceDirectoryResult{Entries: []dispatch.WorkspaceDirectoryEntry{}} + switch { + case o.id == "": + return result, dispatch.ErrWorkspaceReadUnavailable + case p != "" && !validPath(p) || limit < 1: + return result, dispatch.ErrWorkspaceReadInvalid + } + if err := o.acquire(ctx); err != nil { + return result, dispatch.ErrWorkspaceReadUnavailable + } + defer o.release() + w, err := o.attach(ctx) + if err != nil { + return result, dispatch.ErrWorkspaceReadUnavailable + } + defer o.done(w) + workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) + if err != nil { + return result, dispatch.ErrWorkspaceReadUnavailable + } + dir, err := w.directory(ctx, workspace, p, false) + var entries []sandboxfs.DirEntry + if err == nil { + entries, err = w.list(ctx, dir, limit) + } + switch { + case isErrno(err, sandboxfs.ErrnoPermissionDenied): + return result, fs.ErrPermission + case errors.Is(err, errNotDirectory): + return result, dispatch.ErrWorkspaceNotDirectory + case err != nil: + return result, dispatch.ErrWorkspaceReadUnavailable + } + result.Truncated = len(entries) > limit + entries = entries[:min(len(entries), limit)] + slices.SortFunc(entries, func(a, b sandboxfs.DirEntry) int { return bytes.Compare(a.Name, b.Name) }) + for _, e := range entries { + entry := dispatch.WorkspaceDirectoryEntry{Name: string(e.Name), Kind: "other"} + switch attr := e.Entry.Attr; attr.Mode & sandboxfs.ModeType { + case sandboxfs.ModeRegular: + size := int64(attr.Size) + entry.Kind, entry.SizeBytes = "file", &size + case sandboxfs.ModeDirectory: + entry.Kind = "directory" + case sandboxfs.ModeSymlink: + entry.Kind = "symlink" + } + result.Entries = append(result.Entries, entry) + } + return result, nil +} + +// WriteWorkspaceFile creates a file in the sandbox workspace with complete +// bytes, never replacing what the path holds. +func (o *environment) WriteWorkspaceFile(ctx context.Context, p string, data []byte) (dispatch.WorkspaceWriteResult, error) { + var result dispatch.WorkspaceWriteResult + switch { + case len(data) > proto.WorkspaceWriteMaxBytes || !validPath(p): + return result, dispatch.ErrWorkspaceWriteInvalid + case o.id == "": + return result, dispatch.ErrWorkspaceWriteUnavailable + } + ctx, cancel := context.WithTimeout(ctx, writeBound) + defer cancel() + if err := o.acquire(ctx); err != nil { + return result, dispatch.ErrWorkspaceWriteBusy + } + defer o.release() + if o.uncertain { + return result, dispatch.ErrWorkspaceWriteUncertain + } + w, err := o.attach(ctx) + if err != nil { + return result, dispatch.ErrWorkspaceWriteUnavailable + } + defer o.done(w) + workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) + if err != nil { + return result, dispatch.ErrWorkspaceWriteUnavailable + } + parent, err := w.directory(ctx, workspace, path.Dir(p), true) + if err == nil { + err = w.publish(ctx, parent, path.Base(p), 0o600, data, false) + } + switch { + case err == nil: + return dispatch.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil + case errors.Is(err, errUncertain): + return result, dispatch.ErrWorkspaceWriteUncertain + case errors.Is(err, fs.ErrExist): + if e, err := w.lookup(ctx, parent, path.Base(p)); err == nil && isType(e.Attr, sandboxfs.ModeDirectory) { + return result, dispatch.ErrWorkspaceWriteDirectory + } + return result, dispatch.ErrWorkspaceWriteUnsafe + } + return result, dispatch.ErrWorkspaceWriteRejected +} + +// 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. +func (o *environment) ExportOutputs(ctx context.Context, out io.Writer) error { + if o.id == "" { + return errors.New("the Session has no Environment") + } + if err := o.acquire(ctx); err != nil { + return err + } + defer o.release() + w, err := o.attach(ctx) + if err != nil { + return err + } + defer o.done(w) + workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) + if err != nil { + return err + } + archive := tar.NewWriter(out) + outputs, err := w.lookup(ctx, workspace, "outputs") + switch { + case isErrno(err, sandboxfs.ErrnoNotFound): + return archive.Close() + case err != nil: + return err + case !isType(outputs.Attr, sandboxfs.ModeDirectory): + return errors.New("workspace outputs is not a directory") + } + x := export{w: w, archive: archive} + if err := x.walk(ctx, outputs.Node, "outputs", 0); err != nil { + return err + } + return archive.Close() +} + +type export struct { + w *world + archive *tar.Writer + entries int + bytes int64 +} + +func (x *export) walk(ctx context.Context, dir sandboxfs.NodeRef, name string, depth int) error { + if depth > 64 || len(name) > 4096 { + return errors.New("workspace export exceeds traversal bound") + } + entries, err := x.w.sorted(ctx, dir, exportEntries) + if err != nil { + return err + } + 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 !validPath(child) { + return fs.ErrInvalid + } + switch e.Entry.Attr.Mode & sandboxfs.ModeType { + case sandboxfs.ModeSymlink: + case sandboxfs.ModeDirectory: + err = x.walk(ctx, e.Entry.Node, child, depth+1) + case sandboxfs.ModeRegular: + err = x.append(ctx, *e.Entry, child) + default: + err = errors.New("workspace output is not a regular file") + } + if err != nil { + return err + } + } + return nil +} + +func (x *export) append(ctx context.Context, e sandboxfs.Entry, name string) error { + h, err := x.w.open(ctx, e) + if err != nil { + return err + } + defer x.w.release(ctx, h) + target := sandboxfs.Target{Kind: sandboxfs.TargetHandle, Handle: h} + before, err := x.w.c.GetAttr(ctx, &sandboxfs.GetAttrRequest{Target: target}) + if err != nil { + return err + } + size := int64(before.Attr.Size) + if !isType(before.Attr, sandboxfs.ModeRegular) || 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: name, Typeflag: tar.TypeReg, Mode: 0o600, Size: size, Format: tar.FormatPAX}); err != nil { + return err + } + n, err := x.w.read(ctx, h, size, x.archive) + if err != nil { + return err + } + after, err := x.w.c.GetAttr(ctx, &sandboxfs.GetAttrRequest{Target: target}) + if err != nil { + return err + } + if n != size || after.Attr.Size != before.Attr.Size || after.Attr.Mtime != before.Attr.Mtime { + return errors.New("workspace output changed during export") + } + return nil +} + +// marker reports whether the completion marker name in dir holds body. +func (w *world) marker(ctx context.Context, dir sandboxfs.NodeRef, name string, body []byte) (bool, error) { + data, _, err := w.readFile(ctx, dir, name, markerBytes) + switch { + case isErrno(err, sandboxfs.ErrnoNotFound): + return false, nil + case err != nil || !bytes.Equal(data, body): + return false, agentcapabilities.ErrInvalid + } + return true, nil +} + +// toolEnvironment reads the frozen tool environment in dir; fs.ErrNotExist +// means no step froze it. +func (w *world) toolEnvironment(ctx context.Context, dir sandboxfs.NodeRef) (map[string]string, error) { + data, _, err := w.readFile(ctx, dir, toolEnvironmentName, toolEnvironmentBytes) + if isErrno(err, sandboxfs.ErrnoNotFound) { + return nil, fs.ErrNotExist + } + var values map[string]string + if err != nil || json.Unmarshal(data, &values) != nil || values == nil || !agentcapabilities.ValidToolEnvironment(values, false) { + return nil, errors.New("the prepared tool environment is unavailable") + } + return values, nil +} + +// stage writes tree into the unfinished installation at root. +func (w *world) stage(ctx context.Context, root sandboxfs.NodeRef, tree agentcapabilities.Tree) error { + for _, name := range []string{agentcapabilities.ManifestName, agentcapabilities.ManifestName + ".tmp"} { + if _, err := w.lookup(ctx, root, name); !isErrno(err, sandboxfs.ErrnoNotFound) { + return agentcapabilities.ErrInvalid + } + } + return w.writeTree(ctx, root, tree.Root, tree.Files) +} + +// readTreeAt reads the tree name below root, as readTree does. +func (w *world) readTreeAt(ctx context.Context, root sandboxfs.NodeRef, name string, immutable bool) ([]agentbundle.File, error) { + dir, err := w.directory(ctx, root, name, false) + if err != nil { + return nil, err + } + return w.readTree(ctx, dir, immutable) +} + +// finalize completes the staged installation at root as +// agentcapabilities.Finalize does, capturing each declared directory from +// the sandbox. +func (w *world) finalize(ctx context.Context, root sandboxfs.NodeRef, input agentcapabilities.Input, identity agentcapabilities.Identity) error { + list := func(name string) ([]fs.DirEntry, error) { + dir, err := w.directory(ctx, root, name, false) + if err != nil { + return nil, err + } + entries, err := w.list(ctx, dir, agentbundle.MaxFiles) + if err != nil || len(entries) > agentbundle.MaxFiles { + return nil, agentcapabilities.ErrInvalid + } + result := make([]fs.DirEntry, len(entries)) + for i, e := range entries { + result[i] = dirEntry{name: string(e.Name), dir: isType(e.Entry.Attr, sandboxfs.ModeDirectory)} + } + return result, nil + } + snapshot, err := agentcapabilities.NewSnapshot(input, identity, list, func(name string) ([]agentbundle.File, error) { return w.readTreeAt(ctx, root, name, true) }) + if err != nil { + return err + } + for _, source := range input.Directories { + if agentcapabilities.ValidateLocalDirectories([]string{source}) != nil || overlaps(source, agentcapabilities.Directory) { + return agentcapabilities.ErrInvalid + } + files, err := w.readTreeAt(ctx, w.root, source, false) + if err != nil { + return agentcapabilities.ErrInvalid + } + tree, err := snapshot.Capture(files) + if err != nil { + return err + } + if err := w.writeTree(ctx, root, tree.Root, tree.Files); err != nil { + return err + } + } + body, err := snapshot.Manifest() + if err != nil { + return err + } + temporary := agentcapabilities.ManifestName + ".tmp" + if _, err := w.create(ctx, root, temporary, 0o400, body); err != nil { + return err + } + if err := w.rename(ctx, root, temporary, root, agentcapabilities.ManifestName); err != nil { + return err + } + return w.syncDir(ctx, root) +} + +// overlaps reports whether one of the absolute paths a and b is the other or +// lies below it. +func overlaps(a, b string) bool { + below := func(parent, child string) bool { + return child == parent || strings.HasPrefix(child, strings.TrimSuffix(parent, "/")+"/") + } + return below(a, b) || below(b, a) +} + +// dirEntry is the name and kind of a listed entry, all that +// agentcapabilities.NewSnapshot reads. +type dirEntry struct { + name string + dir bool +} + +func (e dirEntry) Name() string { return e.name } +func (e dirEntry) IsDir() bool { return e.dir } +func (e dirEntry) Type() fs.FileMode { + if e.dir { + return fs.ModeDir + } + return 0 +} +func (e dirEntry) Info() (fs.FileInfo, error) { return nil, fs.ErrInvalid } diff --git a/apps/daemon/internal/agenthost/executor_linux.go b/apps/daemon/internal/agenthost/executor_linux.go index 956e8deaf..3b93b4a01 100644 --- a/apps/daemon/internal/agenthost/executor_linux.go +++ b/apps/daemon/internal/agenthost/executor_linux.go @@ -23,19 +23,22 @@ import ( type deps struct { dial dialFunc tasks listTasks + // stream wraps each stream an Environment owner opens; nil keeps it. + stream func(sandboxlink.Service, io.ReadWriteCloser) io.ReadWriteCloser } // Registry returns the kinds the agent host runs, for the daemon's dispatch // and heartbeat: each kind in Config.Harnesses that declares an agent.View, // in the Environments the agent host serves. Its Executor factory prepares an -// Executor of the Session that bind binds the request to, as the package -// documentation describes, and bind's error fails the preparation. The Router -// that runs it sets dispatch.Config.SessionEnvironments. -func (h *Host) Registry(bind func(proto.PromptRequestPayload) (Binding, Environment, error)) *agent.Registry { - d := deps{dial: relayDial(h.cfg), tasks: taskUIDs} - return registry(h.cfg.Harnesses, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { - return open(ctx, h.cfg, req, bind, d) - }) +// Executor of the Session whose Environment owner Host.Environments resolved +// and prepared, as the package documentation describes. The Router that runs +// it sets dispatch.Config.Environments to Host.Environments. +func (h *Host) Registry() *agent.Registry { + return registry(h.cfg.Harnesses, h.openExecutor) +} + +func (h *Host) openExecutor(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + return open(ctx, h.cfg, req, func(r proto.PromptRequestPayload) (Binding, Environment, error) { return h.executor(ctx, r) }, h.owners.d) } // registry registers each kind in harnesses that declares a view, with diff --git a/apps/daemon/internal/agenthost/host_linux.go b/apps/daemon/internal/agenthost/host_linux.go index 2ab93bc13..20fdf3cd9 100644 --- a/apps/daemon/internal/agenthost/host_linux.go +++ b/apps/daemon/internal/agenthost/host_linux.go @@ -27,7 +27,7 @@ func Open(cfg Config) (_ *Host, err error) { if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil { return nil, fmt.Errorf("%w: state directory: %w", ErrInvalidConfig, err) } - h := &Host{cfg: cfg} + h := &Host{cfg: cfg, owners: owners{d: deps{dial: relayDial(cfg), tasks: taskUIDs}}} defer func() { if err != nil { h.Close() diff --git a/apps/daemon/internal/agenthost/host_other.go b/apps/daemon/internal/agenthost/host_other.go index fe9f4aef9..2a59377f8 100644 --- a/apps/daemon/internal/agenthost/host_other.go +++ b/apps/daemon/internal/agenthost/host_other.go @@ -6,6 +6,7 @@ import ( "fmt" "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/sandboxwire" ) @@ -15,9 +16,14 @@ func Open(Config) (*Host, error) { return nil, fmt.Errorf("%w: open", ErrUnsupported) } +type owners struct{} + // Registry holds no kind: the agent host needs Linux. -func (*Host) Registry(func(proto.PromptRequestPayload) (Binding, Environment, error)) *agent.Registry { - return agent.NewRegistry() +func (*Host) Registry() *agent.Registry { return agent.NewRegistry() } + +// Environments serves no Session: the agent host needs Linux. +func (*Host) Environments(proto.AssignmentRef, proto.AssignmentBindPayload) dispatch.Environment { + return nil } // RemoveHome reports that the agent host needs Linux. diff --git a/apps/daemon/internal/agenthost/launch_linux.go b/apps/daemon/internal/agenthost/launch_linux.go index 08456e20c..8ca063197 100644 --- a/apps/daemon/internal/agenthost/launch_linux.go +++ b/apps/daemon/internal/agenthost/launch_linux.go @@ -227,12 +227,7 @@ func (s *session) processScope(ctx context.Context) (sandboxprocess.Scope, error if err != nil { return 0, err } - for _, scope := range []sandboxprocess.Scope{sandboxprocess.ScopeCgroupV2, sandboxprocess.ScopePOSIXSession} { - if slices.Contains(d.Capabilities.Scopes, scope) { - return scope, nil - } - } - return 0, fmt.Errorf("the Process service declares no scope among %v", d.Capabilities.Scopes) + return strongestScope(d.Capabilities) } // brokerFailed fails the Session with a process broker failure. diff --git a/apps/daemon/internal/agenthost/sessiondir_linux.go b/apps/daemon/internal/agenthost/sessiondir_linux.go index 5e104ec06..e827f8f4a 100644 --- a/apps/daemon/internal/agenthost/sessiondir_linux.go +++ b/apps/daemon/internal/agenthost/sessiondir_linux.go @@ -156,15 +156,19 @@ func sweep(stateDir string) error { return nil } -// RemoveHome removes the Session's directory with its home once the Session's -// processes have settled. It returns ErrSessionExists while an Executor of -// the Session has not closed, and an Executor of the Session does not open -// while RemoveHome runs. A Session without a directory has nothing to remove. +// RemoveHome drains and forgets the Session's Environment owner, then removes +// the Session's directory with its home once the Session's processes have +// settled. It returns ErrSessionExists while an Executor of the Session has +// not closed, and an Executor of the Session does not open while RemoveHome +// runs. A Session without a directory has nothing to remove. func (h *Host) RemoveHome(id sandboxwire.ID) error { if !claimSession(id) { return fmt.Errorf("%w: remove home", ErrSessionExists) } defer releaseSession(id) + if err := h.dropEnvironment(id); err != nil { + return fmt.Errorf("%w: remove home: %w", ErrTeardown, err) + } if err := os.RemoveAll(filepath.Join(sessionsDir(h.cfg.StateDir), id.String())); err != nil { return fmt.Errorf("%w: remove home: %w", ErrTeardown, err) } diff --git a/apps/daemon/internal/agenthost/setup_linux.go b/apps/daemon/internal/agenthost/setup_linux.go new file mode 100644 index 000000000..76c82c060 --- /dev/null +++ b/apps/daemon/internal/agenthost/setup_linux.go @@ -0,0 +1,133 @@ +//go:build linux + +package agenthost + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" + "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" +) + +// setupGraceMillis is the grace a cancelled setup step gets before KILL, as +// the guest's. +const setupGraceMillis = 250 + +// 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} { + if slices.Contains(caps.Scopes, scope) { + return scope, nil + } + } + return 0, fmt.Errorf("the Process service declares no scope among %v", caps.Scopes) +} + +// run runs one setup step in the sandbox as the Process operation id, in the +// strongest scope the service declares, discards its output and returns once +// it has settled: nil when it exited 0, an InitializationFailure when it +// could not start or exited 1 to 255, and an error otherwise. When ctx ends +// first, run cancels the step and waits closeBound for it to settle. A step +// that may run unobserved quarantines the owner. +func (o *environment) run(ctx context.Context, id sandboxwire.ID, program string, args []string, env map[string]string, cwd string) error { + rw, err := o.open(ctx, sandboxlink.ServiceProcess, sp.Version) + if err != nil { + return err + } + c := sp.NewClient(rw) + defer c.Close() + d, err := c.Describe(ctx) + if err != nil { + return err + } + scope, err := strongestScope(d.Capabilities) + if err != nil { + return err + } + spec := sp.ProcessSpec{Executable: []byte(program), Argv: [][]byte{[]byte(program)}, Cwd: []byte(cwd), Umask: 0o022, IOMode: sp.IOPipes, Scope: scope} + for _, arg := range args { + spec.Argv = append(spec.Argv, []byte(arg)) + } + for _, name := range slices.Sorted(maps.Keys(env)) { + spec.Env = append(spec.Env, sp.EnvVar{Name: []byte(name), Value: []byte(env[name])}) + } + start := sp.StartRequest{OperationRef: sp.OperationRef{ServerInstanceID: d.ServerInstanceID, OperationID: id}, Spec: spec} + if spec.Validate() != nil || d.Capabilities.CheckStart(spec) != nil || len(sp.Encode(start)) > int(d.Capabilities.MaxStartBytes) { + return agentcapabilities.ErrInvalid + } + op, _, err := c.Start(ctx, d.ServerInstanceID, id, spec) + if err != nil { + var f *sp.Failure + if errors.As(err, &f) && f.Effect == sandboxwire.EffectNone { + return err + } + return o.lose(err) + } + // late bounds the requests that settle the step: closeBound past ctx. + late, cancelLate := context.WithCancel(context.WithoutCancel(ctx)) + defer cancelLate() + defer context.AfterFunc(ctx, func() { time.AfterFunc(closeBound, cancelLate) })() + var exit *sp.ExitStatus + var exitLost, startFailed, outputClosed, scopeClosed bool + cancelled := ctx.Done() + for !startFailed && !((exit != nil || exitLost) && outputClosed && scopeClosed) { + select { + case ev, ok := <-op.Events(): + if !ok { + return o.lose(c.Err()) + } + switch ev := ev.(type) { + case sp.StartFailedEvent: + startFailed = true + case sp.ExitedEvent: + exit = &ev.Status + case sp.ObservationLostEvent: + exitLost = exitLost || ev.Observation == sp.ObservationExit + case sp.OutputClosedEvent: + outputClosed = true + case sp.ScopeClosedEvent: + scopeClosed = true + } + if err := op.Ack(late, ev.Header().Sequence); err != nil { + return o.lose(err) + } + case <-cancelled: + cancelled = nil + if err := op.Cancel(late, setupGraceMillis); err != nil { + return o.lose(err) + } + case <-late.Done(): + return o.lose(ctx.Err()) + } + } + if op.Release(late) != nil { + op.Detach() + } + switch { + case ctx.Err() != nil: + return fmt.Errorf("setup step cancelled: %w", ctx.Err()) + case startFailed: + return &dispatch.InitializationFailure{} + case exitLost || exit.Kind != sp.ExitCode: + return errors.New("the setup step's exit status is unknown") + case exit.Code != 0: + code := int(exit.Code) + return &dispatch.InitializationFailure{ExitCode: &code} + } + return nil +} + +// lose quarantines the owner for a setup step that may still run and whose +// outcome it cannot observe. +func (o *environment) lose(err error) error { + o.uncertain = true + return fmt.Errorf("%w: setup step: %w", errUncertain, err) +} diff --git a/apps/daemon/internal/agenthost/view_linux_test.go b/apps/daemon/internal/agenthost/view_linux_test.go index 202020759..a8cb478d7 100644 --- a/apps/daemon/internal/agenthost/view_linux_test.go +++ b/apps/daemon/internal/agenthost/view_linux_test.go @@ -126,14 +126,20 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Fatalf("Open: %v", err) } defer func() { h.Close() }() - workspace, err := os.MkdirTemp("/tmp", "agenthost-workspace-") - if err != nil { + // The sandbox's world is this container's /, which holds one Environment + // at a time: each Session in it starts from an empty initialization area. + workspace := sandboxWorkspace + if err := os.MkdirAll(workspace, 0o777); err != nil { t.Fatal(err) } - defer os.RemoveAll(workspace) if err := os.Chmod(workspace, 0o777); err != nil { t.Fatal(err) } + reset := func(t *testing.T) { + if err := os.RemoveAll(sandboxInitialization); err != nil { + t.Fatal(err) + } + } req := request("test", workspace, upstream.URL, upstreamKey) // Each subtest's daemon drives its Sessions over the relay. newRun := func(t *testing.T) *daemon { return newDaemon(t, cfg, deps{dial: relayDial(cfg), tasks: taskUIDs}) } @@ -145,6 +151,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Run("one Session", func(t *testing.T) { d, b := newRun(t), sb.bind(cfg.RuntimeID, 2*time.Second) + reset(t) r := d.turn(t, b, req, "check") for _, name := range harnessChecks { if msg, ok := r.Checks[name]; !ok || msg != "" { @@ -195,7 +202,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Run("environment none runs in an empty root", func(t *testing.T) { var dials atomic.Int32 - d, b := newDaemon(t, cfg, deps{dial: countingDial(&dials), tasks: taskUIDs}), Binding{SessionID: sandboxwire.NewID()} + d, b := newDaemon(t, cfg, deps{dial: countingDial(&dials), tasks: taskUIDs}), newBinding(sandboxlink.ResourceRef{}) none := request("test", "", upstream.URL, upstreamKey) none.LocalEnvironment, none.DisableExecutionEnvironment = nil, true r := d.turn(t, b, none, "none") @@ -225,6 +232,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Run("a stdio MCP server runs its frozen command under its alias", func(t *testing.T) { d, b := newRun(t), sb.bind(cfg.RuntimeID, time.Minute) + reset(t) pkg := filepath.Join(workspace, "pkg") if err := os.Mkdir(pkg, 0o755); err != nil { t.Fatal(err) @@ -247,6 +255,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Run("a command runs in the sandbox through the shim", func(t *testing.T) { d, b := newRun(t), sb.bind(cfg.RuntimeID, time.Minute) + reset(t) if r := d.turn(t, b, req, "shim"); r.Stdout != "42\n" || r.Code != 3 { t.Errorf("the forwarded command printed %q and exited %d, want 42 and 3; stderr %s", r.Stdout, r.Code, r.Stderr) } @@ -258,6 +267,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Run("two Sessions run at once", func(t *testing.T) { d, waiting, other := newRun(t), sb.bind(cfg.RuntimeID, time.Minute), sb.bind(cfg.RuntimeID, time.Minute) + reset(t) run := d.start(t, waiting, req, "wait") defer os.Remove(beat) until(t, "the Harness to run", beating) @@ -271,6 +281,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { if err := p.Wait(); err != nil || string(out) != "{\"touch\":\"\"}\n" { t.Errorf("the spawned process printed %q and ended with %v", out, err) } + reset(t) if r := d.turn(t, other, req, "shim"); r.Stdout != "42\n" || r.Code != 3 { t.Errorf("the other Session's command printed %q and exited %d; stderr %s", r.Stdout, r.Code, r.Stderr) } @@ -288,6 +299,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Run("a lost relay fails the Session", func(t *testing.T) { d, b := newRun(t), sb.bind(cfg.RuntimeID, time.Minute) + reset(t) run := d.start(t, b, req, "wait") defer os.Remove(beat) until(t, "the Harness to run", beating) @@ -310,6 +322,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Run("a restarted sandbox service fails the Session", func(t *testing.T) { d, b := newRun(t), sb.bind(cfg.RuntimeID, time.Minute) + reset(t) run := d.start(t, b, req, "wait") defer os.Remove(beat) until(t, "the Harness to write to the world", beating) @@ -329,6 +342,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Run("a home survives a restart", func(t *testing.T) { d, b := newRun(t), sb.bind(cfg.RuntimeID, time.Minute) + reset(t) if r := d.turn(t, b, req, "check"); r.Checks["home"] != "" { t.Fatalf("home: %q", r.Checks["home"]) } diff --git a/apps/daemon/internal/agenthost/world_linux.go b/apps/daemon/internal/agenthost/world_linux.go new file mode 100644 index 000000000..31579aefa --- /dev/null +++ b/apps/daemon/internal/agenthost/world_linux.go @@ -0,0 +1,494 @@ +//go:build linux + +package agenthost + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "slices" + "strings" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" + "github.com/google/uuid" +) + +var ( + // errUncertain is a mutation of the sandbox whose outcome is unknown, or + // one refused because an earlier one's is: the owner never replays it. + errUncertain = errors.New("agenthost: an Environment mutation's outcome is unknown") + // errNotDirectory is a path component that is missing, not a directory or + // a symbolic link, which is never followed. + errNotDirectory = errors.New("agenthost: not a directory") + errTooLarge = errors.New("agenthost: file exceeds its bound") +) + +// world is the owner's File client on the sandbox's world export. The owner +// runs one operation on it at a time and forgets, as each ends, every node +// reference it acquired except the root's. +type world struct { + c *sandboxfs.Client + caps sandboxfs.Capabilities + root sandboxfs.NodeRef + handles *sandboxfs.HandleIDs + refs map[sandboxfs.NodeRef]uint64 + // uncertain is the owner's quarantine. A mutation whose effect is + // possible sets it, and no mutation is sent while it holds. + uncertain *bool +} + +// attachWorld describes the File service on stream, checks that it serves +// what the owner needs, and attaches the world export. +func attachWorld(ctx context.Context, stream io.ReadWriteCloser, uncertain *bool) (*world, error) { + w := &world{c: sandboxfs.NewClient(stream), handles: new(sandboxfs.HandleIDs), refs: map[sandboxfs.NodeRef]uint64{}, uncertain: uncertain} + d, err := w.c.Describe(ctx, &sandboxfs.DescribeRequest{}) + if err != nil { + w.c.Close() + return nil, err + } + caps := d.Capabilities + if caps.ReadOnly || !caps.HardLinks || !caps.AtomicRename || !caps.DirectoryFsync || !caps.ReadDirPlus || caps.MaxReadBytes == 0 || + caps.MaxWriteBytes == 0 || caps.MaxReadDirBytes == 0 || !slices.Contains(d.Exports, sandboxfs.WorldExport) { + w.c.Close() + return nil, errors.New("agenthost: the File service does not serve a writable world") + } + w.caps = caps + a, err := w.c.Attach(ctx, &sandboxfs.AttachRequest{Export: sandboxfs.WorldExport}) + if err != nil { + w.c.Close() + return nil, err + } + w.root = a.Root.Node + return w, nil +} + +// ended reports whether the stream has failed or closed. +func (w *world) ended() bool { + select { + case <-w.c.Done(): + return true + default: + return false + } +} + +func (w *world) track(e sandboxfs.Entry) sandboxfs.Entry { + w.refs[e.Node]++ + return e +} + +// forget drops the references the operation acquired. +func (w *world) forget(ctx context.Context) error { + var batch []sandboxfs.ForgetEntry + for node, count := range w.refs { + batch = append(batch, sandboxfs.ForgetEntry{Node: node, Count: count}) + } + clear(w.refs) + for len(batch) > 0 { + n := min(len(batch), 4096) + if _, err := w.c.Forget(ctx, &sandboxfs.ForgetRequest{Entries: batch[:n]}); err != nil { + return err + } + batch = batch[n:] + } + return nil +} + +// mutation returns err, the failure of a request that may change the world. +// A failure whose effect is possible quarantines the owner. +func (w *world) mutation(err error) error { + var f *sandboxfs.Failure + if err != nil && (!errors.As(err, &f) || f.Effect != sandboxwire.EffectNone) { + *w.uncertain = true + return fmt.Errorf("%w: %w", errUncertain, err) + } + return err +} + +// mutable refuses a mutation while the owner is quarantined. +func (w *world) mutable() error { + if *w.uncertain { + return errUncertain + } + return nil +} + +func isErrno(err error, errno sandboxfs.Errno) bool { + var f *sandboxfs.Failure + return errors.As(err, &f) && f.Code == sandboxfs.CodeErrno && f.Errno == errno +} + +func isType(a sandboxfs.Attr, t uint32) bool { return a.Mode&sandboxfs.ModeType == t } + +// validPath reports whether p is a relative path as the guest accepts a +// workspace or installation path: plain names 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") +} + +// components splits a path below a directory into its names; "", "." and +// "/" name the directory itself. +func components(p string) ([]string, error) { + if p = strings.Trim(p, "/"); p == "" || p == "." { + return nil, nil + } + if !validPath(p) { + return nil, fs.ErrInvalid + } + return strings.Split(p, "/"), nil +} + +func (w *world) lookup(ctx context.Context, dir sandboxfs.NodeRef, name string) (sandboxfs.Entry, error) { + r, err := w.c.Lookup(ctx, &sandboxfs.LookupRequest{Parent: dir, Name: []byte(name)}) + if err != nil { + return sandboxfs.Entry{}, err + } + return w.track(r.Entry), nil +} + +// directory resolves p below dir: each component must be a directory, and +// none a symbolic link. With create, it makes a missing one with mode 0700. +func (w *world) directory(ctx context.Context, dir sandboxfs.NodeRef, p string, create bool) (sandboxfs.NodeRef, error) { + names, err := components(p) + if err != nil { + return sandboxfs.NodeRef{}, err + } + for _, name := range names { + e, err := w.lookup(ctx, dir, name) + if create && isErrno(err, sandboxfs.ErrnoNotFound) { + if e, err = w.mkdir(ctx, dir, name); isErrno(err, sandboxfs.ErrnoExists) { + e, err = w.lookup(ctx, dir, name) + } + } + switch { + case isErrno(err, sandboxfs.ErrnoNotFound) || isErrno(err, sandboxfs.ErrnoNotDirectory): + return sandboxfs.NodeRef{}, errNotDirectory + case err != nil: + return sandboxfs.NodeRef{}, err + case !isType(e.Attr, sandboxfs.ModeDirectory): + return sandboxfs.NodeRef{}, errNotDirectory + } + dir = e.Node + } + return dir, nil +} + +func (w *world) mkdir(ctx context.Context, dir sandboxfs.NodeRef, name string) (sandboxfs.Entry, error) { + if err := w.mutable(); err != nil { + return sandboxfs.Entry{}, err + } + r, err := w.c.Mkdir(ctx, &sandboxfs.MkdirRequest{Parent: dir, Name: []byte(name), Mode: 0o700}) + if err != nil { + return sandboxfs.Entry{}, w.mutation(err) + } + return w.track(r.Entry), nil +} + +// open opens the regular file e for reading and returns its handle. +func (w *world) open(ctx context.Context, e sandboxfs.Entry) (sandboxfs.HandleID, error) { + if !isType(e.Attr, sandboxfs.ModeRegular) { + return 0, fs.ErrInvalid + } + h := w.handles.Next() + if _, err := w.c.Open(ctx, &sandboxfs.OpenRequest{Handle: h, Node: e.Node, Access: sandboxfs.AccessRead, Flags: sandboxfs.OpenNoFollow}); err != nil { + return 0, err + } + return h, nil +} + +// read reads the open file h from its start to its end, at most limit +// bytes, into out. +func (w *world) read(ctx context.Context, h sandboxfs.HandleID, limit int64, out io.Writer) (int64, error) { + var n int64 + for { + r, err := w.c.Read(ctx, &sandboxfs.ReadRequest{Handle: h, Offset: uint64(n), Size: w.caps.MaxReadBytes}) + if err != nil { + return n, err + } + if n += int64(len(r.Data)); n > limit { + return n, errTooLarge + } + if _, err := out.Write(r.Data); err != nil { + return n, err + } + if len(r.Data) < int(w.caps.MaxReadBytes) { + return n, nil + } + } +} + +func (w *world) release(ctx context.Context, h sandboxfs.HandleID) { + w.c.Release(ctx, &sandboxfs.ReleaseRequest{Handle: h}) +} + +// readFile reads the regular file name in dir, at most limit bytes. +func (w *world) readFile(ctx context.Context, dir sandboxfs.NodeRef, name string, limit int64) ([]byte, sandboxfs.Attr, error) { + e, err := w.lookup(ctx, dir, name) + if err != nil { + return nil, sandboxfs.Attr{}, err + } + if !isType(e.Attr, sandboxfs.ModeRegular) || int64(e.Attr.Size) > limit { + return nil, e.Attr, fs.ErrInvalid + } + h, err := w.open(ctx, e) + if err != nil { + return nil, e.Attr, err + } + defer w.release(ctx, h) + var b strings.Builder + n, err := w.read(ctx, h, limit, &b) + if err == nil && uint64(n) != e.Attr.Size { + err = fs.ErrInvalid + } + return []byte(b.String()), e.Attr, err +} + +// create makes name in dir, exclusively, with mode, writes data to it and +// syncs it. +func (w *world) create(ctx context.Context, dir sandboxfs.NodeRef, name string, mode uint32, data []byte) (sandboxfs.Entry, error) { + if err := w.mutable(); err != nil { + return sandboxfs.Entry{}, err + } + h := w.handles.Next() + r, err := w.c.Create(ctx, &sandboxfs.CreateRequest{Handle: h, Parent: dir, Name: []byte(name), Mode: mode, Access: sandboxfs.AccessWrite, Exclusive: true}) + if err != nil { + return sandboxfs.Entry{}, w.mutation(err) + } + e := w.track(r.Entry) + defer w.release(ctx, h) + for off := 0; off < len(data); { + chunk := data[off:min(len(data), off+int(w.caps.MaxWriteBytes))] + r, err := w.c.Write(ctx, &sandboxfs.WriteRequest{Handle: h, Offset: uint64(off), Data: chunk}) + if err == nil && r.Failure != nil { + err = r.Failure + } + if err == nil && r.Written == 0 { + err = io.ErrShortWrite + } + if err != nil { + return e, w.mutation(err) + } + off += int(r.Written) + } + if _, err := w.c.Fsync(ctx, &sandboxfs.FsyncRequest{Handle: h}); err != nil { + return e, w.mutation(err) + } + return e, nil +} + +func (w *world) link(ctx context.Context, node, dir sandboxfs.NodeRef, name string) error { + if err := w.mutable(); err != nil { + return err + } + r, err := w.c.Link(ctx, &sandboxfs.LinkRequest{Node: node, NewParent: dir, NewName: []byte(name)}) + if err != nil { + return w.mutation(err) + } + w.track(r.Entry) + return nil +} + +func (w *world) rename(ctx context.Context, dir sandboxfs.NodeRef, name string, newDir sandboxfs.NodeRef, newName string) error { + if err := w.mutable(); err != nil { + return err + } + _, err := w.c.Rename(ctx, &sandboxfs.RenameRequest{Parent: dir, Name: []byte(name), NewParent: newDir, NewName: []byte(newName), Mode: sandboxfs.RenameReplace}) + return w.mutation(err) +} + +// remove unlinks a temporary file of the owner's. It never quarantines: the +// file was never published. +func (w *world) remove(ctx context.Context, dir sandboxfs.NodeRef, name string) { + w.c.Unlink(ctx, &sandboxfs.UnlinkRequest{Parent: dir, Name: []byte(name)}) +} + +func (w *world) syncDir(ctx context.Context, dir sandboxfs.NodeRef) error { + h := w.handles.Next() + if _, err := w.c.OpenDir(ctx, &sandboxfs.OpenDirRequest{Handle: h, Node: dir}); err != nil { + return err + } + defer w.c.ReleaseDir(ctx, &sandboxfs.ReleaseDirRequest{Handle: h}) + _, err := w.c.Fsync(ctx, &sandboxfs.FsyncRequest{Handle: h}) + return w.mutation(err) +} + +// publish writes data as name in dir with mode through a temporary file, so +// name holds complete bytes or nothing. With replace it replaces an existing +// name; without, an existing name fails with fs.ErrExist. +func (w *world) publish(ctx context.Context, dir sandboxfs.NodeRef, name string, mode uint32, data []byte, replace bool) error { + temporary := ".oac-" + uuid.NewString() + e, err := w.create(ctx, dir, temporary, mode, data) + switch { + case err != nil: + case replace: + err = w.rename(ctx, dir, temporary, dir, name) + default: + // The temporary name stays after a link, and after a link that failed. + if err = w.link(ctx, e.Node, dir, name); !errors.Is(err, errUncertain) { + w.remove(ctx, dir, temporary) + if isErrno(err, sandboxfs.ErrnoExists) { + return fs.ErrExist + } + if err == nil { + return w.syncDir(ctx, dir) + } + return err + } + } + if err != nil { + if !errors.Is(err, errUncertain) { + w.remove(ctx, dir, temporary) + } + return err + } + return w.syncDir(ctx, dir) +} + +// list reads dir's entries with their attributes, in the directory's order, +// until it has more than limit or the directory ends. +func (w *world) list(ctx context.Context, dir sandboxfs.NodeRef, limit int) ([]sandboxfs.DirEntry, error) { + h := w.handles.Next() + if _, err := w.c.OpenDir(ctx, &sandboxfs.OpenDirRequest{Handle: h, Node: dir}); err != nil { + return nil, err + } + defer w.c.ReleaseDir(ctx, &sandboxfs.ReleaseDirRequest{Handle: h}) + var entries []sandboxfs.DirEntry + var cookie uint64 + for len(entries) <= limit { + r, err := w.c.ReadDir(ctx, &sandboxfs.ReadDirRequest{Handle: h, Cookie: cookie, Limit: w.caps.MaxReadDirBytes, WithAttrs: true}) + if err != nil { + return nil, err + } + for _, e := range r.Entries { + if e.Entry == nil { + return nil, fs.ErrInvalid + } + w.track(*e.Entry) + entries = append(entries, e) + cookie = e.Cookie + } + if r.End || len(r.Entries) == 0 { + break + } + } + return entries, nil +} + +// sorted lists dir as list does and sorts the entries by name. +func (w *world) sorted(ctx context.Context, dir sandboxfs.NodeRef, limit int) ([]sandboxfs.DirEntry, error) { + entries, err := w.list(ctx, dir, limit) + slices.SortFunc(entries, func(a, b sandboxfs.DirEntry) int { return strings.Compare(string(a.Name), string(b.Name)) }) + return entries, err +} + +// readTree reads the tree at dir as agentcapabilities.ReadTree reads a local +// one: at most agentbundle.MaxFiles entries and agentbundle.MaxExpandedBytes, +// regular files and directories only, files without write bits when +// immutable, in fs.WalkDir's order. +func (w *world) readTree(ctx context.Context, dir sandboxfs.NodeRef, immutable bool) ([]agentbundle.File, error) { + t := treeReader{w: w, immutable: immutable, entries: 1} + if err := t.walk(ctx, dir, ""); err != nil { + return nil, err + } + return t.files, nil +} + +type treeReader struct { + w *world + immutable bool + files []agentbundle.File + entries int + total int +} + +func (t *treeReader) walk(ctx context.Context, dir sandboxfs.NodeRef, prefix string) error { + entries, err := t.w.sorted(ctx, dir, agentbundle.MaxFiles) + if err != nil { + return err + } + for _, e := range entries { + if t.entries++; t.entries > agentbundle.MaxFiles { + return fs.ErrInvalid + } + name, attr := prefix+string(e.Name), e.Entry.Attr + switch { + case isType(attr, sandboxfs.ModeDirectory): + if err := t.walk(ctx, e.Entry.Node, name+"/"); err != nil { + return err + } + case !isType(attr, sandboxfs.ModeRegular) || t.immutable && attr.Mode&0o222 != 0 || attr.Size > uint64(agentbundle.MaxExpandedBytes-t.total): + return fs.ErrInvalid + default: + h, err := t.w.open(ctx, *e.Entry) + if err != nil { + return err + } + var b strings.Builder + n, err := t.w.read(ctx, h, int64(agentbundle.MaxExpandedBytes-t.total), &b) + t.w.release(ctx, h) + if err != nil || uint64(n) != attr.Size { + return fs.ErrInvalid + } + t.total += int(n) + t.files = append(t.files, agentbundle.File{Path: name, Data: []byte(b.String()), Executable: attr.Mode&0o111 != 0}) + } + } + return nil +} + +// writeTree creates name below dir, which must not exist, with files: its +// directories 0700, and each file 0400, or 0500 when executable. Every +// directory it made is synced. +func (w *world) writeTree(ctx context.Context, dir sandboxfs.NodeRef, name string, files []agentbundle.File) error { + names, err := components(name) + if err != nil || len(names) == 0 { + return fs.ErrInvalid + } + parent, err := w.directory(ctx, dir, strings.Join(names[:len(names)-1], "/"), true) + if err != nil { + return err + } + e, err := w.mkdir(ctx, parent, names[len(names)-1]) + if err != nil { + return err + } + dirs := map[string]sandboxfs.NodeRef{"": e.Node} + order := []string{""} + for _, file := range files { + parts, err := components(file.Path) + if err != nil || len(parts) == 0 { + return fs.ErrInvalid + } + at := "" + for _, part := range parts[:len(parts)-1] { + next := strings.TrimPrefix(at+"/"+part, "/") + if _, ok := dirs[next]; !ok { + d, err := w.mkdir(ctx, dirs[at], part) + if err != nil { + return err + } + dirs[next] = d.Node + order = append(order, next) + } + at = next + } + mode := uint32(0o400) + if file.Executable { + mode = 0o500 + } + if _, err := w.create(ctx, dirs[at], parts[len(parts)-1], mode, file.Data); err != nil { + return err + } + } + for _, d := range order { + if err := w.syncDir(ctx, dirs[d]); err != nil { + return err + } + } + return w.syncDir(ctx, parent) +} diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index 16ddd3abe..98865580a 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -39,6 +39,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview/sessionviewtest" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" @@ -118,16 +119,19 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { } } -// qualify runs the kind's Turns through dispatch. The first writes a file, -// runs a failing command and reports what it printed and its exit status, -// then the test checks all three; only the sandbox's tool environment holds -// the value and the status. A view that declares function tools runs a second -// Turn in a new Executor, which resumes the Session's native history, and -// calls a function there. A view that declares tool search runs a Turn in -// another Session that finds the function, deferred, with tool search. A view -// that declares environment none answers a Turn in a Session without an -// Environment, and its native state names the work directory. Every view -// calls a tool of a stdio MCP server that runs in the sandbox. +// qualify runs the kind's Turns through dispatch. runtime_prepare first +// freezes the Session's tool environment and runs a setup step that writes a +// file with a value only the tool environment holds. The first Turn writes a +// file, runs a failing command and reports what it printed and its exit +// status, then the test checks all of them. A view that declares function +// tools runs a second Turn in a new Executor, which resumes the Session's +// native history, and calls a function there. A view that declares tool +// search runs a Turn in a new Executor without native history that finds the +// function, deferred, with tool search. A view that declares environment none +// answers a Turn in a Session without an Environment, and its native state +// names the work directory. Every view calls a tool of a stdio MCP server +// that runs in the sandbox. Each Executor after the first reopens the +// Environment that runtime_prepare prepared. func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, caps proto.AgentKindCapabilities, model proto.PromptRequestPayload) { name := "qualify-" + kind + ".txt" value, content := strings.ToLower(rand.Text()), "qualified "+strings.ToLower(rand.Text()[:12]) @@ -139,18 +143,24 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, " It prints a value and exits with a non-zero status; that is expected.\n" + "3. Answer with exactly one line: VALUE= EXIT=" - env := agenthost.Environment{ - Sandbox: map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/home/runtime", "LANG": "C.UTF-8"}, - Tool: map[string]string{"QUALIFY_VALUE": value, "QUALIFY_EXIT": fmt.Sprint(exit)}, - } configuration := proto.PromptRequestPayload{AgentKind: kind, DisableSubagents: true, Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, - LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}} + LocalEnvironment: &proto.LocalEnvironment{ID: uuid.UUID(sb.resource.EnvironmentID).String(), WorkspaceDirectory: workspace, NetworkAccess: "enabled", + CapabilitySources: &agentcapabilities.Input{}}} if caps.FunctionTools.IsSupported() { configuration.FunctionTools = []proto.FunctionTool{lookupTicket} } k := newTicket(t) - s := sb.session(h, cfg, env, configuration) + // The sandbox holds one Environment Session's preparation at a time. + sb.reset(t, cfg) + s := sb.session(h, cfg, configuration) + setup := "setup-" + kind + ".txt" + s.prepare(t, + proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"QUALIFY_VALUE": value, "QUALIFY_EXIT": fmt.Sprint(exit)}}, + proto.RuntimeInitialization{Action: "setup", Command: `printf '%s' "$QUALIFY_VALUE" > ` + setup}) + if got := sb.read(t, cfg, workspace+"/"+setup); got != value { + t.Errorf("the setup step wrote %q, want the tool environment's %q", got, value) + } done, answer, _ := s.turn(t, "qualify", prompt, k) if !strings.Contains(answer, "VALUE="+value) || !strings.Contains(answer, fmt.Sprintf("EXIT=%d", exit)) { t.Errorf("the answer %q does not report VALUE=%s EXIT=%d", answer, value, exit) @@ -165,8 +175,9 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, if native == "" { t.Fatal("the first Turn reported no native session to resume") } - s.configuration.AgentSessionID = native - done, answer, calls := s.turn(t, "resumed-function", k.prompt("Call the lookup_ticket function"), k) + resumed := s.with(configuration) + resumed.configuration.AgentSessionID = native + done, answer, calls := resumed.turn(t, "resumed-function", k.prompt("Call the lookup_ticket function"), k) if resumed, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string); resumed != native { t.Errorf("the resumed Turn reported the native session %q, want %q", resumed, native) } @@ -176,14 +187,13 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, deferred := lookupTicket deferred.DeferLoading = true configuration.ToolSearch, configuration.FunctionTools = true, []proto.FunctionTool{deferred} - search := sb.session(h, cfg, env, configuration) - _, answer, calls := search.turn(t, "tool-search", k.prompt("Search your tools for the function that looks up support tickets"), k) + _, answer, calls := s.with(configuration).turn(t, "tool-search", k.prompt("Search your tools for the function that looks up support tickets"), k) k.check(t, answer, calls) } if caps.EnvironmentNone.IsSupported() { none := configuration none.LocalEnvironment, none.DisableExecutionEnvironment, none.FunctionTools, none.ToolSearch = nil, true, nil, false - s := sb.session(h, cfg, agenthost.Environment{}, none) + s := sb.session(h, cfg, none) _, answer, _ := s.turn(t, "environment-none", "What is 17 times 23? Answer with exactly one line: PRODUCT=", k) if !strings.Contains(answer, "PRODUCT=391") { t.Errorf("the answer %q does not report PRODUCT=391", answer) @@ -194,7 +204,7 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, code := strings.ToLower(rand.Text()[:12]) stdio := configuration stdio.FunctionTools, stdio.ToolSearch = nil, false - s := sb.session(h, cfg, env, stdio) + s := s.with(stdio) s.mcp = []proto.EnvironmentMCP{{InstallationRoot: workspace, Server: agentplugin.MCPServer{Name: "qualify", Type: "stdio", Command: "python3", Args: []string{"-c", mcpServer, code}}}} _, answer, _ := s.turn(t, "stdio-mcp", "Call the reveal_code tool of the qualify MCP server once.\nAnswer with exactly one line: CODE=", k) if !strings.Contains(answer, "CODE="+code) { @@ -266,13 +276,14 @@ func (k ticket) check(t *testing.T, answer string, calls []proto.FunctionCallPay } } -// session is a Session bound to the sandbox. Each Turn runs in a new -// Executor through a new dispatch Router. +// session is a Session bound to the sandbox, in the sandbox's Environment +// unless its configuration disables it. Each Turn runs in a new Executor +// through a new dispatch Router, which binds the Session to the Host's +// Environment owner. type session struct { h *agenthost.Host cfg agenthost.Config binding agenthost.Binding - env agenthost.Environment id string configuration proto.PromptRequestPayload // mcp is the installed MCP that the Environment's preparation resolves @@ -280,36 +291,85 @@ type session struct { mcp []proto.EnvironmentMCP } -func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, env agenthost.Environment, configuration proto.PromptRequestPayload) *session { - s := &session{h: h, cfg: cfg, binding: sb.binding(), env: env, id: uuid.NewString(), configuration: configuration} +func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, configuration proto.PromptRequestPayload) *session { + s := &session{h: h, cfg: cfg, binding: sb.binding(), configuration: configuration} + s.id = uuid.UUID(s.binding.SessionID).String() s.configuration.AgentStateKey = "agents-api-" + s.id sb.grant(s.binding, cfg.RuntimeID) return s } -// turn binds the Session's assignment, prepares an Executor of the Session, -// runs prompt as its Turn run, answers each function call with k's result, -// and retires the Executor with the Router's Shutdown. It returns the Turn's Done, its answer and its -// function calls. -func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePayload, string, []proto.FunctionCallPayload) { +// with is the Session with configuration for its next Executors. +func (s *session) with(configuration proto.PromptRequestPayload) *session { + next := *s + next.configuration, next.mcp = configuration, nil + next.configuration.AgentStateKey = s.configuration.AgentStateKey + return &next +} + +// router returns a new Router that serves the Host's kinds, with the +// Session bound to it. +func (s *session) router(t *testing.T, out sender, id string) (*dispatch.Router, proto.AssignmentRef) { t.Helper() - out := make(sender, 256) - reg := s.h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { - return s.binding, s.env, nil - }) + reg := s.h.Registry() if s.mcp != nil { reg = withMCP(t, reg, s.mcp) } - router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: s.cfg.Log, Registry: reg}) + router, err := dispatch.New(dispatch.Config{Sender: out, Environments: s.h.Environments, Log: s.cfg.Log, Registry: reg}) if err != nil { t.Fatal(err) } - ref := proto.AssignmentRef{SessionID: s.id, AssignmentID: s.binding.AssignmentID.String(), Epoch: s.binding.AssignmentEpoch} - handle(t, router, ref, proto.TypeAssignmentBind, "bind-"+run, proto.AssignmentBindPayload{EnvironmentID: s.configuration.EnvironmentID()}) + ref := proto.AssignmentRef{SessionID: s.id, AssignmentID: uuid.UUID(s.binding.AssignmentID).String(), Epoch: s.binding.AssignmentEpoch} + bind := proto.AssignmentBindPayload{EnvironmentID: s.configuration.EnvironmentID()} + if bind.EnvironmentID != "" { + r := s.binding.Resource + bind.Resource = &sandboxbootstrap.Resource{TenantID: uuid.UUID(r.TenantID).String(), EnvironmentID: bind.EnvironmentID, Kind: "allocation", + ID: uuid.UUID(r.ID).String(), Generation: r.Generation} + bind.AttachGrant = s.binding.AttachGrant + } + handle(t, router, ref, proto.TypeAssignmentBind, id, bind) var bound proto.AssignmentStatusPayload - if err := out.next(t, "bind-"+run, time.After(turnLimit)).DecodePayload(&bound); err != nil || bound.State != proto.AssignmentBound { + if err := out.next(t, id, time.After(turnLimit)).DecodePayload(&bound); err != nil || bound.State != proto.AssignmentBound { t.Fatalf("assignment_bind: %+v %v", bound, err) } + return router, ref +} + +// prepare applies each step to the Session's Environment with +// runtime_prepare, then the empty selection's finalize. +func (s *session) prepare(t *testing.T, steps ...proto.RuntimeInitialization) { + t.Helper() + out := make(sender, 64) + router, ref := s.router(t, out, uuid.NewString()) + defer router.Shutdown(context.Background()) + transfer := func(p proto.RuntimePreparePayload) { + p.Step, p.EnvironmentID, p.SessionID = "begin", s.configuration.EnvironmentID(), s.id + id := uuid.NewString() + for i, step := range []proto.RuntimePreparePayload{p, {Step: "commit"}} { + handle(t, router, ref, proto.TypeRuntimePrepare, id, step) + var result proto.RuntimePrepareResultPayload + if err := out.next(t, id, time.After(turnLimit)).DecodePayload(&result); err != nil { + t.Fatal(err) + } + if want := []string{"ready", "completed"}[i]; result.Outcome != want { + t.Fatalf("runtime_prepare %s %s: %+v, want %s", p.Action, step.Step, result, want) + } + } + } + for _, step := range steps { + transfer(proto.RuntimePreparePayload{Action: "initialize", Initialization: &step}) + } + transfer(proto.RuntimePreparePayload{Action: "finalize", Sources: s.configuration.LocalEnvironment.CapabilitySources}) +} + +// turn binds the Session's assignment, prepares an Executor of the Session, +// runs prompt as its Turn run, answers each function call with k's result, +// and retires the Executor with the Router's Shutdown. It returns the Turn's Done, its answer and its +// function calls. +func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePayload, string, []proto.FunctionCallPayload) { + t.Helper() + out := make(sender, 256) + router, ref := s.router(t, out, "bind-"+run) prepare := "prepare-" + run handle(t, router, ref, proto.TypeExecutionPrepare, prepare, proto.ExecutionPreparePayload{SessionID: s.id, Configuration: s.configuration}) ready := out.status(t, prepare) @@ -621,3 +681,52 @@ func (sb *sandbox) read(t *testing.T, cfg agenthost.Config, name string) (conten }) return content } + +// reset empties the sandbox's initialization area. +func (sb *sandbox) reset(t *testing.T, cfg agenthost.Config) { + sb.files(t, cfg, func(ctx context.Context, c *sandboxfs.Client, root sandboxfs.NodeRef) { + walked, err := c.Walk(ctx, &sandboxfs.WalkRequest{Parent: root, Names: [][]byte{[]byte("environment"), []byte("initialization")}}) + if err != nil || walked.Failure != nil { + t.Fatalf("the initialization area: %v %v", err, walked.Failure) + } + empty(ctx, t, c, new(sandboxfs.HandleIDs), walked.Entries[1].Node) + }) +} + +// empty removes what dir holds. +func empty(ctx context.Context, t *testing.T, c *sandboxfs.Client, handles *sandboxfs.HandleIDs, dir sandboxfs.NodeRef) { + h := handles.Next() + if _, err := c.OpenDir(ctx, &sandboxfs.OpenDirRequest{Handle: h, Node: dir}); err != nil { + t.Fatal(err) + } + var entries []sandboxfs.DirEntry + for cookie := uint64(0); ; { + r, err := c.ReadDir(ctx, &sandboxfs.ReadDirRequest{Handle: h, Cookie: cookie, Limit: 64 << 10}) + if err != nil { + t.Fatal(err) + } + entries = append(entries, r.Entries...) + if r.End || len(r.Entries) == 0 { + break + } + cookie = r.Entries[len(r.Entries)-1].Cookie + } + if _, err := c.ReleaseDir(ctx, &sandboxfs.ReleaseDirRequest{Handle: h}); err != nil { + t.Fatal(err) + } + for _, e := range entries { + var err error + if e.Type == sandboxfs.ModeDirectory { + var child *sandboxfs.LookupResponse + if child, err = c.Lookup(ctx, &sandboxfs.LookupRequest{Parent: dir, Name: e.Name}); err == nil { + empty(ctx, t, c, handles, child.Entry.Node) + _, err = c.Rmdir(ctx, &sandboxfs.RmdirRequest{Parent: dir, Name: e.Name}) + } + } else { + _, err = c.Unlink(ctx, &sandboxfs.UnlinkRequest{Parent: dir, Name: e.Name}) + } + if err != nil { + t.Fatalf("remove %s: %v", e.Name, err) + } + } +} diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 39b2a1aae..f08407787 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -63,7 +63,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, } if environment != nil { req, err = environment.Configure(req) - } else if req.LocalEnvironment != nil && !r.sessionEnvironments { + } else if req.LocalEnvironment != nil { err = errors.New("the Session has no Environment owner") } if err != nil { diff --git a/apps/daemon/internal/dispatch/router.go b/apps/daemon/internal/dispatch/router.go index d2a8a0009..fc4e53368 100644 --- a/apps/daemon/internal/dispatch/router.go +++ b/apps/daemon/internal/dispatch/router.go @@ -53,7 +53,6 @@ type Router struct { workspaceExport *workspaceExport workspaceReads map[string]string // read ID → SessionID environments func(proto.AssignmentRef, proto.AssignmentBindPayload) Environment - sessionEnvironments bool removeHome func(sessionID string) error } @@ -96,10 +95,6 @@ type Config struct { // the bind: the Runtime does not serve that Session. Nil Environments // leaves every Session without an owner. Environments func(proto.AssignmentRef, proto.AssignmentBindPayload) Environment - // SessionEnvironments says that the Executor factory binds a prepared - // execution's LocalEnvironment itself, without an owner. It excludes - // Environments. - SessionEnvironments bool // RemoveHome removes the Session's native home once its Executors have // closed. Nil declares that assignment_release does not accept RemoveHome. RemoveHome func(sessionID string) error @@ -115,9 +110,6 @@ func New(cfg Config) (*Router, error) { if cfg.Sender == nil { return nil, errors.New("dispatch.New: Sender is required") } - if cfg.SessionEnvironments && cfg.Environments != nil { - return nil, errors.New("dispatch.New: SessionEnvironments excludes Environments") - } log := cfg.Log if log == nil { log = obslog.Bg() @@ -145,7 +137,6 @@ func New(cfg Config) (*Router, error) { preparationRequests: make(map[string]*preparationState), preparationTimeout: preparationTimeout, environments: cfg.Environments, - sessionEnvironments: cfg.SessionEnvironments, removeHome: cfg.RemoveHome, }, nil } diff --git a/deploy/distribution/AgentHost.Dockerfile b/deploy/distribution/AgentHost.Dockerfile index 25f01b506..338f73a44 100644 --- a/deploy/distribution/AgentHost.Dockerfile +++ b/deploy/distribution/AgentHost.Dockerfile @@ -12,7 +12,8 @@ FROM base AS sandbox RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates bash git python3 python3-pip ripgrep \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /environment/workspace /workspace /home/runtime + && mkdir -p /environment/workspace /environment/initialization /environment/packages /workspace /home/runtime \ + && chown 1000:1000 /environment/initialization /environment/packages COPY --chmod=0555 oac-sandbox-io /usr/local/bin/ ENV HOME=/home/runtime USER 1000:1000 From ce4c640d96138dbc59363c7833c0cd3824e0e17b Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 20:22:57 +0000 Subject: [PATCH 07/13] Test the agent host's Environment owner against oac-sandbox-io --- .../agenthost/agenthost_linux_test.go | 15 +- .../agenthost/environment_linux_test.go | 392 ++++++++++++++++++ 2 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 apps/daemon/internal/agenthost/environment_linux_test.go diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index ea5de3fd8..d550f41c9 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -152,8 +152,8 @@ type daemon struct { func newDaemon(t *testing.T, cfg Config, d deps) *daemon { t.Helper() - dm := &daemon{host: &Host{cfg: cfg, owners: owners{d: d}}, frames: map[string]chan proto.Envelope{}, opened: map[string]*session{}} - reg := registry(cfg.Harnesses, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + dm := &daemon{host: &Host{cfg: cfg, owners: owners{d: d}}} + dm.route(t, registry(cfg.Harnesses, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { if dm.mcp != nil { local := *req.LocalEnvironment local.MCP = dm.mcp @@ -166,7 +166,15 @@ func newDaemon(t *testing.T, cfg Config, d deps) *daemon { dm.mu.Unlock() } return e, err - }) + })) + return dm +} + +// route serves dm's Sessions through a new Router that runs reg, with the +// Host's Environment owners. +func (dm *daemon) route(t *testing.T, reg *agent.Registry) { + t.Helper() + dm.frames, dm.opened = map[string]chan proto.Envelope{}, map[string]*session{} removeHome := func(session string) error { id, err := canonicalID(session) if err != nil { @@ -179,7 +187,6 @@ func newDaemon(t *testing.T, cfg Config, d deps) *daemon { t.Fatal(err) } t.Cleanup(func() { dm.shutdown() }) - return dm } // stateKeyPrefix and the Session ID make the state key dispatch requires. diff --git a/apps/daemon/internal/agenthost/environment_linux_test.go b/apps/daemon/internal/agenthost/environment_linux_test.go new file mode 100644 index 000000000..bfd8fe887 --- /dev/null +++ b/apps/daemon/internal/agenthost/environment_linux_test.go @@ -0,0 +1,392 @@ +//go:build linux + +package agenthost + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "io/fs" + "os" + "path" + "sync" + "testing" + "time" + + "github.com/google/uuid" + + "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/MiniMax-AI/OpenAgentCore/internal/sandboxfs" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +// TestEnvironmentOwnerServesTheSandbox prepares an Environment with a Skill +// and a setup step through runtime_prepare, reopens it on a new Router +// without initializing it again, quiesces and resumes it, and checks that a +// File mutation whose outcome is unknown is never replayed. It runs with the +// view suite; see the comment there. +func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { + if os.Getenv(gateEnv) != "1" { + t.Skipf("set %s=1 and run the test binary as root in a throwaway container; see the view suite", gateEnv) + } + sb := startSandbox(t, os.Getenv(sandboxIOEnv)) + // The sandbox's world is this container's /. + for _, p := range []string{sandboxInitialization, path.Join(sandboxWorkspace, "setup.txt"), path.Join(sandboxWorkspace, "notes")} { + if err := os.RemoveAll(p); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(sandboxWorkspace, 0o777); err != nil { + t.Fatal(err) + } + harnesses := agent.NewRegistry() + register(harnesses, "test", &agent.View{Proxy: agent.ViewProxyEnv, Executor: func(context.Context, proto.PromptRequestPayload, agent.ViewSession) (agent.Executor, error) { + return nil, errors.New("the test's factory replaces the view's") + }}) + cfg := Config{StateDir: t.TempDir(), RelayURL: sb.url, RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: harnesses} + sb.auth.AddRuntime(cfg.Credential, cfg.RuntimeID) + sb.ready(t, cfg) + p := &probe{} + h := &Host{cfg: cfg, owners: owners{d: deps{dial: relayDial(cfg), stream: p.stream}}} + // The factory records what the owner prepared: admission does not run + // Skills in views yet. + prepared := make(chan preparedExecutor, 4) + reg := registry(harnesses, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + _, env, err := h.executor(ctx, req) + prepared <- preparedExecutor{req: req, env: env} + if err != nil { + return nil, err + } + return &fakeExecutor{close: func() error { return nil }}, nil + }) + b := sb.bind(cfg.RuntimeID, time.Minute) + skill := agentskill.Metadata{Type: "inline", Name: "probe-skill", Description: "Probe the installation."} + manifest := []byte("---\nname: probe-skill\ndescription: Probe the installation.\n---\nProbe.\n") + req := request("test", sandboxWorkspace, "https://model.invalid", "key") + req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Skills: []agentskill.Metadata{skill}} + req.LocalEnvironment.Capabilities = true + + // Prepare as Core does: configure, a setup step that sees the tool + // environment, the Skill and finalize, then the Executor. + first := &daemon{host: h} + first.route(t, reg) + first.assign(t, b) + for _, step := range []struct { + begin proto.RuntimePreparePayload + data []byte + }{ + {proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"PROBE": "probe-value"}}}, nil}, + {proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Command: `printf '%s\n' "$PROBE" >> setup.txt`}}, nil}, + {proto.RuntimePreparePayload{Action: "skill", Skill: &skill}, skillArchive(t, manifest)}, + {proto.RuntimePreparePayload{Action: "finalize", Sources: req.LocalEnvironment.CapabilitySources}, nil}, + } { + if r := first.runtimePrepare(t, b, step.begin, step.data); r.Outcome != "completed" { + t.Fatalf("runtime_prepare %s: %+v, want completed", step.begin.Action, r) + } + } + if _, status := first.prepare(t, b, req); status.State != "ready" { + t.Fatalf("the preparation is %s (%s), want ready", status.State, status.ErrorCode) + } + got := <-prepared + local := got.req.LocalEnvironment + if local.WorkspaceRoot != sandboxWorkspace || local.CapabilityRoot != agentcapabilities.Directory || len(local.Skills) != 1 || + local.Skills[0].Metadata != skill || local.Skills[0].InstallationRoot != agentcapabilities.Directory || local.Skills[0].RelativeRoot != "skills/probe-skill" { + t.Fatalf("the Executor's Environment is %+v", local) + } + if got.env.Tool["PROBE"] != "probe-value" || got.env.Sandbox["PATH"] != sandboxBaseline["PATH"] { + t.Fatalf("the Executor's environments are %+v", got.env) + } + if body, err := os.ReadFile(path.Join(agentcapabilities.Directory, "skills/probe-skill/SKILL.md")); err != nil || !bytes.Equal(body, manifest) { + t.Fatalf("the installed Skill is %q, %v", body, err) + } + checkSetup(t) + if err := first.shutdown(); err != nil { + t.Fatal(err) + } + + // Reopen: a new Router checks the completed installation and neither + // changes the world nor runs a step. + p.reset(0) + second := &daemon{host: h} + second.route(t, reg) + id, status := second.prepare(t, b, req) + if status.State != "ready" { + t.Fatalf("the reopened preparation is %s (%s), want ready", status.State, status.ErrorCode) + } + if got := <-prepared; len(got.req.LocalEnvironment.Skills) != 1 || got.env.Tool["PROBE"] != "probe-value" { + t.Fatalf("the reopened Executor's Environment is %+v, %+v", got.req.LocalEnvironment, got.env) + } + if n := p.counts(); n.mutations != 0 || n.processes != 0 { + t.Fatalf("the reopen sent %d File mutations and opened %d Process streams", n.mutations, n.processes) + } + checkSetup(t) + + // Quiesce drains the owner; after 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 !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) + } + p.reset(0) + id, status = second.prepare(t, b, req) + if status.State != "ready" { + t.Fatalf("the resumed preparation is %s (%s), want ready", status.State, status.ErrorCode) + } + if r := second.read(t, b, status.Handle); r.Outcome != "completed" || !lists(r.Directory, "setup.txt") { + t.Fatalf("the resumed read is %+v", r) + } + if p.counts().files != 1 { + t.Fatalf("the resumed read opened %d File streams, want 1", p.counts().files) + } + second.release(t, b, id, status.Handle) + + // An uncertain File mutation quarantines the owner: no later write or + // runtime_prepare on any Router sends one again. + p.reset(sandboxfs.OpLink) + if r := second.write(t, b, "notes/uncertain.txt", []byte("uncertain")); r.Outcome != "unknown" { + t.Fatalf("the interrupted write is %+v, want unknown", r) + } + // The Router reports the uncertain write as it shuts down and still + // drains the owner. + second.shutdown() + if !h.drained(t, b) { + t.Fatal("the owner kept its attachment after the Router shut down") + } + p.reset(0) + third := &daemon{host: h} + third.route(t, reg) + third.assign(t, b) + if r := third.write(t, b, "notes/uncertain.txt", []byte("uncertain")); r.Outcome != "unknown" { + t.Fatalf("the write after an uncertain one is %+v, want unknown", r) + } + // An unknown outcome fences the Router's transfers, so the next one runs + // on another. + third.shutdown() + fourth := &daemon{host: h} + fourth.route(t, reg) + fourth.assign(t, b) + if r := fourth.runtimePrepare(t, b, proto.RuntimePreparePayload{Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/notes/file.txt"}}, []byte("file")); r.Outcome != "unknown" { + t.Fatalf("the runtime_prepare after an uncertain write is %+v, want unknown", r) + } + if n := p.counts(); n.files != 0 || n.mutations != 0 { + t.Fatalf("the quarantined owner opened %d File streams and sent %d mutations", n.files, n.mutations) + } + for _, name := range []string{"uncertain.txt", "file.txt"} { + if _, err := os.Lstat(path.Join(sandboxWorkspace, "notes", name)); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("%s exists: %v", name, err) + } + } +} + +type preparedExecutor struct { + req proto.PromptRequestPayload + env Environment +} + +// checkSetup checks that the setup step ran once. +func checkSetup(t *testing.T) { + t.Helper() + if body, err := os.ReadFile(path.Join(sandboxWorkspace, "setup.txt")); err != nil || string(body) != "probe-value\n" { + t.Fatalf("the setup step wrote %q, %v", body, err) + } +} + +func skillArchive(t *testing.T, manifest []byte) []byte { + t.Helper() + var b bytes.Buffer + w := zip.NewWriter(&b) + f, err := w.Create("probe-skill/SKILL.md") + if err == nil { + _, err = f.Write(manifest) + } + if err == nil { + err = w.Close() + } + if err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +// drained reports whether b's Session's Environment owner holds no +// attachment. +func (h *Host) drained(t *testing.T, b Binding) bool { + t.Helper() + h.owners.mu.Lock() + o := h.owners.m[b.SessionID] + h.owners.mu.Unlock() + if o == nil { + t.Fatal("the Session has no Environment owner") + } + if err := o.acquire(context.Background()); err != nil { + t.Fatal(err) + } + defer o.release() + return o.link == nil +} + +// runtimePrepare sends one runtime_prepare transfer of data on b's Session +// and returns its result. +func (dm *daemon) runtimePrepare(t *testing.T, b Binding, begin proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { + t.Helper() + begin.Step, begin.EnvironmentID, begin.SessionID = "begin", environmentID(b), ref(b).SessionID + if begin.Action != "initialize" && begin.Action != "finalize" { + digest := sha256.Sum256(data) + begin.SizeBytes, begin.SHA256 = len(data), hex.EncodeToString(digest[:]) + } + id := uuid.NewString() + var r proto.RuntimePrepareResultPayload + send := func(payload proto.RuntimePreparePayload) { + dm.handle(t, ref(b), proto.TypeRuntimePrepare, id, payload) + if err := dm.next(t, id).DecodePayload(&r); err != nil { + t.Fatal(err) + } + } + if send(begin); r.Outcome != "ready" { + return r + } + for off := 0; off < len(data); off += proto.RuntimePrepareChunkBytes { + if send(proto.RuntimePreparePayload{Step: "chunk", Offset: off, Data: data[off:min(len(data), off+proto.RuntimePrepareChunkBytes)]}); r.Outcome != "received" { + t.Fatalf("runtime_prepare chunk: %+v", r) + } + } + send(proto.RuntimePreparePayload{Step: "commit"}) + return r +} + +// write writes data to p in b's Session's workspace and returns the result. +func (dm *daemon) write(t *testing.T, b Binding, p string, data []byte) proto.WorkspaceWriteResultPayload { + t.Helper() + digest := sha256.Sum256(data) + id := uuid.NewString() + var r proto.WorkspaceWriteResultPayload + send := func(payload proto.WorkspaceWritePayload) { + dm.handle(t, ref(b), proto.TypeWorkspaceWrite, id, payload) + if err := dm.next(t, id).DecodePayload(&r); err != nil { + t.Fatal(err) + } + } + if send(proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environmentID(b), SessionID: ref(b).SessionID, Path: p, SizeBytes: len(data), + SHA256: hex.EncodeToString(digest[:])}); r.Outcome != "ready" { + return r + } + if send(proto.WorkspaceWritePayload{Step: "chunk", Data: data}); r.Outcome != "received" { + t.Fatalf("workspace_write chunk: %+v", r) + } + send(proto.WorkspaceWritePayload{Step: "commit"}) + return r +} + +// read lists the workspace of b's Session under the ready preparation handle. +func (dm *daemon) read(t *testing.T, b Binding, handle string) proto.WorkspaceReadResultPayload { + t.Helper() + id := uuid.NewString() + dm.handle(t, ref(b), proto.TypeWorkspaceRead, id, proto.WorkspaceReadPayload{Handle: handle, EnvironmentID: environmentID(b), MaxEntries: 100}) + var r proto.WorkspaceReadResultPayload + if err := dm.next(t, id).DecodePayload(&r); err != nil { + t.Fatal(err) + } + return r +} + +// release releases the preparation that request id made. +func (dm *daemon) release(t *testing.T, b Binding, id, handle string) { + t.Helper() + dm.handle(t, ref(b), proto.TypeExecutionRelease, id, proto.ExecutionReleasePayload{Handle: handle}) + var status proto.PreparationStatusPayload + if err := dm.next(t, id).DecodePayload(&status); err != nil || status.State != "released" { + t.Fatalf("the release is %+v, %v", status, err) + } +} + +func lists(d *proto.WorkspaceDirectoryResult, name string) bool { + for _, e := range d.Entries { + if e.Name == name { + return true + } + } + return false +} + +// probe observes the streams an Environment owner opens: it counts File +// streams, File mutations and Process streams, and breaks the File stream +// that sends the next request of an armed operation before sending it. +type probe struct { + mu sync.Mutex + n probeCounts + armed sandboxfs.Op +} + +type probeCounts struct{ files, mutations, processes int } + +var mutations = map[sandboxfs.Op]bool{sandboxfs.OpSetAttr: true, sandboxfs.OpCreate: true, sandboxfs.OpWrite: true, sandboxfs.OpFsync: true, + sandboxfs.OpMkdir: true, sandboxfs.OpUnlink: true, sandboxfs.OpRmdir: true, sandboxfs.OpRename: true, sandboxfs.OpLink: true, sandboxfs.OpSymlink: true} + +// reset clears the counts and arms op; zero arms none. +func (p *probe) reset(op sandboxfs.Op) { + p.mu.Lock() + defer p.mu.Unlock() + p.n, p.armed = probeCounts{}, op +} + +func (p *probe) counts() probeCounts { + p.mu.Lock() + defer p.mu.Unlock() + return p.n +} + +func (p *probe) stream(service sandboxlink.Service, st io.ReadWriteCloser) io.ReadWriteCloser { + p.mu.Lock() + defer p.mu.Unlock() + if service == sandboxlink.ServiceProcess { + p.n.processes++ + return st + } + p.n.files++ + return &probed{ReadWriteCloser: st, p: p} +} + +type probed struct { + io.ReadWriteCloser + p *probe +} + +// Write sees one whole frame per call, as sandboxwire.WriteFrame writes it. +func (s *probed) Write(b []byte) (int, error) { + f, err := sandboxwire.ReadFrame(bytes.NewReader(b), sandboxwire.MaxPayload) + if err != nil { + return 0, err + } + op := sandboxfs.Op(f.Type) + s.p.mu.Lock() + if mutations[op] { + s.p.n.mutations++ + } + broken := op == s.p.armed + if broken { + s.p.armed = 0 + } + s.p.mu.Unlock() + if broken { + s.Close() + return 0, errors.New("the probe broke the stream") + } + return s.ReadWriteCloser.Write(b) +} From d8059e60ff6e3528e6d7d651315d4a4100e89964 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 20:25:41 +0000 Subject: [PATCH 08/13] Document the agent host's Environment owner The owner no longer repeats dispatch's state-key check: dispatch resolves it from the Session's assignment and checks the key before any writable preparation. --- apps/daemon/internal/agenthost/agenthost.go | 4 ++-- apps/daemon/internal/agenthost/environment_linux.go | 9 ++++----- contracts/agents-api/harness-onboarding.md | 4 ++-- contracts/agents-api/zh/harness-onboarding.md | 6 +++--- docs/runtime-protocol.md | 2 +- docs/zh/runtime-protocol.md | 4 ++-- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/daemon/internal/agenthost/agenthost.go b/apps/daemon/internal/agenthost/agenthost.go index c4e024d21..345f6a6f3 100644 --- a/apps/daemon/internal/agenthost/agenthost.go +++ b/apps/daemon/internal/agenthost/agenthost.go @@ -65,8 +65,8 @@ type Binding struct { // Environment is the remote environment policy of processes forwarded to the // sandbox. type Environment struct { - // Sandbox holds the Environment's fixed values, such as HOME, PATH, - // TMPDIR and LANG in the sandbox. + // Sandbox holds the Environment's fixed values in the sandbox: HOME, + // PATH and LANG. Sandbox map[string]string // Tool is the Environment's tool environment. Tool map[string]string diff --git a/apps/daemon/internal/agenthost/environment_linux.go b/apps/daemon/internal/agenthost/environment_linux.go index cd0e7a848..b56dae9c2 100644 --- a/apps/daemon/internal/agenthost/environment_linux.go +++ b/apps/daemon/internal/agenthost/environment_linux.go @@ -273,13 +273,12 @@ func (o *environment) Close(ctx context.Context) error { return o.drain() } -// Configure checks the request against the owner's Session and Environment -// and returns it with the sandbox workspace as its root. +// Configure checks the request against the owner's Environment, which +// dispatch resolved from the Session's assignment, and returns it with the +// sandbox workspace as its root. func (o *environment) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { local := r.LocalEnvironment switch { - case r.AgentStateKey != "agents-api-"+o.session: - return r, errors.New("the request is not the Session's") case o.id == "": if local != nil || !r.DisableExecutionEnvironment { return r, errors.New("the Session has no Environment") @@ -312,7 +311,7 @@ func (o *environment) Prepare(ctx context.Context, r proto.PromptRequestPayload) return r, nil } if o.id == "" || r.LocalEnvironment == nil || r.LocalEnvironment.ID != o.id || r.LocalEnvironment.CapabilitySources == nil || - r.LocalEnvironment.WorkspaceRoot != sandboxWorkspace || r.AgentStateKey != "agents-api-"+o.session { + r.LocalEnvironment.WorkspaceRoot != sandboxWorkspace { return r, agentcapabilities.ErrInvalid } if err := o.acquire(ctx); err != nil { diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 1d7731d55..308654fc1 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -300,7 +300,7 @@ The agent host derives the process broker's table from the declaration: `/.oac/b ### Environment -`Launch` takes the complete Harness environment in `StartOptions.Env`, which the adapter derives from its installation and the request's typed fields; the request carries no environment values. The agent host's own environment never passes through, so a view adapter does not start from `os.Environ()`. A process run in the sandbox gets the broker's environment: the `ForwardEnv` variables from the Harness, the Environment's fixed sandbox values (`HOME`, `PATH`, `TMPDIR` and `LANG`) and the Environment's tool environment. The broker is the only home of the tool environment, and a view adapter passes none of it to the Harness. The agent host keeps model and MCP credentials only in the gateway's protected configuration and adds none to the Harness's environment, a Process spec or a capability tree the view exposes. +`Launch` takes the complete Harness environment in `StartOptions.Env`, which the adapter derives from its installation and the request's typed fields; the request carries no environment values. The agent host's own environment never passes through, so a view adapter does not start from `os.Environ()`. A process run in the sandbox gets the broker's environment: the `ForwardEnv` variables from the Harness, the Environment's fixed sandbox values (`HOME`, `PATH` and `LANG`) and the Environment's tool environment. The broker is the only home of the tool environment, and a view adapter passes none of it to the Harness. The agent host keeps model and MCP credentials only in the gateway's protected configuration and adds none to the Harness's environment, a Process spec or a capability tree the view exposes. `ForwardEnv` never names a variable the view or the broker sets: `HOME`, `PATH`, `TMPDIR`, `LANG`, `LD_LIBRARY_PATH`, or `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` and `NO_PROXY` in any case. When the Environment's tool environment also sets a forwarded variable, the tool environment's value wins. @@ -353,7 +353,7 @@ Run the adapter's Turns, cancellation and continuation in a view, then qualify e | `Home` | Native history and configuration stay under `/.oac/home`, and a later Executor in the same Session continues from them. | | Declaration | Each declared feature runs a Turn through dispatch: environment none in the empty-root view, function calls and results, tool search, and each stdio binding under its alias. | -`scripts/qualify-agent-host.sh` runs each Harness's Turns through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. The first Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. When the kind declares function tools, a second Turn runs in a new Executor that resumes the Session's native history and calls a function; the test returns a text, image and text result through dispatch, and the answer must report both texts. When the kind declares tool search, a Turn in another Session finds the deferred function with tool search and calls it. When the kind declares environment none, a Turn in a Session without an Environment answers through the model, and the Harness's native state in the Session home must name its working directory, `/.oac/home/work`. The test gives a Session's Environment one installed stdio MCP server, a script that runs in the sandbox, and the answer must report the code its one tool returns. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. +`scripts/qualify-agent-host.sh` runs each Harness's Turns through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. Each Session's Environment is prepared through `runtime_prepare` as Core prepares it: a configure step freezes the tool environment, and a setup step writes a file with one of its values, which the test checks; every later Executor of the Session reopens that preparation. The first Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. When the kind declares function tools, a second Turn runs in a new Executor that resumes the Session's native history and calls a function; the test returns a text, image and text result through dispatch, and the answer must report both texts. When the kind declares tool search, a Turn in a new Executor without native history finds the deferred function with tool search and calls it. When the kind declares environment none, a Turn in a Session without an Environment answers through the model, and the Harness's native state in the Session home must name its working directory, `/.oac/home/work`. The test gives a Session's Environment one installed stdio MCP server, a script that runs in the sandbox, and the answer must report the code its one tool returns. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. ## Native references diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 0db52f180..a6aab70cc 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: bd60eb63b07d823a0b44502bcbe1a329100a063d05a1b7ebdda979fa152eb60a +source_hash: 4142f7fe1dba17a09e6cf3d528edb445522b2bf16e8c5f646491cc5ee32ea49f --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -302,7 +302,7 @@ agent host 根据声明推导进程 broker 的映射表:`/.oac/bin/` 在 ### 环境 {#environment} -`Launch` 在 `StartOptions.Env` 中接收完整的 Harness 环境,适配器根据自己的安装和请求的类型化字段推导该环境;请求不携带任何环境值。agent host 自身的环境从不传入,因此视图适配器不从 `os.Environ()` 开始构造。在沙箱中运行的进程获得 broker 的环境:来自 Harness 的 `ForwardEnv` 变量、Environment 固定的沙箱值(`HOME`、`PATH`、`TMPDIR` 和 `LANG`)以及 Environment 的工具环境。broker 是工具环境的唯一归属,视图适配器不向 Harness 传递任何工具环境。agent host 只把模型和 MCP 凭据保存在网关受保护的配置中,从不把它们加入 Harness 的环境、Process spec 或视图暴露的能力树。 +`Launch` 在 `StartOptions.Env` 中接收完整的 Harness 环境,适配器根据自己的安装和请求的类型化字段推导该环境;请求不携带任何环境值。agent host 自身的环境从不传入,因此视图适配器不从 `os.Environ()` 开始构造。在沙箱中运行的进程获得 broker 的环境:来自 Harness 的 `ForwardEnv` 变量、Environment 固定的沙箱值(`HOME`、`PATH` 和 `LANG`)以及 Environment 的工具环境。broker 是工具环境的唯一归属,视图适配器不向 Harness 传递任何工具环境。agent host 只把模型和 MCP 凭据保存在网关受保护的配置中,从不把它们加入 Harness 的环境、Process spec 或视图暴露的能力树。 `ForwardEnv` 从不指定视图或 broker 设置的变量:`HOME`、`PATH`、`TMPDIR`、`LANG`、`LD_LIBRARY_PATH`,以及任意大小写的 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 和 `NO_PROXY`。当 Environment 的工具环境也设置了某个转发变量时,以工具环境的值为准。 @@ -355,7 +355,7 @@ stdio 绑定在沙箱中以其别名运行。`ViewSession.MCP` 中索引为 `i` | `Home` | 原生历史和配置保存在 `/.oac/home` 下,同一 Session 中后续的 Executor 从中继续。 | | 声明 | 每项声明的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、函数调用及其结果、工具搜索,以及每个以别名运行的 stdio 绑定。 | -`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 运行每个 Harness 的 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。kind 声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。kind 声明工具搜索时,另一个 Session 中的 Turn 用工具搜索找到延迟加载的函数并调用它。kind 声明 environment none 时,一个没有 Environment 的 Session 中的 Turn 通过模型作答,且 Session home 中 Harness 的原生状态必须写明其工作目录 `/.oac/home/work`。测试为一个 Session 的 Environment 提供一个已安装的 stdio MCP 服务器,即在沙箱中运行的脚本,回答必须报告其唯一工具返回的代码。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 +`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 运行每个 Harness 的 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。每个 Session 的 Environment 像 Core 那样通过 `runtime_prepare` 准备:configure 步骤冻结工具环境,setup 步骤写入一个文件,内容是工具环境中的一个值,由测试检查;Session 之后的每个 Executor 都重新打开这次准备。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。kind 声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。kind 声明工具搜索时,一个没有原生历史的新 Executor 中的 Turn 用工具搜索找到延迟加载的函数并调用它。kind 声明 environment none 时,一个没有 Environment 的 Session 中的 Turn 通过模型作答,且 Session home 中 Harness 的原生状态必须写明其工作目录 `/.oac/home/work`。测试为一个 Session 的 Environment 提供一个已安装的 stdio MCP 服务器,即在沙箱中运行的脚本,回答必须报告其唯一工具返回的代码。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 ## 原生参考 {#native-references} diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 2898d95e6..d2c7018a5 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -115,7 +115,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. 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 with the same Environment, resource and grant is `bound` again; any other bind of it fails with `assignment_conflict`. 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`. 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` (except on an agent host, whose Executor binds its Environment itself), `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. 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 the Runtime or releasing the assignment closes its attachment; a bind under a later assignment takes the owner over once that attachment is closed and otherwise fails with `assignment_conflict`. It runs each setup step as the Process operation whose ID is the `runtime_prepare` envelope ID. 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. A quiesced Runtime admits only a release and the matching `environment_resume`, which carries the assignment that quiesced it. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 1349a54e5..6635bfd95 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: 6139b91a175c1d3e746bafc15a062421615bd10541918f2dceaac0e4db9b3dfc +source_hash: e58e5acd22ad7bca6e5a7cd0caf550c09b533a0dae9411c6302284a81734cc1b --- 此协议在 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)。 @@ -117,7 +117,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 上的服务。grant 是机密。Core 不向其他任何 Runtime 发送这两个字段。只带其中一个字段、或带其他 Environment 的 resource 的绑定以 `invalid_request` 失败。以相同的 Environment、resource 和 grant 重复绑定同一分配仍得到 `bound`;该分配的其他绑定以 `assignment_conflict` 失败。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 的绑定。没有 owner 的 Session 不支持上述任何操作,Runtime 以各自的类型化错误码拒绝:只读 preparation 为 `unsupported_read_preparation`;带 `local_environment` 的 Executor 配置为 `invalid_configuration`(agent host 除外,其 Executor 自行绑定 Environment);`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,从不替换为其他实现。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 和连接存续得更久。Runtime 静默(quiesce)或分配被释放时关闭其 attachment;之后分配下的绑定在该 attachment 关闭后接管 owner,否则以 `assignment_conflict` 失败。它把每个 setup 步骤作为 Process 操作运行,操作 ID 即 `runtime_prepare` 的 envelope ID。无法观察到结果的文件变更或 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 连接时重发所有未确认的释放;失败的释放退避重试,等待最久的释放先发送,因此失败的释放不会拖延其他释放。已 quiesce 的 Runtime 只准入释放和匹配的 `environment_resume`,后者携带使其 quiesce 的分配。 From f9a3ffab6cc325cc96e4169f5d650fbcc32aad7b Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 21:20:48 +0000 Subject: [PATCH 09/13] Reject runtime_prepare that cannot reach the sandbox A runtime_prepare whose owner could not attach, open the Process service or have a step admitted ended unknown, which kept the Router's transfer slot and fenced every later transfer. Such a failure has no effect, so the owner now returns dispatch.ErrEnvironmentUnavailable, which also replaces the workspace write's unavailable error, and the Router ends it rejected with resource_unavailable. --- .../internal/agenthost/environment_linux.go | 14 ++++++++------ .../agenthost/environment_linux_test.go | 19 +++++++++++++++++++ apps/daemon/internal/agenthost/setup_linux.go | 15 ++++++++------- apps/daemon/internal/dispatch/environment.go | 15 ++++++++++----- .../internal/dispatch/runtime_preparation.go | 3 +++ .../dispatch/runtime_preparation_test.go | 1 + .../dispatch/workspace_export_test.go | 2 +- .../internal/dispatch/workspace_write.go | 2 +- .../internal/localworkspace/native_files.go | 2 +- apps/daemon/internal/localworkspace/write.go | 2 +- .../internal/localworkspace/write_test.go | 2 +- docs/runtime-protocol.md | 2 +- docs/zh/runtime-protocol.md | 4 ++-- 13 files changed, 57 insertions(+), 26 deletions(-) diff --git a/apps/daemon/internal/agenthost/environment_linux.go b/apps/daemon/internal/agenthost/environment_linux.go index b56dae9c2..43db8bda6 100644 --- a/apps/daemon/internal/agenthost/environment_linux.go +++ b/apps/daemon/internal/agenthost/environment_linux.go @@ -377,13 +377,15 @@ func (o *environment) Prepare(ctx context.Context, r proto.PromptRequestPayload) } // ApplyRuntimePreparation applies one runtime_prepare transfer to the -// sandbox. A setup step runs as the Process operation named transfer. +// sandbox. A setup step runs as the Process operation named transfer. A +// failure to reach the sandbox before any effect is +// dispatch.ErrEnvironmentUnavailable. func (o *environment) ApplyRuntimePreparation(ctx context.Context, transfer uuid.UUID, input proto.RuntimePreparePayload, data []byte) error { if o.id == "" || input.EnvironmentID != o.id || input.SessionID != o.session || !proto.ValidRuntimePrepareRequest(input) || input.Step != "begin" { return agentcapabilities.ErrInvalid } if err := o.acquire(ctx); err != nil { - return err + return dispatch.ErrEnvironmentUnavailable } defer o.release() if o.uncertain { @@ -391,7 +393,7 @@ func (o *environment) ApplyRuntimePreparation(ctx context.Context, transfer uuid } w, err := o.attach(ctx) if err != nil { - return err + return dispatch.ErrEnvironmentUnavailable } defer o.done(w) identity := o.identity() @@ -654,7 +656,7 @@ func (o *environment) WriteWorkspaceFile(ctx context.Context, p string, data []b case len(data) > proto.WorkspaceWriteMaxBytes || !validPath(p): return result, dispatch.ErrWorkspaceWriteInvalid case o.id == "": - return result, dispatch.ErrWorkspaceWriteUnavailable + return result, dispatch.ErrEnvironmentUnavailable } ctx, cancel := context.WithTimeout(ctx, writeBound) defer cancel() @@ -667,12 +669,12 @@ func (o *environment) WriteWorkspaceFile(ctx context.Context, p string, data []b } w, err := o.attach(ctx) if err != nil { - return result, dispatch.ErrWorkspaceWriteUnavailable + return result, dispatch.ErrEnvironmentUnavailable } defer o.done(w) workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) if err != nil { - return result, dispatch.ErrWorkspaceWriteUnavailable + return result, dispatch.ErrEnvironmentUnavailable } parent, err := w.directory(ctx, workspace, path.Dir(p), true) if err == nil { diff --git a/apps/daemon/internal/agenthost/environment_linux_test.go b/apps/daemon/internal/agenthost/environment_linux_test.go index bfd8fe887..1d0dad944 100644 --- a/apps/daemon/internal/agenthost/environment_linux_test.go +++ b/apps/daemon/internal/agenthost/environment_linux_test.go @@ -14,6 +14,7 @@ import ( "os" "path" "sync" + "sync/atomic" "testing" "time" @@ -195,6 +196,24 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { } } +// TestUnreachableSandboxRejectsRuntimePreparation checks that a +// runtime_prepare whose owner cannot reach the sandbox ends rejected, which +// leaves the Router free to run another Session's and to shut down. +func TestUnreachableSandboxRejectsRuntimePreparation(t *testing.T) { + var dials atomic.Int32 + dm := newDaemon(t, newViewFixture(t).cfg, deps{dial: countingDial(&dials), tasks: noTasks}) + configure := proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "configure"}} + for _, b := range []Binding{newBinding(newResource()), newBinding(newResource())} { + dm.assign(t, b) + if r := dm.runtimePrepare(t, b, configure, nil); r.Outcome != "rejected" || r.ErrorCode != "resource_unavailable" { + t.Fatalf("runtime_prepare is %+v, want rejected with resource_unavailable", r) + } + } + if err := dm.shutdown(); err != nil || dials.Load() != 2 { + t.Fatalf("Shutdown after %d dials: %v", dials.Load(), err) + } +} + type preparedExecutor struct { req proto.PromptRequestPayload env Environment diff --git a/apps/daemon/internal/agenthost/setup_linux.go b/apps/daemon/internal/agenthost/setup_linux.go index 76c82c060..b19cfc740 100644 --- a/apps/daemon/internal/agenthost/setup_linux.go +++ b/apps/daemon/internal/agenthost/setup_linux.go @@ -34,23 +34,24 @@ func strongestScope(caps sp.Capabilities) (sp.Scope, error) { // run runs one setup step in the sandbox as the Process operation id, in the // strongest scope the service declares, discards its output and returns once // it has settled: nil when it exited 0, an InitializationFailure when it -// could not start or exited 1 to 255, and an error otherwise. When ctx ends -// first, run cancels the step and waits closeBound for it to settle. A step -// that may run unobserved quarantines the owner. +// could not start or exited 1 to 255, dispatch.ErrEnvironmentUnavailable when +// the service is unreachable or refused it before any effect, and an error +// otherwise. When ctx ends first, run cancels the step and waits closeBound +// for it to settle. A step that may run unobserved quarantines the owner. func (o *environment) run(ctx context.Context, id sandboxwire.ID, program string, args []string, env map[string]string, cwd string) error { rw, err := o.open(ctx, sandboxlink.ServiceProcess, sp.Version) if err != nil { - return err + return dispatch.ErrEnvironmentUnavailable } c := sp.NewClient(rw) defer c.Close() d, err := c.Describe(ctx) if err != nil { - return err + return dispatch.ErrEnvironmentUnavailable } scope, err := strongestScope(d.Capabilities) if err != nil { - return err + return dispatch.ErrEnvironmentUnavailable } spec := sp.ProcessSpec{Executable: []byte(program), Argv: [][]byte{[]byte(program)}, Cwd: []byte(cwd), Umask: 0o022, IOMode: sp.IOPipes, Scope: scope} for _, arg := range args { @@ -67,7 +68,7 @@ func (o *environment) run(ctx context.Context, id sandboxwire.ID, program string if err != nil { var f *sp.Failure if errors.As(err, &f) && f.Effect == sandboxwire.EffectNone { - return err + return dispatch.ErrEnvironmentUnavailable } return o.lose(err) } diff --git a/apps/daemon/internal/dispatch/environment.go b/apps/daemon/internal/dispatch/environment.go index d76adc830..e300c1a31 100644 --- a/apps/daemon/internal/dispatch/environment.go +++ b/apps/daemon/internal/dispatch/environment.go @@ -58,6 +58,12 @@ type WorkspaceWriteResult struct { SizeBytes int64 } +// ErrEnvironmentUnavailable reports that the owner could not reach the +// Environment's resources and the operation had no effect. The Router ends a +// workspace_write or runtime_prepare that returns it rejected with +// resource_unavailable. +var ErrEnvironmentUnavailable = errors.New("environment unavailable") + var ( ErrWorkspaceReadUnavailable = errors.New("workspace read unavailable") ErrWorkspaceReadInvalid = errors.New("workspace read invalid") @@ -66,11 +72,10 @@ var ( // missing, a regular file or a symbolic link; the link was not followed. ErrWorkspaceNotDirectory = errors.New("workspace path is not a directory") - ErrWorkspaceWriteUnavailable = errors.New("workspace write unavailable") - ErrWorkspaceWriteBusy = errors.New("workspace write busy") - ErrWorkspaceWriteInvalid = errors.New("workspace write invalid") - ErrWorkspaceWriteRejected = errors.New("workspace write rejected") - ErrWorkspaceWriteUncertain = errors.New("workspace write outcome uncertain") + ErrWorkspaceWriteBusy = errors.New("workspace write busy") + ErrWorkspaceWriteInvalid = errors.New("workspace write invalid") + ErrWorkspaceWriteRejected = errors.New("workspace write rejected") + ErrWorkspaceWriteUncertain = errors.New("workspace write outcome uncertain") ) // Known Files.create destination refusals. Both wrap ErrWorkspaceWriteRejected: diff --git a/apps/daemon/internal/dispatch/runtime_preparation.go b/apps/daemon/internal/dispatch/runtime_preparation.go index 4d65d5496..8db2ec1f4 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation.go +++ b/apps/daemon/internal/dispatch/runtime_preparation.go @@ -219,6 +219,9 @@ func runtimePreparationResult(err error, size int) proto.RuntimePrepareResultPay if err == nil { return proto.RuntimePrepareResultPayload{Outcome: "completed", SizeBytes: size} } + if errors.Is(err, ErrEnvironmentUnavailable) { + return rejectedRuntimePreparation("resource_unavailable") + } var initialization *InitializationFailure if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && errors.As(err, &initialization) { code := 0 diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go index 456a0eec2..da997c58f 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -328,6 +328,7 @@ func TestRuntimePreparationResultCategoriesAndUnknownOwnership(t *testing.T) { }{ {nil, "completed", ""}, {agentcapabilities.ErrInvalid, "failed", "runtime_preparation_failed"}, + {ErrEnvironmentUnavailable, "rejected", "resource_unavailable"}, {context.DeadlineExceeded, "unknown", "runtime_preparation_unconfirmed"}, {errors.Join(agentcapabilities.ErrInvalid, context.Canceled), "unknown", "runtime_preparation_unconfirmed"}, {errors.New("private native diagnostic"), "unknown", "runtime_preparation_unconfirmed"}, diff --git a/apps/daemon/internal/dispatch/workspace_export_test.go b/apps/daemon/internal/dispatch/workspace_export_test.go index 42c02bc6a..19e9f2843 100644 --- a/apps/daemon/internal/dispatch/workspace_export_test.go +++ b/apps/daemon/internal/dispatch/workspace_export_test.go @@ -46,7 +46,7 @@ func (stubEnvironment) ListWorkspaceDirectory(context.Context, string, int) (Wor return WorkspaceDirectoryResult{}, ErrWorkspaceReadUnavailable } func (stubEnvironment) WriteWorkspaceFile(context.Context, string, []byte) (WorkspaceWriteResult, error) { - return WorkspaceWriteResult{}, ErrWorkspaceWriteUnavailable + return WorkspaceWriteResult{}, ErrEnvironmentUnavailable } func (e stubEnvironment) ExportOutputs(_ context.Context, w io.Writer) error { archive := tar.NewWriter(w) diff --git a/apps/daemon/internal/dispatch/workspace_write.go b/apps/daemon/internal/dispatch/workspace_write.go index afa9def78..3bf06b4e2 100644 --- a/apps/daemon/internal/dispatch/workspace_write.go +++ b/apps/daemon/internal/dispatch/workspace_write.go @@ -163,7 +163,7 @@ func workspaceWriteResult(write WorkspaceWriteResult, err error, size int) proto err error code string }{ - {ErrWorkspaceWriteUnavailable, "resource_unavailable"}, + {ErrEnvironmentUnavailable, "resource_unavailable"}, {ErrWorkspaceWriteBusy, "write_capacity"}, {ErrWorkspaceWriteInvalid, "invalid_request"}, {ErrWorkspaceWriteRejected, "write_rejected"}, diff --git a/apps/daemon/internal/localworkspace/native_files.go b/apps/daemon/internal/localworkspace/native_files.go index b4ba22589..b11f61540 100644 --- a/apps/daemon/internal/localworkspace/native_files.go +++ b/apps/daemon/internal/localworkspace/native_files.go @@ -111,7 +111,7 @@ func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) } root, err := os.OpenRoot(b.workspace) if err != nil { - return result, dispatch.ErrWorkspaceWriteUnavailable + return result, dispatch.ErrEnvironmentUnavailable } defer root.Close() if err = root.MkdirAll(filepath.Dir(local), 0700); err != nil { diff --git a/apps/daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go index e158e9533..6f6fdc5bc 100644 --- a/apps/daemon/internal/localworkspace/write.go +++ b/apps/daemon/internal/localworkspace/write.go @@ -20,7 +20,7 @@ func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []by return result, dispatch.ErrWorkspaceWriteInvalid } if ctx.Err() != nil { - return result, dispatch.ErrWorkspaceWriteUnavailable + return result, dispatch.ErrEnvironmentUnavailable } w := b.writer if !w.mu.TryLock() { diff --git a/apps/daemon/internal/localworkspace/write_test.go b/apps/daemon/internal/localworkspace/write_test.go index f34ddc8bd..7edc1a371 100644 --- a/apps/daemon/internal/localworkspace/write_test.go +++ b/apps/daemon/internal/localworkspace/write_test.go @@ -61,7 +61,7 @@ func TestNativeFileAdmission(t *testing.T) { } ctx, cancel := context.WithCancel(t.Context()) cancel() - if _, err := b.WriteWorkspaceFile(ctx, "cancelled", nil); !errors.Is(err, dispatch.ErrWorkspaceWriteUnavailable) { + 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) { diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index d2c7018a5..105bf1513 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -115,7 +115,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. 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 with the same Environment, resource and grant is `bound` again; any other bind of it fails with `assignment_conflict`. 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 the Runtime or releasing the assignment closes its attachment; a bind under a later assignment takes the owner over once that attachment is closed and otherwise fails with `assignment_conflict`. It runs each setup step as the Process operation whose ID is the `runtime_prepare` envelope ID. 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. 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 the Runtime or releasing the assignment closes its attachment; a bind under a later assignment takes the owner over once that attachment is closed and otherwise fails with `assignment_conflict`. 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`. 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. A quiesced Runtime admits only a release and the matching `environment_resume`, which carries the assignment that quiesced it. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 6635bfd95..9e522f9bb 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: e58e5acd22ad7bca6e5a7cd0caf550c09b533a0dae9411c6302284a81734cc1b +source_hash: dee8140c919774cce1c613720ff3c8bc9c984cb3a153e9451c02217f661137cc --- 此协议在 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)。 @@ -117,7 +117,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 上的服务。grant 是机密。Core 不向其他任何 Runtime 发送这两个字段。只带其中一个字段、或带其他 Environment 的 resource 的绑定以 `invalid_request` 失败。以相同的 Environment、resource 和 grant 重复绑定同一分配仍得到 `bound`;该分配的其他绑定以 `assignment_conflict` 失败。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 和连接存续得更久。Runtime 静默(quiesce)或分配被释放时关闭其 attachment;之后分配下的绑定在该 attachment 关闭后接管 owner,否则以 `assignment_conflict` 失败。它把每个 setup 步骤作为 Process 操作运行,操作 ID 即 `runtime_prepare` 的 envelope ID。无法观察到结果的文件变更或 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,从不替换为其他实现。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 和连接存续得更久。Runtime 静默(quiesce)或分配被释放时关闭其 attachment;之后分配下的绑定在该 attachment 关闭后接管 owner,否则以 `assignment_conflict` 失败。它把每个 setup 步骤作为 Process 操作运行,操作 ID 即 `runtime_prepare` 的 envelope ID。在产生任何作用前无法连到沙箱的 `workspace_write` 或 `runtime_prepare` 以 `rejected` 和 `resource_unavailable` 结束。无法观察到结果的文件变更或 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 连接时重发所有未确认的释放;失败的释放退避重试,等待最久的释放先发送,因此失败的释放不会拖延其他释放。已 quiesce 的 Runtime 只准入释放和匹配的 `environment_resume`,后者携带使其 quiesce 的分配。 From 1fec1c735642836606a532a405684aec0b8f0fe9 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 21:22:22 +0000 Subject: [PATCH 10/13] Refuse plugin stdio servers that take Environment credentials The agent host installed a plugin whose stdio MCP server declares env_vars, which admission then rejects for every execution of the Session. The owner now refuses it before staging, through the predicate that also sets a binding's environment_configuration authority. --- apps/daemon/internal/agent/mcp_binding.go | 11 ++++- .../daemon/internal/agent/mcp_binding_test.go | 6 ++- .../internal/agenthost/environment_linux.go | 8 ++-- .../agenthost/environment_linux_test.go | 46 +++++++++++++------ docs/runtime-protocol.md | 2 +- docs/zh/runtime-protocol.md | 4 +- 6 files changed, 54 insertions(+), 23 deletions(-) diff --git a/apps/daemon/internal/agent/mcp_binding.go b/apps/daemon/internal/agent/mcp_binding.go index a59eb8091..94c157b69 100644 --- a/apps/daemon/internal/agent/mcp_binding.go +++ b/apps/daemon/internal/agent/mcp_binding.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) // MCPBinding is the Runtime's transient effective declaration. Adapters project @@ -27,6 +28,14 @@ type MCPBinding struct { Stdio *proto.EnvironmentMCP } +// EnvironmentMCPCredentials reports whether an installed MCP server takes +// credentials from the Environment's configuration: a bearer token variable, +// literal headers or environment variables. Its binding's credential +// authority is environment_configuration. +func EnvironmentMCPCredentials(server agentplugin.MCPServer) bool { + return server.BearerTokenEnvVar != "" || len(server.HTTPHeaders) > 0 || len(server.EnvVars) > 0 +} + // ResolveMCPBindings combines public declarations with the frozen installation. // Public declarations retain explicit origin and Vault authority; installed MCP // retains Environment configuration authority. Neither may relocate implicitly. @@ -59,7 +68,7 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { if declaration.BearerTokenEnvVar != "" && installed.BearerToken == nil { return nil, errors.New("environment MCP credential unavailable") } - if installed.BearerToken != nil || len(declaration.HTTPHeaders) > 0 || len(declaration.EnvVars) > 0 { + if installed.BearerToken != nil || EnvironmentMCPCredentials(declaration) { item.CredentialAuthority = "environment_configuration" } if declaration.Type == "stdio" { diff --git a/apps/daemon/internal/agent/mcp_binding_test.go b/apps/daemon/internal/agent/mcp_binding_test.go index e1a020ede..77f8a3019 100644 --- a/apps/daemon/internal/agent/mcp_binding_test.go +++ b/apps/daemon/internal/agent/mcp_binding_test.go @@ -28,12 +28,14 @@ func TestMCPBindingsPreserveOriginAuthorityAndPolicy(t *testing.T) { local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ {Server: agentplugin.MCPServer{Name: "stdio", Type: "stdio", Command: "node", Args: []string{"tool.js"}}, PackageRoot: "plugins/proof", InstallationRoot: "/private/installed"}, {Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp", HTTPHeaders: map[string]string{"X-Selected": "literal"}}, BearerToken: &token}, + {Server: agentplugin.MCPServer{Name: "configured", Type: "stdio", Command: "node", EnvVars: []string{"TOOL_TOKEN"}}, PackageRoot: "plugins/proof", InstallationRoot: "/private/installed"}, }} got, err := ResolveMCPBindings(proto.PromptRequestPayload{LocalEnvironment: local}) - if err != nil || len(got) != 2 { + if err != nil || len(got) != 3 { t.Fatal("installed bindings unavailable", err) } - if got[0].ConnectionOrigin != "environment" || got[0].CredentialAuthority != "none" || got[0].Stdio.PackageRoot != "plugins/proof" || got[0].Required || got[0].AllowedTools != nil || got[1].CredentialAuthority != "environment_configuration" { + if got[0].ConnectionOrigin != "environment" || got[0].CredentialAuthority != "none" || got[0].Stdio.PackageRoot != "plugins/proof" || got[0].Required || got[0].AllowedTools != nil || + got[1].CredentialAuthority != "environment_configuration" || got[2].CredentialAuthority != "environment_configuration" { t.Fatal("installed identity or authority changed") } got[0].Stdio.Server.Args[0] = "mutated" diff --git a/apps/daemon/internal/agenthost/environment_linux.go b/apps/daemon/internal/agenthost/environment_linux.go index 43db8bda6..29453ae70 100644 --- a/apps/daemon/internal/agenthost/environment_linux.go +++ b/apps/daemon/internal/agenthost/environment_linux.go @@ -21,6 +21,7 @@ import ( "github.com/google/uuid" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/internal/agentbundle" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" @@ -467,15 +468,16 @@ func initializationFailed(err error) error { return &dispatch.InitializationFailure{} } -// checkPluginCredentials keeps a plugin's literal MCP headers out of the -// world: the agent host does not install a plugin that declares them. +// checkPluginCredentials refuses a plugin whose MCP server declares literal +// headers, which would put a credential in the world, or is a stdio server +// that takes credentials from the Environment, which no view runs. func checkPluginCredentials(tree agentcapabilities.Tree) error { bundle, err := agentplugin.Inspect(tree.Files) if err != nil { return agentcapabilities.ErrInvalid } for _, server := range bundle.MCP { - if len(server.HTTPHeaders) != 0 { + if len(server.HTTPHeaders) != 0 || server.Type == "stdio" && agent.EnvironmentMCPCredentials(server) { return agentcapabilities.ErrInvalid } } diff --git a/apps/daemon/internal/agenthost/environment_linux_test.go b/apps/daemon/internal/agenthost/environment_linux_test.go index 1d0dad944..d3a623ba1 100644 --- a/apps/daemon/internal/agenthost/environment_linux_test.go +++ b/apps/daemon/internal/agenthost/environment_linux_test.go @@ -11,8 +11,10 @@ import ( "errors" "io" "io/fs" + "maps" "os" "path" + "slices" "sync" "sync/atomic" "testing" @@ -23,6 +25,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/internal/agentskill" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" @@ -30,10 +33,11 @@ import ( ) // TestEnvironmentOwnerServesTheSandbox prepares an Environment with a Skill -// and a setup step through runtime_prepare, reopens it on a new Router -// without initializing it again, quiesces and resumes it, and checks that a -// File mutation whose outcome is unknown is never replayed. It runs with the -// view suite; see the comment there. +// and a setup step through runtime_prepare, refuses a plugin whose MCP server +// it cannot serve, reopens the Environment on a new Router without +// initializing it again, quiesces and resumes it, and checks that a File +// mutation whose outcome is unknown is never replayed. It runs with the view +// suite; see the comment there. func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { if os.Getenv(gateEnv) != "1" { t.Skipf("set %s=1 and run the test binary as root in a throwaway container; see the view suite", gateEnv) @@ -76,17 +80,28 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { req.LocalEnvironment.Capabilities = true // Prepare as Core does: configure, a setup step that sees the tool - // environment, the Skill and finalize, then the Executor. + // environment, the Skill and finalize, then the Executor. A plugin whose + // stdio MCP server takes credentials from the Environment fails first, + // before anything of it is staged. first := &daemon{host: h} first.route(t, reg) first.assign(t, b) + plugin := agentplugin.Metadata{Type: "inline", Name: "package", Description: "Package proof."} + configured := archive(t, "package", map[string][]byte{".codex-plugin/plugin.json": []byte(`{"name":"package","description":"Package proof."}`), + ".mcp.json": []byte(`{"mcpServers":{"local":{"command":"python3","env_vars":["PROBE"]}}}`)}) + if r := first.runtimePrepare(t, b, proto.RuntimePreparePayload{Action: "plugin", Plugin: &plugin}, configured); r.Outcome != "failed" { + t.Fatalf("runtime_prepare of a plugin whose stdio server takes Environment credentials: %+v, want failed", r) + } + if _, err := os.Lstat(path.Join(agentcapabilities.Directory, "plugins")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("the refused plugin was staged: %v", err) + } for _, step := range []struct { begin proto.RuntimePreparePayload data []byte }{ {proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"PROBE": "probe-value"}}}, nil}, {proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Command: `printf '%s\n' "$PROBE" >> setup.txt`}}, nil}, - {proto.RuntimePreparePayload{Action: "skill", Skill: &skill}, skillArchive(t, manifest)}, + {proto.RuntimePreparePayload{Action: "skill", Skill: &skill}, archive(t, "probe-skill", map[string][]byte{"SKILL.md": manifest})}, {proto.RuntimePreparePayload{Action: "finalize", Sources: req.LocalEnvironment.CapabilitySources}, nil}, } { if r := first.runtimePrepare(t, b, step.begin, step.data); r.Outcome != "completed" { @@ -227,18 +242,21 @@ func checkSetup(t *testing.T) { } } -func skillArchive(t *testing.T, manifest []byte) []byte { +// archive zips files below the archive root root. +func archive(t *testing.T, root string, files map[string][]byte) []byte { t.Helper() var b bytes.Buffer w := zip.NewWriter(&b) - f, err := w.Create("probe-skill/SKILL.md") - if err == nil { - _, err = f.Write(manifest) - } - if err == nil { - err = w.Close() + for _, name := range slices.Sorted(maps.Keys(files)) { + f, err := w.Create(root + "/" + name) + if err == nil { + _, err = f.Write(files[name]) + } + if err != nil { + t.Fatal(err) + } } - if err != nil { + if err := w.Close(); err != nil { t.Fatal(err) } return b.Bytes() diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 105bf1513..9fca31b63 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -115,7 +115,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. 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 with the same Environment, resource and grant is `bound` again; any other bind of it fails with `assignment_conflict`. 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 the Runtime or releasing the assignment closes its attachment; a bind under a later assignment takes the owner over once that attachment is closed and otherwise fails with `assignment_conflict`. 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`. 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. 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 the Runtime or releasing the assignment closes its attachment; a bind under a later assignment takes the owner over once that attachment is closed and otherwise fails with `assignment_conflict`. 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. A quiesced Runtime admits only a release and the matching `environment_resume`, which carries the assignment that quiesced it. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 9e522f9bb..854fcc55e 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: dee8140c919774cce1c613720ff3c8bc9c984cb3a153e9451c02217f661137cc +source_hash: 87a7ecf9bfe2dbf03725c69ac68ae4abf4ee7b25206e49e72a4eb45fc160ca35 --- 此协议在 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)。 @@ -117,7 +117,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 上的服务。grant 是机密。Core 不向其他任何 Runtime 发送这两个字段。只带其中一个字段、或带其他 Environment 的 resource 的绑定以 `invalid_request` 失败。以相同的 Environment、resource 和 grant 重复绑定同一分配仍得到 `bound`;该分配的其他绑定以 `assignment_conflict` 失败。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 和连接存续得更久。Runtime 静默(quiesce)或分配被释放时关闭其 attachment;之后分配下的绑定在该 attachment 关闭后接管 owner,否则以 `assignment_conflict` 失败。它把每个 setup 步骤作为 Process 操作运行,操作 ID 即 `runtime_prepare` 的 envelope ID。在产生任何作用前无法连到沙箱的 `workspace_write` 或 `runtime_prepare` 以 `rejected` 和 `resource_unavailable` 结束。无法观察到结果的文件变更或 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,从不替换为其他实现。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 和连接存续得更久。Runtime 静默(quiesce)或分配被释放时关闭其 attachment;之后分配下的绑定在该 attachment 关闭后接管 owner,否则以 `assignment_conflict` 失败。它把每个 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 连接时重发所有未确认的释放;失败的释放退避重试,等待最久的释放先发送,因此失败的释放不会拖延其他释放。已 quiesce 的 Runtime 只准入释放和匹配的 `environment_resume`,后者携带使其 quiesce 的分配。 From 5a4de0b2c6db6fe70f304b8100657b120ec64b75 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 21:22:58 +0000 Subject: [PATCH 11/13] Compose the agent host's Harness declarations once Config.Harnesses was composed when its kinds were registered and again by the agent host's Registry. Because composition only narrows, a registry built on a host without a local workspace lost local Environments for good. Config.Harnesses now holds the declarations as adapters state them, and Registry composes them once with the Environments the agent host serves. --- apps/daemon/internal/agenthost/admit_linux_test.go | 4 ++++ apps/daemon/internal/agenthost/agenthost.go | 6 ++++-- apps/daemon/internal/agenthost/agenthost_linux_test.go | 10 ++++++---- apps/daemon/internal/agenthost/executor_linux.go | 3 ++- .../internal/agenthostqualify/qualify_linux_test.go | 3 ++- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index db329b709..a9f776e58 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -164,6 +164,10 @@ func TestRegistryRunsKindsWithViews(t *testing.T) { var kinds []string for _, info := range (&Host{cfg: f.cfg}).Registry().SupportedAgentKinds() { kinds = append(kinds, info.Kind) + if caps := info.Capabilities; !caps.LocalEnvironment.IsSupported() || !caps.EnvironmentNone.IsSupported() || + !caps.WorkspaceReadPreparation.IsSupported() || !caps.WorkspaceOutputExport.IsSupported() { + t.Errorf("%s does not run in the Environments the agent host serves: %+v", info.Kind, caps) + } } slices.Sort(kinds) if !slices.Equal(kinds, []string{"masked", "shimmed", "viewed"}) { diff --git a/apps/daemon/internal/agenthost/agenthost.go b/apps/daemon/internal/agenthost/agenthost.go index 345f6a6f3..ddd15066f 100644 --- a/apps/daemon/internal/agenthost/agenthost.go +++ b/apps/daemon/internal/agenthost/agenthost.go @@ -32,8 +32,10 @@ type Config struct { // RuntimeID and Credential authenticate the agent host to the relay. RuntimeID sandboxwire.ID Credential []byte - // Harnesses holds the Harness declarations. A kind runs only when it - // declares an agent.View. + // Harnesses holds the Harness declarations as their adapters state + // them, registered with RegisterKind and RegisterView: Registry composes + // them with the Environments the agent host serves. A kind runs only + // when it declares an agent.View. Harnesses *agent.Registry // Shim is the absolute host path of the static oac-process-shim binary. Shim string diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index d550f41c9..18441612d 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -70,13 +70,15 @@ func newConfig(t *testing.T, reg *agent.Registry, ca *x509.Certificate) Config { RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: reg, Shim: exe, CADir: dir} } -// register declares kind with view, or without one when view is nil. +// register declares kind with view, or without one when view is nil, as +// Config.Harnesses holds it. func register(reg *agent.Registry, kind string, view *agent.View) { info := proto.SupportedAgentKind{Kind: kind, Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ LocalEnvironment: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, MCPHTTPTools: proto.CapabilitySupported, MCPHTTPBearerAuth: proto.CapabilitySupported})} - declaration := agent.Declaration{Info: info, - Configuration: harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: string(modelprovider.Anthropic)}}}} - reg.Register(declaration, agent.Runtime{Info: info, View: view}, agent.EnvironmentSupport{Local: true, None: true}) + reg.RegisterKind(info, harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: string(modelprovider.Anthropic)}}}) + if view != nil { + reg.RegisterView(kind, *view) + } } // request is a Session request the agent host admits. diff --git a/apps/daemon/internal/agenthost/executor_linux.go b/apps/daemon/internal/agenthost/executor_linux.go index 3b93b4a01..8a2767938 100644 --- a/apps/daemon/internal/agenthost/executor_linux.go +++ b/apps/daemon/internal/agenthost/executor_linux.go @@ -42,7 +42,8 @@ func (h *Host) openExecutor(ctx context.Context, req proto.PromptRequestPayload) } // registry registers each kind in harnesses that declares a view, with -// factory as its Executor factory, in a local Environment and with +// factory as its Executor factory. It composes each declaration once, with +// the Environments the agent host serves: a local Environment and // environment none. func registry(harnesses *agent.Registry, factory agent.ExecutorFactory) *agent.Registry { reg := agent.NewRegistry() diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index 98865580a..fd2ad635b 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -113,7 +113,8 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { if runtime == nil || runtime.View == nil { t.Fatalf("%s declares no agent-host view; discovery reported why above", kind) } - reg.Register(declaration, *runtime, agent.EnvironmentSupport{Local: true, None: true}) + reg.RegisterKind(runtime.Info, declaration.Configuration) + reg.RegisterView(kind, *runtime.View) qualify(t, h, cfg, sb, kind, runtime.Info.Capabilities, sessionModel(t, raw, key)) }) } From 74b501d05f9356c43335061bfbacba033a80064f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 21:24:09 +0000 Subject: [PATCH 12/13] Take the agent host's path rule and bounds from the guest The agent host copied the guest's workspace path rule, export bounds and setup kill grace. localworkspace now exports each once, uses its one path rule in its three checks, and the agent host reads them from there. The processbroker comment no longer lists TMPDIR among the sandbox values. --- .../internal/agenthost/environment_linux.go | 24 ++++++++-------- apps/daemon/internal/agenthost/setup_linux.go | 7 ++--- apps/daemon/internal/agenthost/world_linux.go | 10 ++----- .../internal/localworkspace/directory.go | 4 +-- .../internal/localworkspace/export_test.go | 2 +- .../internal/localworkspace/native_files.go | 28 +++++++++++++------ .../runtime_initialization_process.go | 6 +++- apps/daemon/internal/localworkspace/write.go | 4 +-- apps/daemon/internal/processbroker/config.go | 2 +- 9 files changed, 44 insertions(+), 43 deletions(-) diff --git a/apps/daemon/internal/agenthost/environment_linux.go b/apps/daemon/internal/agenthost/environment_linux.go index 29453ae70..a1c50dfbd 100644 --- a/apps/daemon/internal/agenthost/environment_linux.go +++ b/apps/daemon/internal/agenthost/environment_linux.go @@ -23,6 +23,7 @@ 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" @@ -43,10 +44,6 @@ const ( toolEnvironmentBytes = 1 << 20 // writeBound bounds a workspace write, which dispatch never cancels. writeBound = time.Minute - // The export bounds are the guest's. - exportFileBytes = 200 << 20 - exportBatchBytes = 500 << 20 - exportEntries = 4096 ) // sandboxBaseline is the environment of the sandbox image that a Session's @@ -486,7 +483,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 || !validPath(relative) || len(data) > proto.RuntimePrepareMaxBytes { + if !ok || !localworkspace.ValidPath(relative) || len(data) > proto.RuntimePrepareMaxBytes { return agentcapabilities.ErrInvalid } workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) @@ -516,7 +513,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 || !validPath(relative) { + if !ok || !localworkspace.ValidPath(relative) { return agentcapabilities.ErrInvalid } cwd = input.CWD @@ -602,7 +599,7 @@ func (o *environment) ListWorkspaceDirectory(ctx context.Context, p string, limi switch { case o.id == "": return result, dispatch.ErrWorkspaceReadUnavailable - case p != "" && !validPath(p) || limit < 1: + case p != "" && !localworkspace.ValidPath(p) || limit < 1: return result, dispatch.ErrWorkspaceReadInvalid } if err := o.acquire(ctx); err != nil { @@ -655,7 +652,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 || !validPath(p): + case len(data) > proto.WorkspaceWriteMaxBytes || !localworkspace.ValidPath(p): return result, dispatch.ErrWorkspaceWriteInvalid case o.id == "": return result, dispatch.ErrEnvironmentUnavailable @@ -733,6 +730,7 @@ 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. type export struct { w *world archive *tar.Writer @@ -741,19 +739,19 @@ type export struct { } func (x *export) walk(ctx context.Context, dir sandboxfs.NodeRef, name string, depth int) error { - if depth > 64 || len(name) > 4096 { + if depth > localworkspace.ExportDepth { return errors.New("workspace export exceeds traversal bound") } - entries, err := x.w.sorted(ctx, dir, exportEntries) + entries, err := x.w.sorted(ctx, dir, localworkspace.ExportEntries) if err != nil { return err } - if x.entries += len(entries); x.entries > exportEntries { + if x.entries += len(entries); x.entries > localworkspace.ExportEntries { return errors.New("workspace export exceeds entry bound") } for _, e := range entries { child := name + "/" + string(e.Name) - if !validPath(child) { + if !localworkspace.ValidPath(child) { return fs.ErrInvalid } switch e.Entry.Attr.Mode & sandboxfs.ModeType { @@ -784,7 +782,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 > exportFileBytes || size > exportBatchBytes-x.bytes { + if !isType(before.Attr, sandboxfs.ModeRegular) || size > localworkspace.ExportFileBytes || size > localworkspace.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 b19cfc740..d53761f6a 100644 --- a/apps/daemon/internal/agenthost/setup_linux.go +++ b/apps/daemon/internal/agenthost/setup_linux.go @@ -11,16 +11,13 @@ 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" ) -// setupGraceMillis is the grace a cancelled setup step gets before KILL, as -// the guest's. -const setupGraceMillis = 250 - // 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} { @@ -102,7 +99,7 @@ func (o *environment) run(ctx context.Context, id sandboxwire.ID, program string } case <-cancelled: cancelled = nil - if err := op.Cancel(late, setupGraceMillis); err != nil { + if err := op.Cancel(late, uint32(localworkspace.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 31579aefa..fa510f2b0 100644 --- a/apps/daemon/internal/agenthost/world_linux.go +++ b/apps/daemon/internal/agenthost/world_linux.go @@ -11,6 +11,7 @@ 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/sandboxfs" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" @@ -124,20 +125,13 @@ func isErrno(err error, errno sandboxfs.Errno) bool { func isType(a sandboxfs.Attr, t uint32) bool { return a.Mode&sandboxfs.ModeType == t } -// validPath reports whether p is a relative path as the guest accepts a -// workspace or installation path: plain names 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") -} - // components splits a path below a directory into its names; "", "." and // "/" name the directory itself. func components(p string) ([]string, error) { if p = strings.Trim(p, "/"); p == "" || p == "." { return nil, nil } - if !validPath(p) { + if !localworkspace.ValidPath(p) { return nil, fs.ErrInvalid } return strings.Split(p, "/"), nil diff --git a/apps/daemon/internal/localworkspace/directory.go b/apps/daemon/internal/localworkspace/directory.go index ba0501978..80b0594e8 100644 --- a/apps/daemon/internal/localworkspace/directory.go +++ b/apps/daemon/internal/localworkspace/directory.go @@ -2,15 +2,13 @@ package localworkspace import ( "context" - "io/fs" - "strings" "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 || len(path) > 4096 || strings.ContainsAny(path, "\\\x00\r\n") || (path != "" && (path == "." || !fs.ValidPath(path))) { + if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || path != "" && !ValidPath(path) { return dispatch.WorkspaceDirectoryResult{}, dispatch.ErrWorkspaceReadInvalid } return b.listNativeDirectory(ctx, path, limit) diff --git a/apps/daemon/internal/localworkspace/export_test.go b/apps/daemon/internal/localworkspace/export_test.go index f396f9f2d..f3224dba5 100644 --- a/apps/daemon/internal/localworkspace/export_test.go +++ b/apps/daemon/internal/localworkspace/export_test.go @@ -68,7 +68,7 @@ func TestNativeExportMissingOutputsAndBound(t *testing.T) { if err != nil { t.Fatal(err) } - if err = f.Truncate(artifactFileBytes + 1); err != nil { + if err = f.Truncate(ExportFileBytes + 1); err != nil { t.Fatal(err) } f.Close() diff --git a/apps/daemon/internal/localworkspace/native_files.go b/apps/daemon/internal/localworkspace/native_files.go index b11f61540..832818445 100644 --- a/apps/daemon/internal/localworkspace/native_files.go +++ b/apps/daemon/internal/localworkspace/native_files.go @@ -16,13 +16,20 @@ 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 path == "." || len(path) > 4096 || !fs.ValidPath(path) || strings.ContainsAny(path, "\\\x00\r\n") { + if !ValidPath(path) { return "", fs.ErrInvalid } return filepath.Localize(path) @@ -144,9 +151,14 @@ func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) return dispatch.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil } -const artifactFileBytes int64 = 200 << 20 -const artifactBatchBytes int64 = 500 << 20 -const artifactEntries = 4096 +// 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 @@ -189,20 +201,20 @@ func (x *nativeExport) walk(path string, depth int) error { if err := x.ctx.Err(); err != nil { return err } - if depth > 64 || len(path) > 4096 { + 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(artifactEntries + 1) + entries, err := dir.ReadDir(ExportEntries + 1) _ = dir.Close() if err != nil && err != io.EOF { return err } x.entries += len(entries) - if x.entries > artifactEntries { + 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() }) @@ -246,7 +258,7 @@ func (x *nativeExport) append(path string) error { return err } size := before.Size() - if !before.Mode().IsRegular() || size < 0 || size > artifactFileBytes || size > artifactBatchBytes-x.bytes { + if !before.Mode().IsRegular() || size < 0 || size > ExportFileBytes || size > ExportBatchBytes-x.bytes { return errors.New("workspace export exceeds file bound") } x.bytes += size diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_process.go b/apps/daemon/internal/localworkspace/runtime_initialization_process.go index 0af92043d..481392ef1 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_process.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_process.go @@ -99,6 +99,10 @@ func initializationEnvironment(configured map[string]string) []string { 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 { @@ -111,7 +115,7 @@ func runInitializationProcess(ctx context.Context, binary string, args []string, return &dispatch.InitializationFailure{} } process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, - Dir: directory, Env: env, KillTimeout: 250 * time.Millisecond}) + Dir: directory, Env: env, KillTimeout: InitializationGrace}) if err != nil { return ErrInitializationUnconfirmed } diff --git a/apps/daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go index 6f6fdc5bc..87e921c21 100644 --- a/apps/daemon/internal/localworkspace/write.go +++ b/apps/daemon/internal/localworkspace/write.go @@ -3,8 +3,6 @@ package localworkspace import ( "context" "errors" - "io/fs" - "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/dispatch" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -16,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 || len(path) > 4096 || path == "." || !fs.ValidPath(path) || strings.ContainsAny(path, "\\\x00\r\n") { + if len(data) > WriteMaxBytes || !ValidPath(path) { return result, dispatch.ErrWorkspaceWriteInvalid } if ctx.Err() != nil { diff --git a/apps/daemon/internal/processbroker/config.go b/apps/daemon/internal/processbroker/config.go index 495243a20..97596e84d 100644 --- a/apps/daemon/internal/processbroker/config.go +++ b/apps/daemon/internal/processbroker/config.go @@ -70,7 +70,7 @@ type Command struct { type Environment struct { // Pass names the shim environment entries that pass through. Pass []string - // Sandbox holds fixed sandbox values such as HOME, PATH, TMPDIR and LANG. + // Sandbox holds fixed sandbox values: HOME, PATH and LANG. Sandbox map[string]string // Tool is the Environment's tool environment. Tool map[string]string From 11d3af9542e50badf86127178bbd13ab7b005539 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 21:30:12 +0000 Subject: [PATCH 13/13] Probe the Environment owner's world without a test-only dependency --- .../internal/agenthost/environment_linux.go | 14 +- .../agenthost/environment_linux_test.go | 131 +++++++++--------- .../internal/agenthost/executor_linux.go | 2 - apps/daemon/internal/agenthost/setup_linux.go | 2 +- 4 files changed, 71 insertions(+), 78 deletions(-) diff --git a/apps/daemon/internal/agenthost/environment_linux.go b/apps/daemon/internal/agenthost/environment_linux.go index a1c50dfbd..0659bf62e 100644 --- a/apps/daemon/internal/agenthost/environment_linux.go +++ b/apps/daemon/internal/agenthost/environment_linux.go @@ -214,7 +214,7 @@ func (o *environment) attach(ctx context.Context) (*world, error) { lost := new(atomic.Bool) // The link calls fail under its lock, so fail only records the loss. o.link, o.lost = newLinkOwner(o.d.dial, o.binding, sandboxwire.NewID(), func(error) { lost.Store(true) }), lost - st, err := o.open(ctx, sandboxlink.ServiceFile, sandboxfs.Version) + st, err := o.link.open(ctx, sandboxlink.ServiceFile, sandboxfs.Version) if err != nil { return nil, err } @@ -224,18 +224,6 @@ func (o *environment) attach(ctx context.Context) (*world, error) { return o.world, nil } -// open opens a stream of service on the owner's attachment. -func (o *environment) open(ctx context.Context, service sandboxlink.Service, version uint16) (io.ReadWriteCloser, error) { - st, err := o.link.open(ctx, service, version) - if err != nil { - return nil, err - } - if o.d.stream != nil { - return o.d.stream(service, st), nil - } - return st, nil -} - // drain closes the owner's attachment. It keeps the attachment when the // relay did not confirm its close, so that a later drain retries. func (o *environment) drain() error { diff --git a/apps/daemon/internal/agenthost/environment_linux_test.go b/apps/daemon/internal/agenthost/environment_linux_test.go index d3a623ba1..33f5a62ac 100644 --- a/apps/daemon/internal/agenthost/environment_linux_test.go +++ b/apps/daemon/internal/agenthost/environment_linux_test.go @@ -59,8 +59,7 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { cfg := Config{StateDir: t.TempDir(), RelayURL: sb.url, RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: harnesses} sb.auth.AddRuntime(cfg.Credential, cfg.RuntimeID) sb.ready(t, cfg) - p := &probe{} - h := &Host{cfg: cfg, owners: owners{d: deps{dial: relayDial(cfg), stream: p.stream}}} + h := &Host{cfg: cfg, owners: owners{d: deps{dial: relayDial(cfg)}}} // The factory records what the owner prepared: admission does not run // Skills in views yet. prepared := make(chan preparedExecutor, 4) @@ -130,7 +129,7 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { // Reopen: a new Router checks the completed installation and neither // changes the world nor runs a step. - p.reset(0) + p := h.probe(t, b, 0) second := &daemon{host: h} second.route(t, reg) id, status := second.prepare(t, b, req) @@ -140,8 +139,8 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { if got := <-prepared; len(got.req.LocalEnvironment.Skills) != 1 || got.env.Tool["PROBE"] != "probe-value" { t.Fatalf("the reopened Executor's Environment is %+v, %+v", got.req.LocalEnvironment, got.env) } - if n := p.counts(); n.mutations != 0 || n.processes != 0 { - t.Fatalf("the reopen sent %d File mutations and opened %d Process streams", n.mutations, n.processes) + if requests, mutations := p.counts(); requests == 0 || mutations != 0 { + t.Fatalf("the reopen sent %d File requests, %d of them mutations, on the probed world", requests, mutations) } checkSetup(t) @@ -160,7 +159,6 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { if err := second.router.Resume(ref(b), suspension, second); err != nil { t.Fatalf("Resume: %v", err) } - p.reset(0) id, status = second.prepare(t, b, req) if status.State != "ready" { t.Fatalf("the resumed preparation is %s (%s), want ready", status.State, status.ErrorCode) @@ -168,14 +166,14 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { if r := second.read(t, b, status.Handle); r.Outcome != "completed" || !lists(r.Directory, "setup.txt") { t.Fatalf("the resumed read is %+v", r) } - if p.counts().files != 1 { - t.Fatalf("the resumed read opened %d File streams, want 1", p.counts().files) + if h.drained(t, b) { + t.Fatal("the resumed owner served the read without an attachment") } second.release(t, b, id, status.Handle) // An uncertain File mutation quarantines the owner: no later write or - // runtime_prepare on any Router sends one again. - p.reset(sandboxfs.OpLink) + // runtime_prepare on any Router opens an attachment to send one again. + h.probe(t, b, sandboxfs.OpLink) if r := second.write(t, b, "notes/uncertain.txt", []byte("uncertain")); r.Outcome != "unknown" { t.Fatalf("the interrupted write is %+v, want unknown", r) } @@ -185,12 +183,11 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { if !h.drained(t, b) { t.Fatal("the owner kept its attachment after the Router shut down") } - p.reset(0) third := &daemon{host: h} third.route(t, reg) third.assign(t, b) - if r := third.write(t, b, "notes/uncertain.txt", []byte("uncertain")); r.Outcome != "unknown" { - t.Fatalf("the write after an uncertain one is %+v, want unknown", r) + if r := third.write(t, b, "notes/uncertain.txt", []byte("uncertain")); r.Outcome != "unknown" || !h.drained(t, b) { + t.Fatalf("the write after an uncertain one is %+v, want unknown without an attachment", r) } // An unknown outcome fences the Router's transfers, so the next one runs // on another. @@ -198,11 +195,8 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { fourth := &daemon{host: h} fourth.route(t, reg) fourth.assign(t, b) - if r := fourth.runtimePrepare(t, b, proto.RuntimePreparePayload{Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/notes/file.txt"}}, []byte("file")); r.Outcome != "unknown" { - t.Fatalf("the runtime_prepare after an uncertain write is %+v, want unknown", r) - } - if n := p.counts(); n.files != 0 || n.mutations != 0 { - t.Fatalf("the quarantined owner opened %d File streams and sent %d mutations", n.files, n.mutations) + if r := fourth.runtimePrepare(t, b, proto.RuntimePreparePayload{Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/notes/file.txt"}}, []byte("file")); r.Outcome != "unknown" || !h.drained(t, b) { + t.Fatalf("the runtime_prepare after an uncertain write is %+v, want unknown without an attachment", r) } for _, name := range []string{"uncertain.txt", "file.txt"} { if _, err := os.Lstat(path.Join(sandboxWorkspace, "notes", name)); !errors.Is(err, fs.ErrNotExist) { @@ -262,9 +256,9 @@ func archive(t *testing.T, root string, files map[string][]byte) []byte { return b.Bytes() } -// drained reports whether b's Session's Environment owner holds no -// attachment. -func (h *Host) drained(t *testing.T, b Binding) bool { +// owner returns b's Session's Environment owner, acquired; the caller +// releases it. +func (h *Host) owner(t *testing.T, b Binding) *environment { t.Helper() h.owners.mu.Lock() o := h.owners.m[b.SessionID] @@ -275,10 +269,47 @@ func (h *Host) drained(t *testing.T, b Binding) bool { if err := o.acquire(context.Background()); err != nil { t.Fatal(err) } + return o +} + +// drained reports whether b's Session's Environment owner holds no +// attachment. +func (h *Host) drained(t *testing.T, b Binding) bool { + t.Helper() + o := h.owner(t, b) defer o.release() return o.link == nil } +// probe attaches b's Session's Environment owner as attach does, over a +// probed File stream, and returns the probe. The probe breaks the stream +// before it sends the first request of armed; zero arms none. +func (h *Host) probe(t *testing.T, b Binding, armed sandboxfs.Op) *probed { + t.Helper() + o := h.owner(t, b) + defer o.release() + ctx, cancel := context.WithTimeout(context.Background(), wait) + defer cancel() + // The File service attaches one world per attachment. + if err := o.drain(); err != nil { + t.Fatal(err) + } + lost := new(atomic.Bool) + o.link, o.lost = newLinkOwner(o.d.dial, o.binding, sandboxwire.NewID(), func(error) { lost.Store(true) }), lost + st, err := o.link.open(ctx, sandboxlink.ServiceFile, sandboxfs.Version) + if err != nil { + t.Fatal(err) + } + p := &probed{ReadWriteCloser: st} + if o.world, err = attachWorld(ctx, p, &o.uncertain); err != nil { + t.Fatal(err) + } + p.mu.Lock() + p.requests, p.mutations, p.armed = 0, 0, armed + p.mu.Unlock() + return p +} + // runtimePrepare sends one runtime_prepare transfer of data on b's Session // and returns its result. func (dm *daemon) runtimePrepare(t *testing.T, b Binding, begin proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { @@ -362,47 +393,22 @@ func lists(d *proto.WorkspaceDirectoryResult, name string) bool { return false } -// probe observes the streams an Environment owner opens: it counts File -// streams, File mutations and Process streams, and breaks the File stream -// that sends the next request of an armed operation before sending it. -type probe struct { - mu sync.Mutex - n probeCounts - armed sandboxfs.Op +// probed counts the File requests and mutations sent on a stream and breaks +// it before it sends the first request of armed. +type probed struct { + io.ReadWriteCloser + mu sync.Mutex + requests, mutations int + armed sandboxfs.Op } -type probeCounts struct{ files, mutations, processes int } - var mutations = map[sandboxfs.Op]bool{sandboxfs.OpSetAttr: true, sandboxfs.OpCreate: true, sandboxfs.OpWrite: true, sandboxfs.OpFsync: true, sandboxfs.OpMkdir: true, sandboxfs.OpUnlink: true, sandboxfs.OpRmdir: true, sandboxfs.OpRename: true, sandboxfs.OpLink: true, sandboxfs.OpSymlink: true} -// reset clears the counts and arms op; zero arms none. -func (p *probe) reset(op sandboxfs.Op) { - p.mu.Lock() - defer p.mu.Unlock() - p.n, p.armed = probeCounts{}, op -} - -func (p *probe) counts() probeCounts { - p.mu.Lock() - defer p.mu.Unlock() - return p.n -} - -func (p *probe) stream(service sandboxlink.Service, st io.ReadWriteCloser) io.ReadWriteCloser { - p.mu.Lock() - defer p.mu.Unlock() - if service == sandboxlink.ServiceProcess { - p.n.processes++ - return st - } - p.n.files++ - return &probed{ReadWriteCloser: st, p: p} -} - -type probed struct { - io.ReadWriteCloser - p *probe +func (s *probed) counts() (requests, mutations int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.requests, s.mutations } // Write sees one whole frame per call, as sandboxwire.WriteFrame writes it. @@ -412,15 +418,16 @@ func (s *probed) Write(b []byte) (int, error) { return 0, err } op := sandboxfs.Op(f.Type) - s.p.mu.Lock() + s.mu.Lock() + s.requests++ if mutations[op] { - s.p.n.mutations++ + s.mutations++ } - broken := op == s.p.armed + broken := op == s.armed if broken { - s.p.armed = 0 + s.armed = 0 } - s.p.mu.Unlock() + s.mu.Unlock() if broken { s.Close() return 0, errors.New("the probe broke the stream") diff --git a/apps/daemon/internal/agenthost/executor_linux.go b/apps/daemon/internal/agenthost/executor_linux.go index 8a2767938..26848a8e5 100644 --- a/apps/daemon/internal/agenthost/executor_linux.go +++ b/apps/daemon/internal/agenthost/executor_linux.go @@ -23,8 +23,6 @@ import ( type deps struct { dial dialFunc tasks listTasks - // stream wraps each stream an Environment owner opens; nil keeps it. - stream func(sandboxlink.Service, io.ReadWriteCloser) io.ReadWriteCloser } // Registry returns the kinds the agent host runs, for the daemon's dispatch diff --git a/apps/daemon/internal/agenthost/setup_linux.go b/apps/daemon/internal/agenthost/setup_linux.go index d53761f6a..8a00ff3cf 100644 --- a/apps/daemon/internal/agenthost/setup_linux.go +++ b/apps/daemon/internal/agenthost/setup_linux.go @@ -36,7 +36,7 @@ func strongestScope(caps sp.Capabilities) (sp.Scope, error) { // otherwise. When ctx ends first, run cancels the step and waits closeBound // for it to settle. A step that may run unobserved quarantines the owner. func (o *environment) run(ctx context.Context, id sandboxwire.ID, program string, args []string, env map[string]string, cwd string) error { - rw, err := o.open(ctx, sandboxlink.ServiceProcess, sp.Version) + rw, err := o.link.open(ctx, sandboxlink.ServiceProcess, sp.Version) if err != nil { return dispatch.ErrEnvironmentUnavailable }