From 1eced33cb7e6e49b2a1dd72559b862cfa9af895c Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 12:43:08 +0000 Subject: [PATCH 1/3] Bind the Environment workspace before preparation --- .../internal/agent/codex/recovery_test.go | 2 +- apps/daemon/internal/agenthost/admit.go | 4 +- .../internal/agenthost/admit_linux_test.go | 12 +- .../agenthost/agenthost_linux_test.go | 18 ++- .../internal/agenthost/environment_linux.go | 45 +++--- .../agenthost/environment_linux_test.go | 128 +++++++++++++++--- .../internal/agenthost/host_linux_test.go | 2 +- .../internal/agenthost/view_linux_test.go | 6 +- .../agenthostqualify/qualify_linux_test.go | 15 +- apps/daemon/internal/dispatch/assignment.go | 15 +- .../internal/dispatch/assignment_test.go | 80 ++++++++++- .../internal/dispatch/environment_test.go | 4 +- .../internal/dispatch/preparation_test.go | 2 +- apps/daemon/internal/dispatch/router_test.go | 6 +- apps/daemon/internal/dispatch/suspend_test.go | 5 +- contracts/agents-api/environments.md | 8 +- contracts/agents-api/zh/environments.md | 10 +- docs/runtime-protocol.md | 4 +- docs/zh/runtime-protocol.md | 6 +- internal/agentdaemon/proto/assignment.go | 28 ++-- internal/agentdaemon/proto/assignment_test.go | 53 ++++++++ internal/agentdaemon/proto/envelope_test.go | 1 + internal/agentdaemon/proto/environment.go | 9 +- .../core/internal/db/queries/sandbox_link.sql | 3 +- .../core/internal/db/sqlc/sandbox_link.sql.go | 29 ++-- .../internal/environmentconfig/placement.go | 75 ++++++++++ .../environment_capabilities_test.go | 9 +- .../execution/environment_directory.go | 3 +- .../execution/environment_file_write.go | 3 +- .../execution/environment_placement.go | 70 +--------- .../execution/environment_placement_test.go | 3 +- .../internal/execution/runtime_lifecycle.go | 3 +- services/core/internal/execution/support.go | 3 +- .../core/internal/execution/worker_device.go | 3 +- .../persistence/postgres/sessionpg/link.go | 3 +- services/core/internal/runtimedevice/link.go | 8 +- .../internal/runtimegateway/assignment.go | 5 +- services/core/internal/runtimegateway/link.go | 33 +++-- .../runtimegateway/link_binding_test.go | 64 +++++++++ .../integration/environment_directory_test.go | 8 +- ...onment_file_write_semantics_public_test.go | 12 +- .../tests/integration/link_authority_test.go | 8 +- .../local_environment_file_write_test.go | 4 +- .../local_environment_worker_test.go | 7 +- 44 files changed, 598 insertions(+), 221 deletions(-) create mode 100644 internal/agentdaemon/proto/assignment_test.go create mode 100644 services/core/internal/environmentconfig/placement.go create mode 100644 services/core/internal/runtimegateway/link_binding_test.go diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index adf984d62..beb01be6f 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -156,7 +156,7 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { t.Fatal(err) } req.DisableExecutionEnvironment = false - req.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}} + req.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString(), CapabilitySources: &agentcapabilities.Input{}} req.WorkspaceRoot = cwd } e, err := testExecutor(t, "complete", req, cfg) diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index cfe64e806..7aff453ec 100644 --- a/apps/daemon/internal/agenthost/admit.go +++ b/apps/daemon/internal/agenthost/admit.go @@ -100,8 +100,8 @@ func admit(cfg Config, roots *x509.CertPool, req agent.PrepareRequest, env Envir switch { case local == nil && !none: return nil, invalidSession("a Session with neither a workspace nor environment none is an incomplete binding") - case local != nil && !isViewPath(local.WorkspaceDirectory): - return nil, invalidSession("workspace %q is not absolute and clean", local.WorkspaceDirectory) + case local != nil && !isViewPath(req.WorkspaceRoot): + return nil, invalidSession("workspace %q is not absolute and clean", req.WorkspaceRoot) 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") } diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index 7eb489e46..0bb0c0595 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -77,7 +77,7 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { "view meeting the agent host's /etc": {"masked", func(*agent.PrepareRequest) {}, []error{ErrUnsupported, agent.ErrInvalidView}}, "incomplete binding": {"viewed", func(r *agent.PrepareRequest) { r.LocalEnvironment = nil }, []error{ErrInvalidSession}}, "shim name without PATH": {"shimmed", func(*agent.PrepareRequest) {}, []error{ErrInvalidSession}}, - "relative workspace": {"viewed", func(r *agent.PrepareRequest) { r.LocalEnvironment.WorkspaceDirectory = "workspace" }, []error{ErrInvalidSession}}, + "relative workspace": {"viewed", func(r *agent.PrepareRequest) { r.WorkspaceRoot = "workspace" }, []error{ErrInvalidSession}}, "credentialed stdio MCP": {"viewed", func(r *agent.PrepareRequest) { r.MCP = []agent.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools", EnvVars: []string{"TOKEN"}}}} }, []error{ErrUnsupported, agent.ErrViewHandoff}}, @@ -88,7 +88,7 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { r.MCP = []agent.EnvironmentMCP{{InstallationRoot: "/capabilities", Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools"}}} }, []error{ErrInvalidSession}}, } { - req := prepared(request(c.kind, "/workspace", "https://model.test", "sk-test")) + req := prepared(request(c.kind, "https://model.test", "sk-test")) c.change(&req) var dials atomic.Int32 e, err := open(context.Background(), f.cfg, req, bindTo(newBinding(newResource())), deps{dial: countingDial(&dials), tasks: noTasks}) @@ -113,7 +113,7 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { b := newBinding(newResource()) change(&b) var dials atomic.Int32 - e, err := open(context.Background(), f.cfg, prepared(request("viewed", "/workspace", "https://model.test", "sk-test")), bindTo(b), deps{dial: countingDial(&dials), tasks: noTasks}) + e, err := open(context.Background(), f.cfg, prepared(request("viewed", "https://model.test", "sk-test")), bindTo(b), deps{dial: countingDial(&dials), tasks: noTasks}) if e != nil || !errors.Is(err, ErrInvalidSession) || dials.Load() != 0 { t.Errorf("%s: open = %v after %d dials, want ErrInvalidSession", name, err, dials.Load()) } @@ -131,7 +131,7 @@ func TestStdioMCPRunsUnderItsAlias(t *testing.T) { if err != nil { t.Fatal(err) } - req := prepared(request("viewed", "/workspace", "https://model.test", "sk-test")) + req := prepared(request("viewed", "https://model.test", "sk-test")) req.MCP = []agent.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"}}, @@ -169,7 +169,7 @@ func TestRegistryRunsKindsWithViews(t *testing.T) { func TestViewExecutorReceivesTheGatewayRequest(t *testing.T) { f := newViewFixture(t) bearer := "mcp-secret" - req := prepared(request("viewed", "/workspace", "https://model.test", "sk-test")) + req := prepared(request("viewed", "https://model.test", "sk-test")) req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "docs", ServerURL: "https://mcp.test/docs?tenant=a", BearerToken: &bearer}} skills := []agentcapabilities.InstalledSkill{{InstallationRoot: agentcapabilities.Directory, RelativeRoot: "skills/review", PackageRoot: "skills/review"}} req.CapabilityRoot, req.Skills = agentcapabilities.Directory, skills @@ -215,7 +215,7 @@ func TestReleaseRemovesTheHome(t *testing.T) { var dials atomic.Int32 d := newDaemon(t, f.cfg, deps{dial: countingDial(&dials), tasks: noTasks}) b := newBinding(sandboxlink.ResourceRef{}) - none := request("viewed", "", "https://model.test", "sk-test") + 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) diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index 3b1dc4a19..ae869e6c9 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -84,12 +84,12 @@ func register(reg *agent.Registry, kind string, view *agent.View) { } // request is a Session request the agent host admits. -func request(kind, workspace, baseURL, key string) proto.PromptRequestPayload { +func request(kind, baseURL, key string) proto.PromptRequestPayload { return proto.PromptRequestPayload{ AgentKind: kind, Model: "m", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Anthropic, BaseURL: baseURL, APIKey: key}, - LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, CapabilitySources: &agentcapabilities.Input{}}, + LocalEnvironment: &proto.LocalEnvironment{CapabilitySources: &agentcapabilities.Input{}}, } } @@ -98,7 +98,7 @@ func request(kind, workspace, baseURL, key string) proto.PromptRequestPayload { func prepared(req proto.PromptRequestPayload) agent.PrepareRequest { p := agent.PrepareRequest{PromptRequestPayload: req, Prepared: harnessconfig.PreparedConfiguration{Model: req.Model, Provider: *req.ModelProvider}} if req.LocalEnvironment != nil { - p.WorkspaceRoot = req.LocalEnvironment.WorkspaceDirectory + p.WorkspaceRoot = logicalWorkspace } return p } @@ -154,8 +154,9 @@ func leftEntries(t *testing.T, cfg Config) []string { // 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 + host *Host + workspace string + router *dispatch.Router // mcp is the installed MCP that the Environment's preparation resolves // into each request; the wire does not carry it. mcp []agent.EnvironmentMCP @@ -217,6 +218,7 @@ func bindPayload(b Binding) proto.AssignmentBindPayload { 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 + p.WorkspaceDirectory = logicalWorkspace } return p } @@ -266,7 +268,11 @@ func (dm *daemon) next(t *testing.T, id string) proto.Envelope { func (dm *daemon) assign(t *testing.T, b Binding) { t.Helper() id := sandboxwire.NewID().String() - dm.handle(t, ref(b), proto.TypeAssignmentBind, id, bindPayload(b)) + payload := bindPayload(b) + if payload.EnvironmentID != "" && dm.workspace != "" { + payload.WorkspaceDirectory = dm.workspace + } + dm.handle(t, ref(b), proto.TypeAssignmentBind, id, payload) if status := dm.status(t, id); status.State != proto.AssignmentBound { t.Fatalf("the bind is %s (%s), want bound", status.State, status.ErrorCode) } diff --git a/apps/daemon/internal/agenthost/environment_linux.go b/apps/daemon/internal/agenthost/environment_linux.go index c9e4533a5..8dc0f9e94 100644 --- a/apps/daemon/internal/agenthost/environment_linux.go +++ b/apps/daemon/internal/agenthost/environment_linux.go @@ -35,7 +35,7 @@ import ( // The sandbox layout an Environment owner prepares. Providers create the // initialization and package directories for the sandbox's user. const ( - sandboxWorkspace = "/workspace" + logicalWorkspace = "/workspace" sandboxInitialization = "/environment/initialization" sandboxPackages = "/environment/packages" toolEnvironmentName = "tool-env.json" @@ -63,9 +63,10 @@ type owners struct { // 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 + d deps + session string // the canonical Session ID + id string // the Environment ID; empty for environment none + workspace string // the immutable physical workspace from assignment_bind // sem serializes the owner's operations, Close included. Its holder // owns every field below; a rebind also holds owners.mu. sem chan struct{} @@ -86,7 +87,10 @@ type environment struct { 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 != "" { + if err != nil || err2 != nil || bind.Validate() != nil { + return nil + } + if bind.EnvironmentID != "" && (bind.Resource == nil || !isViewPath(bind.WorkspaceDirectory)) { return nil } b := Binding{SessionID: session, AssignmentID: assignment, AssignmentEpoch: ref.Epoch, AttachGrant: slices.Clone(bind.AttachGrant)} @@ -98,12 +102,12 @@ func (h *Host) Environments(ref proto.AssignmentRef, bind proto.AssignmentBindPa 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} + o = &environment{d: h.owners.d, session: ref.SessionID, id: bind.EnvironmentID, workspace: bind.WorkspaceDirectory, 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: + case o.id != bind.EnvironmentID || o.workspace != bind.WorkspaceDirectory: return nil case !sameBinding(o.binding, b): select { @@ -272,8 +276,6 @@ func (o *environment) Configure(r proto.PromptRequestPayload) error { return errors.New("the request does not name the Session's Environment") case r.WorkspaceReadOnly: return nil - case local.WorkspaceDirectory != sandboxWorkspace: - return fmt.Errorf("the workspace is not %s", sandboxWorkspace) case local.CapabilitySources == nil || agentcapabilities.ValidateInput(*local.CapabilitySources) != nil: return agentcapabilities.ErrInvalid } @@ -287,8 +289,7 @@ func (o *environment) Prepare(ctx context.Context, r agent.PrepareRequest) (agen 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.WorkspaceDirectory != sandboxWorkspace { + if o.id == "" || r.LocalEnvironment == nil || r.LocalEnvironment.ID != o.id || r.LocalEnvironment.CapabilitySources == nil { return r, agentcapabilities.ErrInvalid } if err := o.acquire(ctx); err != nil { @@ -338,7 +339,7 @@ func (o *environment) Prepare(ctx context.Context, r agent.PrepareRequest) (agen return r, err } for i, item := range manifest.MCP { - mcp = append(mcp, agent.EnvironmentMCP{InstallationRoot: agentcapabilities.Directory, WorkspaceRoot: sandboxWorkspace, + mcp = append(mcp, agent.EnvironmentMCP{InstallationRoot: agentcapabilities.Directory, WorkspaceRoot: o.workspace, PackageRoot: item.PackageRoot, Server: item.Server, BearerToken: tokens[i]}) } } @@ -346,7 +347,7 @@ func (o *environment) Prepare(ctx context.Context, r agent.PrepareRequest) (agen manifest.Skills[i].InstallationRoot = agentcapabilities.Directory } o.tool = values - r.WorkspaceRoot, r.CapabilityRoot, r.Skills, r.MCP = sandboxWorkspace, agentcapabilities.Directory, manifest.Skills, mcp + r.WorkspaceRoot, r.CapabilityRoot, r.Skills, r.MCP = o.workspace, agentcapabilities.Directory, manifest.Skills, mcp return r, nil } @@ -458,11 +459,11 @@ 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+"/") + relative, ok := strings.CutPrefix(target, logicalWorkspace+"/") if !ok || !proto.ValidWorkspacePath(relative) || len(data) > proto.RuntimePrepareMaxBytes { return agentcapabilities.ErrInvalid } - workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) + workspace, err := w.directory(ctx, w.root, o.workspace, false) if err != nil { return initializationFailed(err) } @@ -486,13 +487,13 @@ func (o *environment) initialize(ctx context.Context, w *world, initialization s case program == "": return agentcapabilities.ErrInvalid } - cwd := sandboxWorkspace - if input.CWD != "" && input.CWD != sandboxWorkspace { - relative, ok := strings.CutPrefix(input.CWD, sandboxWorkspace+"/") + cwd := o.workspace + if input.CWD != "" && input.CWD != logicalWorkspace { + relative, ok := strings.CutPrefix(input.CWD, logicalWorkspace+"/") if !ok || !proto.ValidWorkspacePath(relative) { return agentcapabilities.ErrInvalid } - cwd = input.CWD + cwd = path.Join(o.workspace, relative) } values, err := w.toolEnvironment(ctx, initialization) if err != nil { @@ -587,7 +588,7 @@ func (o *environment) ListWorkspaceDirectory(ctx context.Context, p string, limi return result, dispatch.ErrWorkspaceReadUnavailable } defer o.done(w) - workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) + workspace, err := w.directory(ctx, w.root, o.workspace, false) if err != nil { return result, dispatch.ErrWorkspaceReadUnavailable } @@ -647,7 +648,7 @@ func (o *environment) WriteWorkspaceFile(ctx context.Context, p string, data []b return result, dispatch.ErrEnvironmentUnavailable } defer o.done(w) - workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) + workspace, err := w.directory(ctx, w.root, o.workspace, false) if err != nil { return result, dispatch.ErrEnvironmentUnavailable } @@ -685,7 +686,7 @@ func (o *environment) ExportOutputs(ctx context.Context, out io.Writer) error { return err } defer o.done(w) - workspace, err := w.directory(ctx, w.root, sandboxWorkspace, false) + workspace, err := w.directory(ctx, w.root, o.workspace, false) if err != nil { return err } diff --git a/apps/daemon/internal/agenthost/environment_linux_test.go b/apps/daemon/internal/agenthost/environment_linux_test.go index 94d5e0bcc..4b050a38e 100644 --- a/apps/daemon/internal/agenthost/environment_linux_test.go +++ b/apps/daemon/internal/agenthost/environment_linux_test.go @@ -3,6 +3,7 @@ package agenthost import ( + "archive/tar" "archive/zip" "bytes" "context" @@ -42,14 +43,21 @@ 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) } + const workspace = "/projects/custom-workspace" 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")} { + for _, p := range []string{sandboxInitialization, workspace, logicalWorkspace} { if err := os.RemoveAll(p); err != nil { t.Fatal(err) } } - if err := os.MkdirAll(sandboxWorkspace, 0o777); err != nil { + if err := os.MkdirAll(workspace, 0o777); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(logicalWorkspace, 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path.Join(logicalWorkspace, "decoy.txt"), []byte("untouched"), 0o600); err != nil { t.Fatal(err) } harnesses := agent.NewRegistry() @@ -73,14 +81,14 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { 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 := request("test", "https://model.invalid", "key") req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Skills: []agentskill.Metadata{skill}} // Prepare as Core does: configure, a setup step that sees the tool // 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 := &daemon{host: h, workspace: workspace} first.route(t, reg) first.assign(t, b) plugin := agentplugin.Metadata{Type: "inline", Name: "package", Description: "Package proof."} @@ -92,12 +100,25 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { if _, err := os.Lstat(path.Join(agentcapabilities.Directory, "plugins")); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("the refused plugin was staged: %v", err) } + // File creation before preparation must use the bound physical root. + if r := first.write(t, b, "before.txt", []byte("before")); r.Outcome != "completed" { + t.Fatalf("before preparation: %+v", r) + } + if r := first.write(t, b, "before.txt", []byte("replacement")); r.Outcome != "rejected" { + t.Fatalf("overwrite: %+v", r) + } + req.LocalEnvironment.CapabilitySources.Plugins = []agentplugin.Metadata{plugin} + validPlugin := archive(t, "package", map[string][]byte{".codex-plugin/plugin.json": []byte(`{"name":"package","description":"Package proof."}`), ".mcp.json": []byte(`{"mcpServers":{"local":{"command":"bash"}}}`)}) for _, step := range []struct { begin proto.RuntimePreparePayload data []byte }{ + {proto.RuntimePreparePayload{Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/notes/initial.txt"}}, []byte("old")}, + {proto.RuntimePreparePayload{Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/notes/initial.txt"}}, []byte("initial")}, + {proto.RuntimePreparePayload{Action: "plugin", Plugin: &plugin}, validPlugin}, {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: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", CWD: "/workspace/notes", Command: "pwd > cwd.txt"}}, nil}, {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}, } { @@ -105,21 +126,61 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { t.Fatalf("runtime_prepare %s: %+v, want completed", step.begin.Action, r) } } - if _, status := first.prepare(t, b, req); status.State != "ready" { + initialID, initialStatus := first.prepare(t, b, req) + if status := initialStatus; status.State != "ready" { t.Fatalf("the preparation is %s (%s), want ready", status.State, status.ErrorCode) } got := <-prepared - if r := got.req; r.WorkspaceRoot != sandboxWorkspace || r.CapabilityRoot != agentcapabilities.Directory || len(r.Skills) != 1 || + if r := got.req; r.WorkspaceRoot != workspace || r.CapabilityRoot != agentcapabilities.Directory || len(r.Skills) != 1 || r.Skills[0].Metadata != skill || r.Skills[0].InstallationRoot != agentcapabilities.Directory || r.Skills[0].RelativeRoot != "skills/probe-skill" { t.Fatalf("the Executor's Environment is %+v", r) } + if len(got.req.MCP) != 1 || got.req.MCP[0].WorkspaceRoot != workspace { + t.Fatalf("MCP workspace: %+v", got.req.MCP) + } + for name, want := range map[string]string{"before.txt": "before", "notes/initial.txt": "initial", "notes/cwd.txt": workspace + "/notes\n"} { + if body, err := os.ReadFile(path.Join(workspace, name)); err != nil || string(body) != want { + t.Fatalf("%s: %q, %v; want %q", name, body, err, want) + } + } 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) + checkSetup(t, workspace) + first.release(t, b, initialID, initialStatus.Handle) + if r := first.write(t, b, "outputs/result.txt", []byte("output")); r.Outcome != "completed" { + t.Fatalf("output write: %+v", r) + } + owner := first.host.owners.m[b.SessionID] + var exported bytes.Buffer + if err := owner.ExportOutputs(t.Context(), &exported); err != nil { + t.Fatal(err) + } + tr := tar.NewReader(&exported) + entry, err := tr.Next() + if err != nil || entry.Name != "outputs/result.txt" { + t.Fatalf("export entry: %+v %v", entry, err) + } + if body, err := io.ReadAll(tr); err != nil || string(body) != "output" { + t.Fatalf("export content: %q %v", body, err) + } + if err := os.Symlink(logicalWorkspace, path.Join(workspace, "escape")); err != nil { + t.Fatal(err) + } + if r := first.write(t, b, "escape/escaped.txt", []byte("escape")); r.Outcome == "completed" { + t.Fatalf("symlink write: %+v", r) + } + if body, err := os.ReadFile(path.Join(logicalWorkspace, "decoy.txt")); err != nil || string(body) != "untouched" { + t.Fatalf("decoy: %q %v", body, err) + } + for _, name := range []string{"before.txt", "notes", "setup.txt", "outputs", "escaped.txt"} { + if _, err := os.Lstat(path.Join(logicalWorkspace, name)); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("operation reached decoy %s: %v", name, err) + } + } if err := first.shutdown(); err != nil { t.Fatal(err) } @@ -127,7 +188,7 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { // Reopen: a new Router checks the completed installation and neither // changes the world nor runs a step. p := h.probe(t, b, 0) - second := &daemon{host: h} + second := &daemon{host: h, workspace: workspace} second.route(t, reg) id, status := second.prepare(t, b, req) if status.State != "ready" { @@ -139,7 +200,7 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { 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) + checkSetup(t, workspace) // A quiesce drains the owner; after the resume it serves a read on a new // attachment. @@ -179,14 +240,14 @@ func TestEnvironmentOwnerServesTheSandbox(t *testing.T) { if err := second.shutdown(); err != nil || !h.drained(t, b) { t.Fatalf("the Router's shutdown is %v, want a drained owner", err) } - third := &daemon{host: h} + third := &daemon{host: h, workspace: workspace} third.route(t, reg) third.assign(t, b) if r := third.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) { + if _, err := os.Lstat(path.Join(workspace, "notes", name)); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("%s exists: %v", name, err) } } @@ -202,11 +263,11 @@ func TestSupersedingBindRebindsTheOwner(t *testing.T) { } sb := startSandbox(t, os.Getenv(sandboxIOEnv)) for _, name := range []string{"before.txt", "after.txt"} { - if err := os.RemoveAll(path.Join(sandboxWorkspace, name)); err != nil { + if err := os.RemoveAll(path.Join(logicalWorkspace, name)); err != nil { t.Fatal(err) } } - if err := os.MkdirAll(sandboxWorkspace, 0o777); err != nil { + if err := os.MkdirAll(logicalWorkspace, 0o777); err != nil { t.Fatal(err) } cfg := Config{StateDir: t.TempDir(), RelayURL: sb.url, RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: agent.NewRegistry()} @@ -233,7 +294,7 @@ func TestSupersedingBindRebindsTheOwner(t *testing.T) { if r := dm.write(t, next, "after.txt", []byte("after")); r.Outcome != "completed" { t.Fatalf("the write at epoch 2 is %+v", r) } - if body, err := os.ReadFile(path.Join(sandboxWorkspace, "after.txt")); err != nil || string(body) != "after" { + if body, err := os.ReadFile(path.Join(logicalWorkspace, "after.txt")); err != nil || string(body) != "after" { t.Fatalf("the sandbox has %q, %v", body, err) } } @@ -262,9 +323,9 @@ type preparedExecutor struct { } // checkSetup checks that the setup step ran once. -func checkSetup(t *testing.T) { +func checkSetup(t *testing.T, workspace string) { t.Helper() - if body, err := os.ReadFile(path.Join(sandboxWorkspace, "setup.txt")); err != nil || string(body) != "probe-value\n" { + if body, err := os.ReadFile(path.Join(workspace, "setup.txt")); err != nil || string(body) != "probe-value\n" { t.Fatalf("the setup step wrote %q, %v", body, err) } } @@ -467,3 +528,38 @@ func (s *probed) Write(b []byte) (int, error) { } return s.ReadWriteCloser.Write(b) } + +func TestEnvironmentOwnerKeepsWorkspaceAcrossRouters(t *testing.T) { + var dials atomic.Int32 + h := &Host{owners: owners{d: deps{dial: countingDial(&dials)}}} + b := newBinding(newResource()) + payload := bindPayload(b) + payload.WorkspaceDirectory = "/projects/one" + owner := h.Environments(ref(b), payload) + if owner == nil { + t.Fatal("initial bind rejected") + } + for _, epoch := range []uint64{1, 2} { + other := b + other.AssignmentEpoch = epoch + changed := payload + changed.WorkspaceDirectory = "/projects/two" + if h.Environments(ref(other), changed) != nil { + t.Fatalf("changed workspace accepted at epoch %d", epoch) + } + if h.Environments(ref(other), payload) != owner { + t.Fatalf("owner lost at epoch %d", epoch) + } + } + for _, workspace := range []string{"", "relative", "/projects/../two", "C:/project"} { + fresh := newBinding(newResource()) + invalid := bindPayload(fresh) + invalid.WorkspaceDirectory = workspace + if h.Environments(ref(fresh), invalid) != nil { + t.Fatalf("unsupported workspace accepted: %q", workspace) + } + } + if dials.Load() != 0 { + t.Fatal("bind performed I/O") + } +} diff --git a/apps/daemon/internal/agenthost/host_linux_test.go b/apps/daemon/internal/agenthost/host_linux_test.go index 018b3e2b5..8c983ffc0 100644 --- a/apps/daemon/internal/agenthost/host_linux_test.go +++ b/apps/daemon/internal/agenthost/host_linux_test.go @@ -180,7 +180,7 @@ func plantSession(t *testing.T, cfg Config, id sandboxwire.ID) sessionDir { func TestRemoveHome(t *testing.T) { f := newViewFixture(t) h, b := &Host{cfg: f.cfg}, newBinding(newResource()) - req := prepared(request("viewed", "/workspace", "https://model.test", "sk-test")) + req := prepared(request("viewed", "https://model.test", "sk-test")) var dials atomic.Int32 // The open stops once it has claimed the Session, before its uid. claimed, proceed := make(chan struct{}), make(chan struct{}) diff --git a/apps/daemon/internal/agenthost/view_linux_test.go b/apps/daemon/internal/agenthost/view_linux_test.go index 695c50824..1c2869061 100644 --- a/apps/daemon/internal/agenthost/view_linux_test.go +++ b/apps/daemon/internal/agenthost/view_linux_test.go @@ -128,7 +128,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { defer func() { h.Close() }() // 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 + workspace := logicalWorkspace if err := os.MkdirAll(workspace, 0o777); err != nil { t.Fatal(err) } @@ -140,7 +140,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { t.Fatal(err) } } - req := request("test", workspace, upstream.URL, upstreamKey) + req := request("test", 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}) } beat := filepath.Join(workspace, "beat") @@ -203,7 +203,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}), newBinding(sandboxlink.ResourceRef{}) - none := request("test", "", upstream.URL, upstreamKey) + none := request("test", upstream.URL, upstreamKey) none.LocalEnvironment, none.DisableExecutionEnvironment = nil, true r := d.turn(t, b, none, "none") for _, name := range []string{"empty root", "work directory", "model through the gateway", "no direct route", "no sandbox network"} { diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index 02e1d3540..e6ab2dceb 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -68,7 +68,7 @@ const ( // caDir holds the agent-host image's roots, one regular PEM file each. caDir = "/usr/share/ca-certificates/mozilla" // workspace is the sandbox directory every Session works in. - workspace = "/workspace" + workspace = "/workspace/custom-project" turnLimit = 10 * time.Minute ) @@ -99,6 +99,16 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { Log: slog.New(slog.NewTextHandler(os.Stderr, nil))} sb.auth.AddRuntime(cfg.Credential, cfg.RuntimeID) sb.ready(t, cfg) + // The logical workspace remains distinct from the bound physical root. + 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("workspace")}}) + if err != nil || walked.Failure != nil { + t.Fatalf("workspace parent: %v %+v", err, walked) + } + if _, err := c.Mkdir(ctx, &sandboxfs.MkdirRequest{Parent: walked.Entries[0].Node, Name: []byte("custom-project"), Mode: 0o755}); err != nil { + t.Fatal(err) + } + }) h, err := agenthost.Open(cfg) if err != nil { t.Fatalf("Open: %v", err) @@ -150,7 +160,7 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, configuration := proto.PromptRequestPayload{AgentKind: kind, DisableSubagents: true, Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, - LocalEnvironment: &proto.LocalEnvironment{ID: uuid.UUID(sb.resource.EnvironmentID).String(), WorkspaceDirectory: workspace, + LocalEnvironment: &proto.LocalEnvironment{ID: uuid.UUID(sb.resource.EnvironmentID).String(), CapabilitySources: &agentcapabilities.Input{}}} if caps.FunctionTools.IsSupported() { configuration.FunctionTools = []proto.FunctionTool{lookupTicket} @@ -358,6 +368,7 @@ func (s *session) router(t *testing.T, out sender, id string) (*dispatch.Router, 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 + bind.WorkspaceDirectory = workspace } handle(t, router, ref, proto.TypeAssignmentBind, id, bind) var bound proto.AssignmentStatusPayload diff --git a/apps/daemon/internal/dispatch/assignment.go b/apps/daemon/internal/dispatch/assignment.go index a2e9d7639..9459c9dff 100644 --- a/apps/daemon/internal/dispatch/assignment.go +++ b/apps/daemon/internal/dispatch/assignment.go @@ -14,8 +14,9 @@ import ( // protects it. A released assignment stays recorded, so its frames stay fenced; // a confirmed release drops its owner, grant and resource. type assignmentState struct { - ref proto.AssignmentRef - environmentID string + ref proto.AssignmentRef + environmentID string + workspaceDirectory string // resource and grant are the bind's Link resource and attach grant; the // resource's Kind is empty when the bind carried none. resource sandboxbootstrap.Resource @@ -109,14 +110,14 @@ func (r *Router) handleAssignmentBind(ctx context.Context, env proto.Envelope) e break } } - r.assignments[ref.SessionID] = &assignmentState{ref: ref, environmentID: input.EnvironmentID, resource: resource, grant: input.AttachGrant, environment: environment} + r.assignments[ref.SessionID] = &assignmentState{ref: ref, environmentID: input.EnvironmentID, workspaceDirectory: input.WorkspaceDirectory, resource: resource, grant: input.AttachGrant, environment: environment} case a.ref.AssignmentID != ref.AssignmentID: code = proto.AssignmentConflict case ref.Epoch > a.ref.Epoch && (!a.released || a.superseding != nil): // The bind supersedes the earlier epoch, or a pending supersede. A - // Session's Environment never changes, so another one is refused + // Session's Environment and workspace never change, so conflicts are refused // before anything is fenced. - if input.EnvironmentID != a.environmentID { + if input.EnvironmentID != a.environmentID || input.WorkspaceDirectory != a.workspaceDirectory { code = proto.AssignmentConflict break } @@ -138,7 +139,7 @@ func (r *Router) handleAssignmentBind(ctx context.Context, env proto.Envelope) e return nil case ref.Epoch < a.ref.Epoch || ref.Epoch == a.ref.Epoch && a.released: code = proto.AssignmentStale - case a.ref != ref || a.environmentID != input.EnvironmentID || a.resource != resource || !bytes.Equal(a.grant, input.AttachGrant): + case a.ref != ref || a.environmentID != input.EnvironmentID || a.workspaceDirectory != input.WorkspaceDirectory || a.resource != resource || !bytes.Equal(a.grant, input.AttachGrant): code = proto.AssignmentConflict } r.mu.Unlock() @@ -201,7 +202,7 @@ func (r *Router) supersede(ctx context.Context, env proto.Envelope, a *assignmen } func sameBind(a, b proto.AssignmentBindPayload) bool { - return a.EnvironmentID == b.EnvironmentID && (a.Resource == nil) == (b.Resource == nil) && (a.Resource == nil || *a.Resource == *b.Resource) && bytes.Equal(a.AttachGrant, b.AttachGrant) + return a.EnvironmentID == b.EnvironmentID && a.WorkspaceDirectory == b.WorkspaceDirectory && (a.Resource == nil) == (b.Resource == nil) && (a.Resource == nil || *a.Resource == *b.Resource) && bytes.Equal(a.AttachGrant, b.AttachGrant) } // handleAssignmentRelease fences the assignment, then settles the Session's diff --git a/apps/daemon/internal/dispatch/assignment_test.go b/apps/daemon/internal/dispatch/assignment_test.go index a8ca00b1c..22c1092c8 100644 --- a/apps/daemon/internal/dispatch/assignment_test.go +++ b/apps/daemon/internal/dispatch/assignment_test.go @@ -186,15 +186,15 @@ func TestAssignmentBindCarriesLink(t *testing.T) { return waitAssignmentStatus(t, h.sender, id) } for id, payload := range map[string]proto.AssignmentBindPayload{ - "no grant": {EnvironmentID: environment, Resource: resource}, - "no resource": {EnvironmentID: environment, AttachGrant: []byte("grant")}, - "other environment": {EnvironmentID: environment, Resource: &other, AttachGrant: []byte("grant")}, + "no grant": {EnvironmentID: environment, WorkspaceDirectory: "/workspace", Resource: resource}, + "no resource": {EnvironmentID: environment, WorkspaceDirectory: "/workspace", AttachGrant: []byte("grant")}, + "other environment": {EnvironmentID: environment, WorkspaceDirectory: "/workspace", Resource: &other, AttachGrant: []byte("grant")}, } { if got := bind(id, payload); got.ErrorCode != "invalid_request" { t.Fatalf("%s: bind = %+v", id, got) } } - link := proto.AssignmentBindPayload{EnvironmentID: environment, Resource: resource, AttachGrant: []byte("grant")} + link := proto.AssignmentBindPayload{EnvironmentID: environment, WorkspaceDirectory: "/workspace", Resource: resource, AttachGrant: []byte("grant")} if got := bind("bind", link); got.State != proto.AssignmentBound { t.Fatalf("bind = %+v", got) } @@ -298,8 +298,8 @@ func TestSupersedingBindFencesTheEarlierEpoch(t *testing.T) { code string }{ "lower": {1, proto.AssignmentBindPayload{}, proto.AssignmentStale}, - "changed": {2, proto.AssignmentBindPayload{EnvironmentID: uuid.NewString()}, proto.AssignmentConflict}, - "other environment": {3, proto.AssignmentBindPayload{EnvironmentID: uuid.NewString()}, proto.AssignmentConflict}, + "changed": {2, proto.AssignmentBindPayload{EnvironmentID: uuid.NewString(), WorkspaceDirectory: "/workspace"}, proto.AssignmentConflict}, + "other environment": {3, proto.AssignmentBindPayload{EnvironmentID: uuid.NewString(), WorkspaceDirectory: "/workspace"}, proto.AssignmentConflict}, "repeated": {2, proto.AssignmentBindPayload{}, ""}, } { if got := bind(id, test.epoch, test.payload); got.ErrorCode != test.code { @@ -320,3 +320,71 @@ func TestSupersedingBindFencesTheEarlierEpoch(t *testing.T) { } waitPreparationStatus(t, h.sender, "current", "ready", "") } + +func TestAssignmentWorkspaceConflictsDoNotFenceOwner(t *testing.T) { + owner := &closingOwner{entered: make(chan struct{}), release: make(chan struct{})} + var resolutions atomic.Int32 + sender := &recSender{} + r, err := dispatch.New(dispatch.Config{Registry: agent.NewRegistry(), Sender: sender, Environments: func(_ proto.AssignmentRef, bind proto.AssignmentBindPayload) dispatch.Environment { + resolutions.Add(1) + if bind.WorkspaceDirectory != "/projects/custom" { + t.Errorf("resolved workspace = %q", bind.WorkspaceDirectory) + } + return owner + }}) + if err != nil { + t.Fatal(err) + } + defer r.Shutdown(context.Background()) + defer func() { + select { + case <-owner.release: + default: + close(owner.release) + } + }() + bind := func(id string, epoch uint64, workspace string) { + env := scoped(t, "s", proto.TypeAssignmentBind, id, proto.AssignmentBindPayload{EnvironmentID: preparationEnvironmentID, WorkspaceDirectory: workspace}) + env.Assignment.Epoch = epoch + if err := r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + } + bind("first", 1, "/projects/custom") + if got := waitAssignmentStatus(t, sender, "first"); got.State != proto.AssignmentBound { + t.Fatalf("first bind = %+v", got) + } + for id, epoch := range map[string]uint64{"same epoch conflict": 1, "higher epoch conflict": 2} { + bind(id, epoch, "/workspace") + if got := waitAssignmentStatus(t, sender, id); got.ErrorCode != proto.AssignmentConflict { + t.Fatalf("%s = %+v", id, got) + } + bind(id+" repeat", 1, "/projects/custom") + if got := waitAssignmentStatus(t, sender, id+" repeat"); got.State != proto.AssignmentBound { + t.Fatalf("conflict fenced current assignment: %+v", got) + } + } + if owner.closes.Load() != 0 || resolutions.Load() != 1 { + t.Fatalf("conflicts changed owner: closes %d, resolutions %d", owner.closes.Load(), resolutions.Load()) + } + bind("supersede", 2, "/projects/custom") + select { + case <-owner.entered: + case <-time.After(3 * time.Second): + t.Fatal("superseding bind did not close earlier owner") + } + for id, epoch := range map[string]uint64{"pending conflict": 2, "pending higher conflict": 3} { + bind(id, epoch, "/workspace") + if got := waitAssignmentStatus(t, sender, id); got.ErrorCode != proto.AssignmentConflict { + t.Fatalf("%s = %+v", id, got) + } + } + close(owner.release) + if got := waitAssignmentStatus(t, sender, "supersede"); got.State != proto.AssignmentBound { + t.Fatalf("conflict fenced pending assignment: %+v", got) + } + bind("current", 2, "/projects/custom") + if got := waitAssignmentStatus(t, sender, "current"); got.State != proto.AssignmentBound || resolutions.Load() != 2 { + t.Fatalf("current bind = %+v, resolutions %d", got, resolutions.Load()) + } +} diff --git a/apps/daemon/internal/dispatch/environment_test.go b/apps/daemon/internal/dispatch/environment_test.go index b6d775bd7..7605eac98 100644 --- a/apps/daemon/internal/dispatch/environment_test.go +++ b/apps/daemon/internal/dispatch/environment_test.go @@ -68,8 +68,6 @@ func (o *testOwner) Configure(r proto.PromptRequestPayload) error { return errors.New("the request does not name the Session's Environment") case r.WorkspaceReadOnly: return nil - case local.WorkspaceDirectory != "/workspace": - return errors.New("the workspace is not /workspace") case local.CapabilitySources == nil || agentcapabilities.ValidateInput(*local.CapabilitySources) != nil: return agentcapabilities.ErrInvalid } @@ -325,7 +323,7 @@ func TestOwnedRuntimeRejectsForeignSessionBind(t *testing.T) { h := localPreparationHarness(t) defer h.router.Shutdown(context.Background()) const foreign = "33333333-3333-4333-8333-333333333333" - if err := h.router.Handle(t.Context(), scoped(t, foreign, proto.TypeAssignmentBind, "bind-foreign", proto.AssignmentBindPayload{EnvironmentID: preparationEnvironmentID})); err != nil { + if err := h.router.Handle(t.Context(), scoped(t, foreign, proto.TypeAssignmentBind, "bind-foreign", proto.AssignmentBindPayload{EnvironmentID: preparationEnvironmentID, WorkspaceDirectory: "/workspace"})); err != nil { t.Fatal(err) } if status := waitAssignmentStatus(t, h.sender, "bind-foreign"); status.ErrorCode != proto.AssignmentConflict { diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index 5187a4c48..aa3ae49e4 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -86,7 +86,7 @@ func localPreparationHarness(t *testing.T) *harness { } func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "prepared", LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}})} + return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: prototest.WithModel(proto.PromptRequestPayload{AgentKind: "prepared", LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, CapabilitySources: &agentcapabilities.Input{}}})} } func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router { diff --git a/apps/daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go index 3702f93a5..33ff35ef1 100644 --- a/apps/daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -181,7 +181,11 @@ func ref(session string) proto.AssignmentRef { // assign binds session to r in environment. func assign(t *testing.T, r *dispatch.Router, session, environment string) { t.Helper() - if err := r.Handle(t.Context(), scoped(t, session, proto.TypeAssignmentBind, "bind-"+session, proto.AssignmentBindPayload{EnvironmentID: environment})); err != nil { + bind := proto.AssignmentBindPayload{EnvironmentID: environment} + if environment != "" { + bind.WorkspaceDirectory = "/workspace" + } + if err := r.Handle(t.Context(), scoped(t, session, proto.TypeAssignmentBind, "bind-"+session, bind)); err != nil { t.Fatalf("assignment_bind: %v", err) } } diff --git a/apps/daemon/internal/dispatch/suspend_test.go b/apps/daemon/internal/dispatch/suspend_test.go index 60de1bb11..b31dc042d 100644 --- a/apps/daemon/internal/dispatch/suspend_test.go +++ b/apps/daemon/internal/dispatch/suspend_test.go @@ -21,8 +21,11 @@ var suspendRef = proto.AssignmentRef{SessionID: "session", AssignmentID: "assign func bindAssignment(r *Router, ref proto.AssignmentRef, environmentID string) { r.mu.Lock() a := &assignmentState{ref: ref, environmentID: environmentID} + if environmentID != "" { + a.workspaceDirectory = "/workspace" + } if r.environments != nil { - a.environment = r.environments(ref, proto.AssignmentBindPayload{EnvironmentID: environmentID}) + a.environment = r.environments(ref, proto.AssignmentBindPayload{EnvironmentID: environmentID, WorkspaceDirectory: a.workspaceDirectory}) } r.assignments[ref.SessionID] = a r.mu.Unlock() diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index b20e99c10..d11a3a220 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -71,7 +71,7 @@ The application owns the machine. It creates the Session with a clean absolute ` - `remote_url` is Core's daemon WebSocket URL, derived from Core's public URL, never from request headers or a daemon address. The machine derives Core's origin from it to enroll, and Sandbox I/O serves the Link URL that enrollment returns. - Enrollment records the executor key that serves the Environment's Link resource; the first key keeps it. It creates no allocation and binds no Session: the Session runs on the deployment's agent host ([Session assignments](../../docs/runtime-protocol.md#session-assignments)). -- The Session's workspace is the machine's `/workspace`: the agent host fails the preparation of an execution whose `workspace_directory` names another path. Naming a path grants no access to it. +- The Session's workspace is the machine's declared `workspace_directory`. The agent host freezes that path on assignment binding, before initialization or file access. Naming a path grants no access to it. - Session reads, lists and events return the `self_hosted` output with the Environment ID, workspace and capability directories, never private configuration. `capability_directories` lists the caller's selections; the Runtime's installation locations stay private. - Compute, workspace and files stay the application's. Deleting the Session or revoking the credential denies further access; the machine owner stops what still runs on the machine and cleans up. - The workspace must survive a restart of `oac-daemon start` or Sandbox I/O. Losing it never authorizes silent replacement or replay. @@ -206,9 +206,9 @@ Preparation runs on the Environment's machine as Sandbox I/O's account and never - System dependencies must be preinstalled in the managed image or by the owner of a self-hosted machine. A missing executable or library fails the operation that needs it. - `packages.system` is rejected in Templates and inline configuration, including a null or empty list (400, param `packages.system`). Package responses still carry the official required `system: []`. - npm installs into the prefix `/environment/packages/npm` and pip into the target `/environment/packages/python`; Node/npm and Python/pip must already be installed. The [tool environment](#explicit-local-tool-environment) puts their commands on `PATH` and the Python packages on `PYTHONPATH`, so native tools see them in every working directory. -- Setup commands run with Bash, without profile or rc files. The default working directory is `/workspace`. +- Setup commands run with Bash, without profile or rc files. The default working directory is the declared workspace; a setup `cwd` under logical `/workspace` resolves within it. Command text is never rewritten. -The machine's layout is fixed: the workspace is `/workspace`, initialization records and the tool environment live in `/environment/initialization`, and packages in `/environment/packages`. Managed Providers create the initialization and package directories for Sandbox I/O's account. These are resource paths, never Environment-source or operating-system switches in Core. +Hosted Environments use `/workspace`; self-hosted Environments use their declared workspace. Initialization records and the tool environment live in `/environment/initialization`, and packages in `/environment/packages`. Managed Providers create the initialization and package directories for Sandbox I/O's account. These are resource paths, never Environment-source or operating-system switches in Core. Every command uses the machine's network. Process ownership waits for exit and I/O settlement. Command output is discarded; a confirmed failure keeps only a bounded integer exit status. @@ -308,7 +308,7 @@ Env values are readable by Agent code but never appear in public metadata or ini | Referenced file | 50 MiB | | Session or Template request body | 16 MiB | -Paths must be canonical, distinct and inside the logical workspace; the agent host anchors each write to the machine's `/workspace`. This is API path scope, not a restriction on native tools running as the same user. Template metadata shows inline files as type, path and size and references as type, path and `file_id`; each Session gets fresh file IDs and sizes for both. File data stays out of ordinary configuration, responses, events and command arguments. A Template keeps references; each Session authorizes and freezes its own encrypted source bytes, so later source deletion cannot change them. +Paths must be canonical, distinct and inside the logical workspace; the agent host maps logical `/workspace` to the declared physical workspace and anchors each write there. This is API path scope, not a restriction on native tools running as the same user. Template metadata shows inline files as type, path and size and references as type, path and `file_id`; each Session gets fresh file IDs and sizes for both. File data stays out of ordinary configuration, responses, events and command arguments. A Template keeps references; each Session authorizes and freezes its own encrypted source bytes, so later source deletion cannot change them. ### Skills diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index eb2dc9ea7..3ba7aaa94 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "环境与模板" source: contracts/agents-api/environments.md -source_hash: 39c9fc5bd3c6010ef018072f81aa9eb8e91f85b37de196d4c45a654c440af05c +source_hash: 1e1ba9b25d9103671d5562577f297651fe932e793d3494e7172f503d9807bcd8 --- Environment 是 Session 的执行资源,包括 Harness 所操作的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment Template 是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -73,7 +73,7 @@ Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会 - `remote_url` 是根据 Core 的公共 URL 推导出的 Core daemon WebSocket URL,绝不根据请求头或 daemon 地址生成。机器从中推导 Core 的 origin 以进行注册,Sandbox I/O 则为注册返回的 Link URL 提供服务。 - 注册记录为该 Environment 的 Link resource 提供服务的 executor key;最先注册的 key 保有它。注册不会创建任何分配,也不绑定 Session:Session 运行在部署的 agent host 上([Session 分配](../../../docs/zh/runtime-protocol.md#session-assignments))。 -- Session 的工作区是机器上的 `/workspace`:若执行的 `workspace_directory` 指向其他路径,agent host 会使其准备失败。指定某个路径并不会授予对它的访问权限。 +- Session 的工作区是机器上声明的 `workspace_directory`。agent host 在绑定 assignment 时、任何初始化或文件访问之前冻结此路径。指定某个路径并不会授予对它的访问权限。 - Session 读取、列表和事件会返回带有 Environment ID、工作区及能力目录的 `self_hosted` 输出,但绝不返回私有配置。`capability_directories` 列出调用方选择的内容;Runtime 的安装位置保持私有。 - 计算资源、工作区和文件仍归应用程序所有。删除 Session 或撤销凭据会拒绝后续访问;机器所有者负责停止机器上仍在运行的内容并进行清理。 - 工作区必须能在 `oac-daemon start` 或 Sandbox I/O 重启后继续存在。丢失它绝不授权进行静默替换或重播。 @@ -208,9 +208,9 @@ Executor 接纳仅验证已冻结的描述符。准备所有者会在调用原 - 系统依赖必须预先安装在托管镜像中,或由自托管机器的所有者安装。缺少可执行文件或库时,需要该依赖的操作会失败。 - Templates 和内联配置都会拒绝 `packages.system`,包括 null 或空列表(400,param `packages.system`)。软件包响应仍会包含官方要求的 `system: []`。 - npm 安装到 prefix `/environment/packages/npm`,pip 安装到 target `/environment/packages/python`;Node/npm 和 Python/pip 必须已经安装。[工具环境](#explicit-local-tool-environment)会将它们的命令放到 `PATH` 中,并将 Python 软件包放到 `PYTHONPATH` 中,因此每个工作目录中的原生工具都能看到它们。 -- 设置命令使用 Bash 运行,不加载 profile 或 rc 文件。默认工作目录为 `/workspace`。 +- 设置命令使用 Bash 运行,不加载 profile 或 rc 文件。默认工作目录是声明的工作区;逻辑 `/workspace` 下的设置 `cwd` 会解析到该工作区内。命令文本不会被改写。 -机器的布局是固定的:工作区为 `/workspace`,初始化记录和工具环境位于 `/environment/initialization`,软件包位于 `/environment/packages`。托管 Provider 会为 Sandbox I/O 的账户创建初始化目录和软件包目录。这些是资源路径,在 Core 中绝不是 Environment 源或操作系统开关。 +托管 Environment 使用 `/workspace`;自托管 Environment 使用其声明的工作区。初始化记录和工具环境位于 `/environment/initialization`,软件包位于 `/environment/packages`。托管 Provider 会为 Sandbox I/O 的账户创建初始化目录和软件包目录。这些是资源路径,在 Core 中绝不是 Environment 源或操作系统开关。 每条命令都使用机器的网络。进程所有权会等待退出及 I/O 结算完成。命令输出会被丢弃;确认失败时只保留一个有界整数退出状态。 @@ -310,7 +310,7 @@ Agent 代码可以读取 env 值,但它们绝不会出现在公开元数据或 | 引用文件 | 50 MiB | | Session 或 Template 请求正文 | 16 MiB | -路径必须规范、互不相同且位于逻辑工作区内部;agent host 会将每次写入锚定到机器上的 `/workspace`。这是 API 路径范围,不是对以同一用户身份运行的原生工具的限制。Template 元数据会将内联文件显示为 type、path 和 size,将引用显示为 type、path 和 `file_id`;无论内联文件还是引用,每个 Session 都会获得全新的文件 ID 和大小。文件数据不会出现在普通配置、响应、事件或命令参数中。Template 会保留引用;每个 Session 会授权并冻结自己的加密源字节,因此之后删除源文件无法改变这些字节。 +路径必须规范、互不相同且位于逻辑工作区内部;agent host 会将逻辑 `/workspace` 映射到声明的物理工作区,并将每次写入锚定到那里。这是 API 路径范围,不是对以同一用户身份运行的原生工具的限制。Template 元数据会将内联文件显示为 type、path 和 size,将引用显示为 type、path 和 `file_id`;无论内联文件还是引用,每个 Session 都会获得全新的文件 ID 和大小。文件数据不会出现在普通配置、响应、事件或命令参数中。Template 会保留引用;每个 Session 会授权并冻结自己的加密源字节,因此之后删除源文件无法改变这些字节。 ### Skills {#skills} diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 2af64a037..5d11f40b8 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -57,7 +57,7 @@ The `execution_prepare` configuration carries the Session's model configuration | `execution_controls` | Always: the resolved text verbosity (default `medium`), an explicit programmatic-tool-calling disable and any `json_schema` output format. Native option names belong to the adapter | | `observe_subagent_identities`, `disable_subagents` | From the Agent's `multi_agent.enabled` | | `disable_execution_environment` | For an Environment of type `none` | -| `local_environment` | For `openai_hosted` and `self_hosted`, with the exact Environment binding. The request carries no working directory; the Environment owner checks `workspace_directory` against the Environment's workspace. It carries no network policy, because Core admits only an [enabled network](../contracts/agents-api/environments.md#restricted-network) | +| `local_environment` | For `openai_hosted` and `self_hosted`, with the exact Environment binding. The request carries no working directory; the Environment owner uses the workspace frozen by `assignment_bind`. It carries no network policy, because Core admits only an [enabled network](../contracts/agents-api/environments.md#restricted-network) | | `require_existing_native_session` | When a native Session must be recovered | An execution configuration requires exactly one of `local_environment` and `disable_execution_environment`; `execution_prepare` rejects neither or both with `unsupported_configuration`. @@ -110,7 +110,7 @@ An assignment binds one Session to the Runtime that runs it. `Envelope.assignmen Every Session frame carries the assignment: `execution_prepare`, `execution_start` and `execution_release`; `prompt_cancel`, `prompt_steer` and `function_result`; every frame of `runtime_prepare`, `workspace_read`, `workspace_write` and `workspace_export`; and `environment_quiesce` and `environment_resume`. A reply echoes its request's assignment, and a Run's frames carry the assignment that started it; Core rejects a reply or Run frame that names another. Heartbeats carry none. -Before a Session's first operation on a connection, including Environment initialization and file work without a Turn, Core sends `assignment_bind` with the Session's Environment ID and waits for `assignment_status` `bound`. When the Runtime is an agent host and the Environment has a live [Link](./sandbox-link-protocol.md) resource, the bind also carries `resource`, that resource as the [bootstrap input](./sandbox-bootstrap.md#launch-input) names it, and `attach_grant`, the base64 grant with which the agent host opens services on that resource generation under this assignment and epoch. When the Environment has no live resource, Core sends the agent host no bind, and the operation that needed the bind fails. The grant is secret. Core sends neither field to any other Runtime. A bind with only one of them, or with a resource of another Environment, fails with `invalid_request`. A repeated bind of the same assignment and epoch with the same Environment, resource and grant is `bound` again; one with anything else fails with `assignment_conflict`. A bind of the bound assignment at a higher epoch supersedes the earlier epoch, with any resource or grant: the Runtime fences and cleans up the earlier epoch's work as a release does, keeping the home, then binds the new epoch and replies `bound`. A Session's Environment never changes, so such a bind that names another Environment fails with `assignment_conflict` before anything is fenced. A bind of another assignment, or of a released assignment at a higher epoch, fails with `assignment_conflict`. Unfinished cleanup replies `failed` with `cleanup_unconfirmed`, and a retry at the same epoch repeats it; a release or a later bind in the meantime fails it with `assignment_stale`. The Runtime admits a Session frame only under the assignment it bound: an older epoch, or a released one, fails with `assignment_stale`; another assignment, Session or Environment fails with `assignment_conflict`. A started Run's frames, including its cancellation receipt, stay admissible under the assignment that started it until the release. A repeated function result or decision whose receipt the Runtime already recorded is answered only under the assignment that applied it; another fails with `assignment_conflict`. +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 its canonical absolute `workspace_directory`, read from the frozen Environment configuration, and waits for `assignment_status` `bound`. When the Runtime is an agent host and the Environment has a live [Link](./sandbox-link-protocol.md) resource, the bind also carries `resource`, that resource as the [bootstrap input](./sandbox-bootstrap.md#launch-input) names it, and `attach_grant`, the base64 grant with which the agent host opens services on that resource generation under this assignment and epoch. When the Environment has no live resource, Core sends the agent host no bind, and the operation that needed the bind fails. An Environment binding requires `workspace_directory`; environment `none` forbids it. The owner freezes this path before any preparation or file operation and keeps it across connection replacement. The grant is secret. Core sends neither field to any other Runtime. A bind with only one of them, or with a resource of another Environment, fails with `invalid_request`. A repeated bind of the same assignment and epoch with the same Environment, workspace, resource and grant is `bound` again; one with anything else fails with `assignment_conflict`. A bind of the bound assignment at a higher epoch supersedes the earlier epoch, with any resource or grant: the Runtime fences and cleans up the earlier epoch's work as a release does, keeping the home, then binds the new epoch and replies `bound`. A Session's Environment and workspace never change, so such a bind that names another Environment or workspace fails with `assignment_conflict` before anything is fenced. A bind of another assignment, or of a released assignment at a higher epoch, fails with `assignment_conflict`. Unfinished cleanup replies `failed` with `cleanup_unconfirmed`, and a retry at the same epoch repeats it; a release or a later bind in the meantime fails it with `assignment_stale`. The Runtime admits a Session frame only under the assignment it bound: an older epoch, or a released one, fails with `assignment_stale`; another assignment, Session or Environment fails with `assignment_conflict`. A started Run's frames, including its cancellation receipt, stay admissible under the assignment that started it until the release. A repeated function result or decision whose receipt the Runtime already recorded is answered only under the assignment that applied it; another fails with `assignment_conflict`. A Session's first bind resolves its Environment owner, which holds the Environment's resources and performs every effect on them; it does not change afterwards. The owner checks each `execution_prepare` configuration against the Environment, including the read-only profile, and fills the installed capabilities before an Executor starts. It applies `runtime_prepare`, lists directories for `workspace_read`, writes files for `workspace_write` and exports outputs for `workspace_export`. The Runtime's dispatcher keeps admission, transfer framing and fencing, and never substitutes another implementation. An agent host's owner works in the Session's sandbox through File and Process on a [Link](./sandbox-link-protocol.md) attachment of its own, opened under the bind's grant on first use. It lasts from the Session's first bind until its home is removed and outlives the Session's Executors and connections. Quiescing its Environment, releasing the assignment or superseding its epoch closes its attachment; a bind that supersedes the epoch takes the owner over once that attachment is closed. It runs each setup step as the Process operation whose ID is the `runtime_prepare` envelope ID. A `workspace_write` or `runtime_prepare` that cannot reach the sandbox before any effect ends `rejected` with `resource_unavailable`. It fails a Plugin whose MCP server declares literal `http_headers`, or is a stdio server with `env_vars`, before staging any of it. A file mutation or setup step whose outcome it cannot observe quarantines the owner until the home is removed: it sends no further mutation, and every later `workspace_write` and `runtime_prepare` ends `unknown`. A Session without an owner supports none of these operations, and the Runtime rejects each with its typed code: `unsupported_read_preparation` for a read-only preparation, `invalid_configuration` for an Executor configuration with a `local_environment`, `runtime_preparation_unsupported` for `runtime_prepare`, `write_unsupported` for `workspace_write`, and `read_unsupported` for `workspace_read` and `workspace_export`. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index bafbdc176..af91f0cd1 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: 5c72353535e1a5adc69cedde20408a38dfcfb2a4256c6335f9f9e381fc71b0ed +source_hash: 1c73efb6ca5e08eca71045cdc33717203afeec91fddb279dacdc58433afc9df3 --- 此协议在 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)。 @@ -59,7 +59,7 @@ heartbeat 只能收窄 Harness 的静态声明。某个 kind 没有内置声明 | `execution_controls` | 始终设置:解析后的 text verbosity(默认 `medium`)、明确禁用 programmatic tool calling,以及任何 `json_schema` 输出格式。原生选项名称由 adapter 负责 | | `observe_subagent_identities`, `disable_subagents` | 根据 Agent 的 `multi_agent.enabled` 设置 | | `disable_execution_environment` | Environment 类型为 `none` 时设置 | -| `local_environment` | 为 `openai_hosted` 和 `self_hosted` 设置,包含精确的 Environment 绑定。请求不携带 working directory;Environment owner 按 Environment 的工作区检查 `workspace_directory`。请求不携带网络策略,因为 Core 只准入[启用的网络](../../contracts/agents-api/zh/environments.md#restricted-network) | +| `local_environment` | 为 `openai_hosted` 和 `self_hosted` 设置,包含精确的 Environment 绑定。请求不携带 working directory;Environment owner 使用 `assignment_bind` 冻结的工作区。请求不携带网络策略,因为 Core 只准入[启用的网络](../../contracts/agents-api/zh/environments.md#restricted-network) | | `require_existing_native_session` | 需要恢复原生 Session 时设置 | 执行配置必须且只能包含 `local_environment` 和 `disable_execution_environment` 之一;两者都缺失或同时存在时,`execution_prepare` 以 `unsupported_configuration` 拒绝。 @@ -112,7 +112,7 @@ Usage frame 和最终 usage snapshot 都携带当前执行的累计测量,替 每个 Session frame 都携带分配:`execution_prepare`、`execution_start` 和 `execution_release`;`prompt_cancel`、`prompt_steer` 和 `function_result`;`runtime_prepare`、`workspace_read`、`workspace_write` 和 `workspace_export` 的每个 frame;以及 `environment_quiesce` 和 `environment_resume`。回复回显请求的分配,Run 的 frame 携带启动它的分配;Core 拒绝指明其他分配的回复或 Run frame。heartbeat 不携带分配。 -在一条连接上执行 Session 的第一个操作之前,包括没有 Turn 的 Environment 初始化和文件操作,Core 发送带 Session 的 Environment ID 的 `assignment_bind`,并等待 `assignment_status` `bound`。当 Runtime 是 agent host 且 Environment 有存活的 [Link](./sandbox-link-protocol.md) resource 时,绑定还携带 `resource` 和 `attach_grant`:前者是该 resource,形式与[引导输入](./sandbox-bootstrap.md#launch-input)中的相同;后者是 base64 编码的 grant,agent host 凭它在此分配和 epoch 下打开该 resource generation 上的服务。若 Environment 没有存活的 resource,Core 不向 agent host 发送绑定,需要该绑定的操作失败。grant 是机密。Core 不向其他任何 Runtime 发送这两个字段。只带其中一个字段、或带其他 Environment 的 resource 的绑定以 `invalid_request` 失败。以相同的 epoch、Environment、resource 和 grant 重复绑定同一分配仍得到 `bound`;其他字段不同的同 epoch 绑定以 `assignment_conflict` 失败。以更高 epoch 绑定已绑定的分配会取代较早的 epoch,resource 和 grant 均可不同:Runtime 像释放那样 fence 并清理较早 epoch 的工作,但保留 home,然后绑定新 epoch 并回复 `bound`。Session 的 Environment 从不改变,因此这样的绑定若指定其他 Environment,会在任何 fence 之前以 `assignment_conflict` 失败。其他分配的绑定,或以更高 epoch 绑定已释放的分配,以 `assignment_conflict` 失败。清理未完成时回复 `failed` 和 `cleanup_unconfirmed`,以同一 epoch 重试会重复清理;期间到达的释放或更晚的绑定使其以 `assignment_stale` 失败。Runtime 只在其已绑定的分配下准入 Session frame:较旧的 epoch 或已释放的分配以 `assignment_stale` 失败;其他分配、Session 或 Environment 以 `assignment_conflict` 失败。已启动 Run 的 frame,包括其取消回执,在释放前仍可在启动它的分配下准入。Runtime 已记录回执的重复函数结果或决策只在应用它的分配下得到回答;其他分配以 `assignment_conflict` 失败。 +在一条连接上执行 Session 的第一个操作之前,包括没有 Turn 的 Environment 初始化和文件操作,Core 发送带 Session 的 Environment ID 及从冻结的 Environment 配置读取的规范绝对路径 `workspace_directory` 的 `assignment_bind`,并等待 `assignment_status` `bound`。当 Runtime 是 agent host 且 Environment 有存活的 [Link](./sandbox-link-protocol.md) resource 时,绑定还携带 `resource` 和 `attach_grant`:前者是该 resource,形式与[引导输入](./sandbox-bootstrap.md#launch-input)中的相同;后者是 base64 编码的 grant,agent host 凭它在此分配和 epoch 下打开该 resource generation 上的服务。若 Environment 没有存活的 resource,Core 不向 agent host 发送绑定,需要该绑定的操作失败。有 Environment 的绑定必须携带 `workspace_directory`;environment `none` 禁止携带该字段。owner 在任何准备或文件操作之前冻结此路径,并在连接替换后保留它。grant 是机密。Core 不向其他任何 Runtime 发送这两个字段。只带其中一个字段、或带其他 Environment 的 resource 的绑定以 `invalid_request` 失败。以相同的 epoch、Environment、workspace、resource 和 grant 重复绑定同一分配仍得到 `bound`;其他字段不同的同 epoch 绑定以 `assignment_conflict` 失败。以更高 epoch 绑定已绑定的分配会取代较早的 epoch,resource 和 grant 均可不同:Runtime 像释放那样 fence 并清理较早 epoch 的工作,但保留 home,然后绑定新 epoch 并回复 `bound`。Session 的 Environment 和 workspace 从不改变,因此这样的绑定若指定其他 Environment 或 workspace,会在任何 fence 之前以 `assignment_conflict` 失败。其他分配的绑定,或以更高 epoch 绑定已释放的分配,以 `assignment_conflict` 失败。清理未完成时回复 `failed` 和 `cleanup_unconfirmed`,以同一 epoch 重试会重复清理;期间到达的释放或更晚的绑定使其以 `assignment_stale` 失败。Runtime 只在其已绑定的分配下准入 Session frame:较旧的 epoch 或已释放的分配以 `assignment_stale` 失败;其他分配、Session 或 Environment 以 `assignment_conflict` 失败。已启动 Run 的 frame,包括其取消回执,在释放前仍可在启动它的分配下准入。Runtime 已记录回执的重复函数结果或决策只在应用它的分配下得到回答;其他分配以 `assignment_conflict` 失败。 Session 的第一次绑定确定其 Environment owner,此后不再改变;owner 持有 Environment 的资源,并执行对这些资源的每个作用。owner 根据 Environment 检查每个 `execution_prepare` 配置(包括只读 profile),并在 Executor 启动前填入已安装的能力。它应用 `runtime_prepare`,为 `workspace_read` 列举目录,为 `workspace_write` 写入文件,为 `workspace_export` 导出输出。Runtime 的 dispatcher 保留准入、传输分帧和 fencing,从不替换为其他实现。agent host 的 owner 通过自己的一个 [Link](./sandbox-link-protocol.md) attachment,用 File 和 Process 在 Session 的沙箱中工作;该 attachment 在首次使用时凭绑定的 grant 打开。owner 从 Session 的第一次绑定存续到其 home 被删除,比 Session 的 Executor 和连接存续得更久。其 Environment 被 quiesce、分配被释放或其 epoch 被取代时关闭其 attachment;取代该 epoch 的绑定在该 attachment 关闭后接管 owner。它把每个 setup 步骤作为 Process 操作运行,操作 ID 即 `runtime_prepare` 的 envelope ID。在产生任何作用前无法连到沙箱的 `workspace_write` 或 `runtime_prepare` 以 `rejected` 和 `resource_unavailable` 结束。若 Plugin 的 MCP server 声明了字面量 `http_headers`,或是带 `env_vars` 的 stdio server,owner 会在暂存其任何内容之前使其失败。无法观察到结果的文件变更或 setup 步骤会隔离 owner,直到 home 被删除:它不再发送任何变更,之后每个 `workspace_write` 和 `runtime_prepare` 都以 `unknown` 结束。没有 owner 的 Session 不支持上述任何操作,Runtime 以各自的类型化错误码拒绝:只读 preparation 为 `unsupported_read_preparation`;带 `local_environment` 的 Executor 配置为 `invalid_configuration`;`runtime_prepare` 为 `runtime_preparation_unsupported`;`workspace_write` 为 `write_unsupported`;`workspace_read` 和 `workspace_export` 为 `read_unsupported`。 diff --git a/internal/agentdaemon/proto/assignment.go b/internal/agentdaemon/proto/assignment.go index 1dc69fd1d..fd2cd14a8 100644 --- a/internal/agentdaemon/proto/assignment.go +++ b/internal/agentdaemon/proto/assignment.go @@ -3,6 +3,7 @@ package proto import ( "errors" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" ) @@ -60,19 +61,28 @@ func (r AssignmentRef) Valid() bool { } // AssignmentBindPayload is what the Runtime fences the Session's frames with. -// EnvironmentID is empty for a Session without an Environment. Resource and -// AttachGrant come together, only to an agent host whose Environment has a -// Link resource: the agent host attaches to Resource with AttachGrant, which -// is secret. +// EnvironmentID and WorkspaceDirectory are empty for a Session without an +// Environment. Otherwise WorkspaceDirectory freezes its absolute root before +// any preparation or file operation. Resource and AttachGrant come together, +// only to an agent host whose Environment has a Link resource: the agent host +// attaches to Resource with AttachGrant, which is secret. type AssignmentBindPayload struct { - EnvironmentID string `json:"environment_id,omitempty"` - Resource *sandboxbootstrap.Resource `json:"resource,omitempty"` - AttachGrant []byte `json:"attach_grant,omitempty"` + EnvironmentID string `json:"environment_id,omitempty"` + WorkspaceDirectory string `json:"workspace_directory,omitempty"` + Resource *sandboxbootstrap.Resource `json:"resource,omitempty"` + AttachGrant []byte `json:"attach_grant,omitempty"` } -// Validate checks that Resource and AttachGrant come together and that -// Resource is a resource of the Environment. +// Validate checks the frozen workspace and requires Resource and AttachGrant +// together, with Resource belonging to the Environment. func (p AssignmentBindPayload) Validate() error { + if p.EnvironmentID == "" { + if p.WorkspaceDirectory != "" { + return errors.New("assignment_bind without an Environment forbids a workspace") + } + } else if agentcapabilities.ValidateSourceDirectories([]string{p.WorkspaceDirectory}) != nil { + return errors.New("assignment_bind requires a canonical absolute workspace") + } if p.Resource == nil && len(p.AttachGrant) == 0 { return nil } diff --git a/internal/agentdaemon/proto/assignment_test.go b/internal/agentdaemon/proto/assignment_test.go new file mode 100644 index 000000000..688981762 --- /dev/null +++ b/internal/agentdaemon/proto/assignment_test.go @@ -0,0 +1,53 @@ +package proto + +import ( + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" + "github.com/google/uuid" +) + +func TestAssignmentBindWorkspace(t *testing.T) { + environment := uuid.NewString() + for name, test := range map[string]struct { + workspace string + valid bool + }{ + "hosted": {"/workspace", true}, + "self hosted": {"/projects/my project", true}, + "root": {"/", true}, + "drive syntax": {`C:\projects`, true}, + "UNC syntax": {`\\host\share`, true}, + "missing": {"", false}, + "relative": {"projects", false}, + "parent": {"/projects/../other", false}, + "trailing slash": {"/projects/", false}, + "backslash": {`/projects\other`, false}, + "control": {"/projects\tother", false}, + "invalid UTF-8": {"/projects/\xff", false}, + "oversized": {"/" + strings.Repeat("a", 4096), false}, + } { + t.Run(name, func(t *testing.T) { + bind := AssignmentBindPayload{EnvironmentID: environment, WorkspaceDirectory: test.workspace} + if err := bind.Validate(); (err == nil) != test.valid { + t.Fatalf("Validate() = %v, want valid %t", err, test.valid) + } + }) + } + if err := (AssignmentBindPayload{}).Validate(); err != nil { + t.Fatalf("none: %v", err) + } + if err := (AssignmentBindPayload{WorkspaceDirectory: "/workspace"}).Validate(); err == nil { + t.Fatal("none accepted a workspace") + } + resource := &sandboxbootstrap.Resource{TenantID: uuid.NewString(), EnvironmentID: environment, Kind: "allocation", ID: uuid.NewString(), Generation: 1} + bind := AssignmentBindPayload{EnvironmentID: environment, WorkspaceDirectory: "/projects/custom", Resource: resource, AttachGrant: []byte("grant")} + if err := bind.Validate(); err != nil { + t.Fatalf("Link bind: %v", err) + } + bind.EnvironmentID, bind.WorkspaceDirectory = "", "" + if err := bind.Validate(); err == nil { + t.Fatal("none accepted an Environment resource") + } +} diff --git a/internal/agentdaemon/proto/envelope_test.go b/internal/agentdaemon/proto/envelope_test.go index 6c36fc215..0d294df64 100644 --- a/internal/agentdaemon/proto/envelope_test.go +++ b/internal/agentdaemon/proto/envelope_test.go @@ -71,6 +71,7 @@ func TestDecodeRequestRejectsUndeclaredMembers(t *testing.T) { payload string out any }{ + "workspace in preparation": {TypeExecutionPrepare, `{"session_id":"s","configuration":{"local_environment":{"id":"env","workspace_directory":"/other"}}}`, &ExecutionPreparePayload{}}, "unknown key": {TypeExecutionPrepare, `{"session_id":"s","configuration":{"agent_kind":"codex","model":"m","unknown":true}}`, &ExecutionPreparePayload{}}, "agent_options in preparation": {TypeExecutionPrepare, `{"session_id":"s","configuration":{"agent_kind":"codex","agent_options":{}}}`, &ExecutionPreparePayload{}}, } { diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 119bee8f8..f3acc8faf 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -2,12 +2,11 @@ package proto import "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" -// LocalEnvironment names a frozen workspace selection. Runtime must verify it -// against the bound local root before resolving capabilities or native execution. +// LocalEnvironment names the bound Environment and its preparation inputs. +// Its workspace is frozen by assignment_bind. type LocalEnvironment struct { - ID string `json:"id"` - WorkspaceDirectory string `json:"workspace_directory"` - CapabilitySources *agentcapabilities.Input `json:"capability_sources"` + ID string `json:"id"` + CapabilitySources *agentcapabilities.Input `json:"capability_sources"` // ToolEnvironment consumes Core-completed confidential initialization. ToolEnvironment bool `json:"tool_environment,omitempty"` } diff --git a/services/core/internal/db/queries/sandbox_link.sql b/services/core/internal/db/queries/sandbox_link.sql index a71f6742f..180b7dde6 100644 --- a/services/core/internal/db/queries/sandbox_link.sql +++ b/services/core/internal/db/queries/sandbox_link.sql @@ -23,11 +23,12 @@ WHERE id = $1 AND revoked_at IS NULL; -- name: GetLinkAssignment :one -- The assignment with its Runtime's Attach authority, the live Link resource --- of its Session's Environment and that Environment's network access. +-- of its Session's Environment and that Environment's frozen configuration and network access. SELECT b.session_id, b.runtime_id, b.epoch, b.desired_state = 'bound' AS bound, (d.revoked_at IS NULL)::boolean AS agent_host, d.credential_revision, r.tenant_id AS resource_tenant_id, r.environment_id AS resource_environment_id, r.kind AS resource_kind, r.id AS resource_id, r.generation AS resource_generation, + (s.configuration->'environment')::jsonb AS environment_configuration, (COALESCE(s.configuration->'environment'->'network'->>'access', 'enabled') = 'enabled')::boolean AS network_enabled FROM session_runtime_assignments b JOIN sessions s ON s.id = b.session_id diff --git a/services/core/internal/db/sqlc/sandbox_link.sql.go b/services/core/internal/db/sqlc/sandbox_link.sql.go index ab0ae31ad..b41844239 100644 --- a/services/core/internal/db/sqlc/sandbox_link.sql.go +++ b/services/core/internal/db/sqlc/sandbox_link.sql.go @@ -64,6 +64,7 @@ SELECT b.session_id, b.runtime_id, b.epoch, b.desired_state = 'bound' AS bound, (d.revoked_at IS NULL)::boolean AS agent_host, d.credential_revision, r.tenant_id AS resource_tenant_id, r.environment_id AS resource_environment_id, r.kind AS resource_kind, r.id AS resource_id, r.generation AS resource_generation, + (s.configuration->'environment')::jsonb AS environment_configuration, (COALESCE(s.configuration->'environment'->'network'->>'access', 'enabled') = 'enabled')::boolean AS network_enabled FROM session_runtime_assignments b JOIN sessions s ON s.id = b.session_id @@ -74,22 +75,23 @@ WHERE b.assignment_id = $1 ` type GetLinkAssignmentRow struct { - SessionID pgtype.UUID `json:"session_id"` - RuntimeID pgtype.UUID `json:"runtime_id"` - Epoch int64 `json:"epoch"` - Bound bool `json:"bound"` - AgentHost bool `json:"agent_host"` - CredentialRevision int64 `json:"credential_revision"` - ResourceTenantID pgtype.UUID `json:"resource_tenant_id"` - ResourceEnvironmentID pgtype.UUID `json:"resource_environment_id"` - ResourceKind pgtype.Text `json:"resource_kind"` - ResourceID pgtype.UUID `json:"resource_id"` - ResourceGeneration pgtype.Int8 `json:"resource_generation"` - NetworkEnabled bool `json:"network_enabled"` + SessionID pgtype.UUID `json:"session_id"` + RuntimeID pgtype.UUID `json:"runtime_id"` + Epoch int64 `json:"epoch"` + Bound bool `json:"bound"` + AgentHost bool `json:"agent_host"` + CredentialRevision int64 `json:"credential_revision"` + ResourceTenantID pgtype.UUID `json:"resource_tenant_id"` + ResourceEnvironmentID pgtype.UUID `json:"resource_environment_id"` + ResourceKind pgtype.Text `json:"resource_kind"` + ResourceID pgtype.UUID `json:"resource_id"` + ResourceGeneration pgtype.Int8 `json:"resource_generation"` + EnvironmentConfiguration []byte `json:"environment_configuration"` + NetworkEnabled bool `json:"network_enabled"` } // The assignment with its Runtime's Attach authority, the live Link resource -// of its Session's Environment and that Environment's network access. +// of its Session's Environment and that Environment's frozen configuration and network access. func (q *Queries) GetLinkAssignment(ctx context.Context, assignmentID pgtype.UUID) (GetLinkAssignmentRow, error) { row := q.db.QueryRow(ctx, getLinkAssignment, assignmentID) var i GetLinkAssignmentRow @@ -105,6 +107,7 @@ func (q *Queries) GetLinkAssignment(ctx context.Context, assignmentID pgtype.UUI &i.ResourceKind, &i.ResourceID, &i.ResourceGeneration, + &i.EnvironmentConfiguration, &i.NetworkEnabled, ) return i, err diff --git a/services/core/internal/environmentconfig/placement.go b/services/core/internal/environmentconfig/placement.go new file mode 100644 index 000000000..dcc0a08bf --- /dev/null +++ b/services/core/internal/environmentconfig/placement.go @@ -0,0 +1,75 @@ +package environmentconfig + +import ( + "bytes" + "encoding/json" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" +) + +// Placement is the frozen workspace and preparation selection of an Environment. +type Placement struct { + Plugins []agentplugin.Metadata `json:"plugins,omitempty"` + Skills []SkillMetadata `json:"skills,omitempty"` + Type string `json:"type"` + ToolEnvironment bool `json:"initialization,omitempty"` + NetworkAccess string `json:"-"` + AllowedDomains []string `json:"-"` + WorkspaceDirectory string `json:"workspace_directory"` + CapabilityDirectories []string `json:"capability_directories"` +} + +// ParsePlacement validates stored Environment configuration without accessing its workspace. +func ParsePlacement(configuration json.RawMessage) (Placement, error) { + var placement Placement + if json.Unmarshal(configuration, &placement) != nil { + return placement, ErrInvalid + } + var local struct { + Plugins []agentplugin.Metadata `json:"plugins,omitempty"` + Skills []SkillMetadata `json:"skills,omitempty"` + Files []InitialFileMetadata `json:"files"` + Packages *v1.EnvironmentPackages `json:"packages,omitempty"` + Initialization bool `json:"initialization,omitempty"` + Type string `json:"type"` + WorkspaceDirectory string `json:"workspace_directory,omitempty"` + CapabilityDirectories []string `json:"capability_directories"` + Network *v1.EnvironmentNetworkInput `json:"network"` + } + decoder := json.NewDecoder(bytes.NewReader(configuration)) + decoder.DisallowUnknownFields() + if decoder.Decode(&local) != nil || agentcapabilities.ValidateSourceDirectories(local.CapabilityDirectories) != nil { + return placement, ErrInvalid + } + // Placement selects a workspace; all preparation fields are shared. + switch placement.Type { + case "openai_hosted": + if local.WorkspaceDirectory != "" || agentcapabilities.ValidateDirectories(local.CapabilityDirectories) != nil { + return placement, ErrInvalid + } + placement.WorkspaceDirectory = "/workspace" + case "self_hosted": + if !ValidSelfHostedPlacement(placement) { + return placement, ErrInvalid + } + default: + return placement, ErrInvalid + } + placement.NetworkAccess = "enabled" + if local.Network != nil { + if (agentnetwork.Policy{Access: local.Network.Access, AllowedDomains: local.Network.AllowedDomains}).Validate() != nil { + return placement, ErrInvalid + } + placement.NetworkAccess, placement.AllowedDomains = local.Network.Access, append([]string(nil), local.Network.AllowedDomains...) + } + return placement, nil +} + +// ValidSelfHostedPlacement checks the path restrictions shared by admission and stored configuration. +func ValidSelfHostedPlacement(placement Placement) bool { + return agentcapabilities.ValidateSourceDirectories([]string{placement.WorkspaceDirectory}) == nil && + agentcapabilities.ValidateSourceDirectories(placement.CapabilityDirectories) == nil +} diff --git a/services/core/internal/execution/environment_capabilities_test.go b/services/core/internal/execution/environment_capabilities_test.go index 12350f637..433e91e24 100644 --- a/services/core/internal/execution/environment_capabilities_test.go +++ b/services/core/internal/execution/environment_capabilities_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -18,7 +19,7 @@ func TestSelfHostedCapabilitySourcesAreFrozenAndStrict(t *testing.T) { t.Fatal(err) } local := request.LocalEnvironment - if local.WorkspaceDirectory != "/home/user/project" || local.ToolEnvironment || + if local.ToolEnvironment || local.CapabilitySources == nil || !slices.Equal(local.CapabilitySources.Directories, []string{"/home/user/skills", "/opt/plugins"}) { t.Fatal("frozen source selections lost", local) } @@ -38,9 +39,13 @@ func TestSelfHostedPreparedPathsArePlatformNeutral(t *testing.T) { raw, _ := json.Marshal(map[string]any{"type": "self_hosted", "workspace_directory": directory, "capability_directories": []string{directory}}) session := sessions.Session{ID: "session", TenantID: "tenant"} environment := sessions.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: raw} + placement, parseErr := environmentconfig.ParsePlacement(raw) + if parseErr != nil || placement.WorkspaceDirectory != directory || !LocalWorkspaceConfiguration(raw) { + t.Fatal("stored workspace parser changed admission", directory, parseErr) + } var request proto.PromptRequestPayload err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, sessions.ExecutionDevice{SessionEnvironmentID: environment.ID}, &request) - if err != nil || request.LocalEnvironment.WorkspaceDirectory != directory || request.LocalEnvironment.CapabilitySources.Directories[0] != directory { + if err != nil || request.LocalEnvironment.CapabilitySources.Directories[0] != directory { t.Fatal("Core interpreted a Runtime source path", directory, err) } } diff --git a/services/core/internal/execution/environment_directory.go b/services/core/internal/execution/environment_directory.go index 1b9d2619c..d8afd719b 100644 --- a/services/core/internal/execution/environment_directory.go +++ b/services/core/internal/execution/environment_directory.go @@ -5,6 +5,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -78,7 +79,7 @@ func (w *Worker) runDirectoryRead(owner context.Context, request directoryReadRe result.err = sessions.ErrNotFound return } - if _, err := parseEnvironmentPlacement(environment.Configuration); err != nil { + if _, err := environmentconfig.ParsePlacement(environment.Configuration); err != nil { return } session, err := w.dispatcher.SessionsReader.GetSession(check, environment.TenantID, environment.SessionID) diff --git a/services/core/internal/execution/environment_file_write.go b/services/core/internal/execution/environment_file_write.go index cac1d95d7..538cdd957 100644 --- a/services/core/internal/execution/environment_file_write.go +++ b/services/core/internal/execution/environment_file_write.go @@ -9,6 +9,7 @@ import ( "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" "github.com/google/uuid" @@ -81,7 +82,7 @@ func (w *Worker) runFileWrite(owner context.Context, request fileWriteRequest) f if environment.SessionID != request.environment.SessionID { return fileWriteResult{err: sessions.ErrNotFound} } - placement, err := parseEnvironmentPlacement(environment.Configuration) + placement, err := environmentconfig.ParsePlacement(environment.Configuration) if err != nil || (placement.Type != "openai_hosted" && placement.Type != "self_hosted") { return unavailable } diff --git a/services/core/internal/execution/environment_placement.go b/services/core/internal/execution/environment_placement.go index 4940dc885..8d59abc80 100644 --- a/services/core/internal/execution/environment_placement.go +++ b/services/core/internal/execution/environment_placement.go @@ -1,83 +1,24 @@ package execution import ( - "bytes" "encoding/json" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentnetwork" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -type environmentPlacement struct { - Plugins []agentplugin.Metadata `json:"plugins,omitempty"` - Skills []environmentconfig.SkillMetadata `json:"skills,omitempty"` - Type string `json:"type"` - ToolEnvironment bool `json:"initialization,omitempty"` - NetworkAccess string `json:"-"` - AllowedDomains []string `json:"-"` - WorkspaceDirectory string `json:"workspace_directory"` - CapabilityDirectories []string `json:"capability_directories"` -} - // LocalWorkspaceConfiguration recognizes the qualified stored V1 profile. It // does not provision a Runtime, validate live authority, or admit hosted creation. func LocalWorkspaceConfiguration(configuration json.RawMessage) bool { - placement, err := parseEnvironmentPlacement(configuration) + placement, err := environmentconfig.ParsePlacement(configuration) return err == nil && (placement.Type == "openai_hosted" || placement.Type == "self_hosted") } -func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacement, error) { - var placement environmentPlacement - if json.Unmarshal(configuration, &placement) != nil { - return placement, sessions.ErrInvalidInput - } - var local struct { - Plugins []agentplugin.Metadata `json:"plugins,omitempty"` - Skills []environmentconfig.SkillMetadata `json:"skills,omitempty"` - Files []environmentconfig.InitialFileMetadata `json:"files"` - Packages *v1.EnvironmentPackages `json:"packages,omitempty"` - Initialization bool `json:"initialization,omitempty"` - Type string `json:"type"` - WorkspaceDirectory string `json:"workspace_directory,omitempty"` - CapabilityDirectories []string `json:"capability_directories"` - Network *v1.EnvironmentNetworkInput `json:"network"` - } - decoder := json.NewDecoder(bytes.NewReader(configuration)) - decoder.DisallowUnknownFields() - if decoder.Decode(&local) != nil || agentcapabilities.ValidateSourceDirectories(local.CapabilityDirectories) != nil { - return placement, sessions.ErrInvalidInput - } - // Placement selects a workspace; all preparation fields are shared. - switch placement.Type { - case "openai_hosted": - if local.WorkspaceDirectory != "" || agentcapabilities.ValidateDirectories(local.CapabilityDirectories) != nil { - return placement, sessions.ErrInvalidInput - } - placement.WorkspaceDirectory = "/workspace" - case "self_hosted": - if !validSelfHostedPlacement(placement) { - return placement, sessions.ErrInvalidInput - } - default: - return placement, sessions.ErrInvalidInput - } - placement.NetworkAccess = "enabled" - if local.Network != nil { - if (agentnetwork.Policy{Access: local.Network.Access, AllowedDomains: local.Network.AllowedDomains}).Validate() != nil { - return placement, sessions.ErrInvalidInput - } - placement.NetworkAccess, placement.AllowedDomains = local.Network.Access, append([]string(nil), local.Network.AllowedDomains...) - } - return placement, nil -} - func (d *Dispatcher) configurePreparedEnvironment(session sessions.Session, environment sessions.Environment, bound sessions.ExecutionDevice, req *proto.PromptRequestPayload) error { - placement, err := parseEnvironmentPlacement(environment.Configuration) + placement, err := environmentconfig.ParsePlacement(environment.Configuration) if err != nil || environment.SessionID != session.ID || environment.TenantID != session.TenantID || bound.SessionEnvironmentID != environment.ID { return sessions.ErrInvalidInput } @@ -88,11 +29,6 @@ func (d *Dispatcher) configurePreparedEnvironment(session sessions.Session, envi } sources.Skills = append(sources.Skills, (environmentconfig.Skill{Metadata: metadata}).InstallationMetadata()) } - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, WorkspaceDirectory: placement.WorkspaceDirectory, CapabilitySources: sources, ToolEnvironment: placement.ToolEnvironment} + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, CapabilitySources: sources, ToolEnvironment: placement.ToolEnvironment} return nil } - -func validSelfHostedPlacement(placement environmentPlacement) bool { - return agentcapabilities.ValidateSourceDirectories([]string{placement.WorkspaceDirectory}) == nil && - agentcapabilities.ValidateSourceDirectories(placement.CapabilityDirectories) == nil -} diff --git a/services/core/internal/execution/environment_placement_test.go b/services/core/internal/execution/environment_placement_test.go index 16e69d7a5..47bb81ce6 100644 --- a/services/core/internal/execution/environment_placement_test.go +++ b/services/core/internal/execution/environment_placement_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -39,7 +40,7 @@ func TestLocalEnvironmentRequiresQualifiedProfileAndExactAuthority(t *testing.T) `{"type":"openai_hosted","network":{"access":"disabled","allow":["example.com"]}}`, `{"type":"openai_hosted","network":{"access":"disabled"},"workspace_directory":"/override"}`, } { - if _, err := parseEnvironmentPlacement([]byte(configuration)); err == nil { + if _, err := environmentconfig.ParsePlacement([]byte(configuration)); err == nil { t.Fatalf("unqualified private profile accepted: %s", configuration) } } diff --git a/services/core/internal/execution/runtime_lifecycle.go b/services/core/internal/execution/runtime_lifecycle.go index d46f0389f..5d05c3a3a 100644 --- a/services/core/internal/execution/runtime_lifecycle.go +++ b/services/core/internal/execution/runtime_lifecycle.go @@ -14,6 +14,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/relay" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -167,7 +168,7 @@ func (r *runtimeLifecycle) provision(ctx context.Context, tenant, environment, p if err != nil { return deployment.Allocation{}, err } - placement, err := parseEnvironmentPlacement(environmentValue.Configuration) + placement, err := environmentconfig.ParsePlacement(environmentValue.Configuration) if err != nil || placement.Type != "openai_hosted" { return deployment.Allocation{}, sandbox.ErrInvalid } diff --git a/services/core/internal/execution/support.go b/services/core/internal/execution/support.go index d048f97c0..62c80618d 100644 --- a/services/core/internal/execution/support.go +++ b/services/core/internal/execution/support.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -27,7 +28,7 @@ func ValidateSessionConfiguration(engine string, configuration json.RawMessage) switch snapshot.Environment.Type { case "none": case "self_hosted": - if strings.TrimSpace(snapshot.Agent.Model) == "" || !validSelfHostedPlacement(environmentPlacement{WorkspaceDirectory: snapshot.Environment.WorkspaceDirectory, CapabilityDirectories: snapshot.Environment.CapabilityDirectories}) { + if strings.TrimSpace(snapshot.Agent.Model) == "" || !environmentconfig.ValidSelfHostedPlacement(environmentconfig.Placement{WorkspaceDirectory: snapshot.Environment.WorkspaceDirectory, CapabilityDirectories: snapshot.Environment.CapabilityDirectories}) { return sessions.ErrInvalidInput } case "openai_hosted": diff --git a/services/core/internal/execution/worker_device.go b/services/core/internal/execution/worker_device.go index e068a83fc..7ebdcfcb7 100644 --- a/services/core/internal/execution/worker_device.go +++ b/services/core/internal/execution/worker_device.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -83,7 +84,7 @@ func (w *Worker) bindSessionDevice(ctx context.Context, session sessions.Session if err != nil { return false, err } - if _, err := parseEnvironmentPlacement(environment.Configuration); err != nil || environment.Initialization != "complete" { + if _, err := environmentconfig.ParsePlacement(environment.Configuration); err != nil || environment.Initialization != "complete" { return false, nil } allocation, err := w.dispatcher.DeploymentReader.EnvironmentAllocation(ctx, deployment.AllocationKey{TenantID: session.TenantID, EnvironmentID: environment.ID}) diff --git a/services/core/internal/persistence/postgres/sessionpg/link.go b/services/core/internal/persistence/postgres/sessionpg/link.go index 360e44e35..03f7e50a0 100644 --- a/services/core/internal/persistence/postgres/sessionpg/link.go +++ b/services/core/internal/persistence/postgres/sessionpg/link.go @@ -119,7 +119,8 @@ func (s *Store) GetLinkAssignment(ctx context.Context, assignment string) (runti return runtimedevice.LinkAssignment{ SessionID: optionalID(row.SessionID), RuntimeID: optionalID(row.RuntimeID), Epoch: uint64(row.Epoch), Bound: row.Bound, AgentHost: row.AgentHost, Revision: uint64(row.CredentialRevision), NetworkEnabled: row.NetworkEnabled, - Resource: linkResource(row.ResourceTenantID, row.ResourceEnvironmentID, row.ResourceKind, row.ResourceID, row.ResourceGeneration), + EnvironmentConfiguration: row.EnvironmentConfiguration, + Resource: linkResource(row.ResourceTenantID, row.ResourceEnvironmentID, row.ResourceKind, row.ResourceID, row.ResourceGeneration), }, true, nil } diff --git a/services/core/internal/runtimedevice/link.go b/services/core/internal/runtimedevice/link.go index e7a8a66c3..1eba48bcc 100644 --- a/services/core/internal/runtimedevice/link.go +++ b/services/core/internal/runtimedevice/link.go @@ -1,6 +1,10 @@ package runtimedevice -import "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" +import ( + "encoding/json" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" +) // ServeAuthority is a live Link resource and the SHA-256 hex digest of the // credential that serves it. @@ -25,6 +29,8 @@ type LinkAssignment struct { // NetworkEnabled reports that the Environment's network access is // enabled. NetworkEnabled bool + // EnvironmentConfiguration is the Session's frozen Environment selection. + EnvironmentConfiguration json.RawMessage } // AgentHost is a live agent host's credential digest and revision. diff --git a/services/core/internal/runtimegateway/assignment.go b/services/core/internal/runtimegateway/assignment.go index 0ce761ca0..20bf86686 100644 --- a/services/core/internal/runtimegateway/assignment.go +++ b/services/core/internal/runtimegateway/assignment.go @@ -27,9 +27,12 @@ func (s *Session) Bind(ctx context.Context, ref proto.AssignmentRef, environment ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() payload := proto.AssignmentBindPayload{EnvironmentID: environmentID} + if environmentID != "" && s.links == nil { + return ErrNoLinkResource + } if s.links != nil { var err error - if payload.Resource, payload.AttachGrant, err = s.links.bindLink(ctx, s.DeviceID, ref, environmentID); err != nil { + if payload, err = s.links.bindLink(ctx, s.DeviceID, ref, environmentID); err != nil { return err } } diff --git a/services/core/internal/runtimegateway/link.go b/services/core/internal/runtimegateway/link.go index 3c2a9210d..1dd03c46c 100644 --- a/services/core/internal/runtimegateway/link.go +++ b/services/core/internal/runtimegateway/link.go @@ -14,10 +14,10 @@ import ( "github.com/google/uuid" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" ) @@ -165,32 +165,35 @@ func (l *LinkAuthority) authorize(ctx context.Context, peer sandboxlink.AttachPe // Link resource: the agent host reaches an Environment only through one. var ErrNoLinkResource = errors.New("agentdaemon gateway: the Environment has no live Link resource") -// bindLink returns the Link resource and attach grant that runtimeID's bind of -// ref in environmentID carries: none unless runtimeID is the agent host that -// holds ref bound, and then environmentID's live Link resource, without which -// it is ErrNoLinkResource. -func (l *LinkAuthority) bindLink(ctx context.Context, runtimeID string, ref proto.AssignmentRef, environmentID string) (*sandboxbootstrap.Resource, []byte, error) { +// bindLink resolves the immutable Environment workspace and live Link authority +// before the Runtime receives any operation on the assignment. +func (l *LinkAuthority) bindLink(ctx context.Context, runtimeID string, ref proto.AssignmentRef, environmentID string) (proto.AssignmentBindPayload, error) { + payload := proto.AssignmentBindPayload{EnvironmentID: environmentID} if environmentID == "" { - return nil, nil, nil + return payload, nil } assignment, found, err := l.store.GetLinkAssignment(ctx, ref.AssignmentID) - if err != nil || !found || !assignment.AgentHost || !assignment.Bound || - assignment.RuntimeID != runtimeID || assignment.SessionID != ref.SessionID || assignment.Epoch != ref.Epoch { - return nil, nil, err + if err != nil { + return payload, err + } + if !found || !assignment.AgentHost || !assignment.Bound || assignment.RuntimeID != runtimeID || assignment.SessionID != ref.SessionID || assignment.Epoch != ref.Epoch || assignment.Resource.EnvironmentID != environmentID { + return payload, ErrNoLinkResource } - if assignment.Resource.EnvironmentID != environmentID { - return nil, nil, ErrNoLinkResource + placement, err := environmentconfig.ParsePlacement(assignment.EnvironmentConfiguration) + if err != nil { + return payload, err } id, err := uuid.Parse(ref.AssignmentID) if err != nil { - return nil, nil, err + return payload, err } resource := assignment.Resource grant, err := l.grant(ctx, sandboxwire.ID(id), ref.Epoch, resource.Kind, resource.ID, resource.Generation) if err != nil { - return nil, nil, err + return payload, err } - return &resource, grant, nil + payload.Resource, payload.AttachGrant, payload.WorkspaceDirectory = &resource, grant, placement.WorkspaceDirectory + return payload, nil } // grantBytes is the size of an attach grant: the assignment ID, epoch and diff --git a/services/core/internal/runtimegateway/link_binding_test.go b/services/core/internal/runtimegateway/link_binding_test.go new file mode 100644 index 000000000..28c3ce62a --- /dev/null +++ b/services/core/internal/runtimegateway/link_binding_test.go @@ -0,0 +1,64 @@ +package runtimegateway + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" +) + +type bindingStore struct { + LinkStore + assignment runtimedevice.LinkAssignment + signed int +} + +func (s *bindingStore) GetLinkAssignment(context.Context, string) (runtimedevice.LinkAssignment, bool, error) { + return s.assignment, true, nil +} + +func (s *bindingStore) SignAttachGrant(context.Context, string) (string, error) { + s.signed++ + return strings.Repeat("a", 64), nil +} + +func TestBindLinkResolvesWorkspaceBeforeSigning(t *testing.T) { + ref := proto.AssignmentRef{SessionID: "session", AssignmentID: "4fe54117-6229-41fa-ac03-104754dc3957", Epoch: 1} + for _, tc := range []struct { + name, configuration, environment, workspace string + rejected bool + }{ + {"hosted", `{"type":"openai_hosted"}`, "environment", "/workspace", false}, + {"self hosted", `{"type":"self_hosted","workspace_directory":"/home/user/project"}`, "environment", "/home/user/project", false}, + {"none", `{"type":"none"}`, "", "", false}, + {"relative workspace", `{"type":"self_hosted","workspace_directory":"relative"}`, "environment", "", true}, + {"unrecognized configuration", `{"type":"self_hosted","workspace_directory":"/project","extra":true}`, "environment", "", true}, + {"missing environment", `{"type":"none"}`, "environment", "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + store := &bindingStore{assignment: runtimedevice.LinkAssignment{SessionID: ref.SessionID, RuntimeID: "host", Epoch: ref.Epoch, Bound: true, AgentHost: true, + Resource: sandboxbootstrap.Resource{EnvironmentID: "environment", Kind: "enrollment", ID: "resource", Generation: 1}, EnvironmentConfiguration: json.RawMessage(tc.configuration)}} + payload, err := NewLinkAuthority(store).bindLink(t.Context(), "host", ref, tc.environment) + if tc.rejected { + if err == nil || store.signed != 0 { + t.Fatal("invalid workspace gained an attach grant", err, store.signed) + } + return + } + if err != nil || payload.EnvironmentID != tc.environment || payload.WorkspaceDirectory != tc.workspace { + t.Fatal("bind lost its workspace", payload, err) + } + if tc.environment == "" { + if payload.Resource != nil || len(payload.AttachGrant) != 0 || store.signed != 0 { + t.Fatal("none gained Link authority", payload) + } + } else if payload.Resource == nil || len(payload.AttachGrant) == 0 || store.signed != 1 { + t.Fatal("workspace bind lost Link authority", payload) + } + }) + } +} diff --git a/services/core/tests/integration/environment_directory_test.go b/services/core/tests/integration/environment_directory_test.go index a5276c5a4..cb4f64f70 100644 --- a/services/core/tests/integration/environment_directory_test.go +++ b/services/core/tests/integration/environment_directory_test.go @@ -20,7 +20,7 @@ type directoryResult struct { func directoryWorker(t *testing.T) (*dispatchHarness, *execution.Worker, sessions.Environment) { t.Helper() - h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"unavailable-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)) + h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"unavailable-model"},"environment":{"type":"self_hosted","workspace_directory":"/home/user/project"}}`)) environment, err := sessionAdapter(h.s).GetSessionEnvironment(t.Context(), h.tenant, h.session.ID) if err != nil { t.Fatal(err) @@ -115,6 +115,12 @@ func TestEnvironmentDirectoryWorkerReadsWithoutExecutionPrerequisites(t *testing t.Fatal("wrong Session admitted", err) } result := startDirectoryRead(t.Context(), w, environment) + bind := h.read(proto.TypeAssignmentBind) + var binding proto.AssignmentBindPayload + if bind.DecodePayload(&binding) != nil || binding.EnvironmentID != environment.ID || binding.WorkspaceDirectory != "/home/user/project" { + t.Fatal("read did not bind the frozen workspace before preparation", binding) + } + h.assignmentFrame(bind) request, read := prepareDirectoryRead(t, h, environment) if _, err := w.ReadEnvironmentDirectory(t.Context(), environment, "reports"); !errors.Is(err, execution.ErrExecutionUnavailable) { t.Fatal("second idle reader bypassed Session owner", err) diff --git a/services/core/tests/integration/environment_file_write_semantics_public_test.go b/services/core/tests/integration/environment_file_write_semantics_public_test.go index e7910318d..80510737a 100644 --- a/services/core/tests/integration/environment_file_write_semantics_public_test.go +++ b/services/core/tests/integration/environment_file_write_semantics_public_test.go @@ -49,7 +49,7 @@ func serveFileWrite(h *dispatchHarness, final proto.WorkspaceWriteResultPayload) } func TestEnvironmentFileCreateRejectionsLeaveNoReceiptOrConsumption(t *testing.T) { - h, w, environment := localWorker(t, false) + h, w, environment := localWorkerForSession(t, false, `{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/home/user/project"}}`) pool := h.s.pool if _, err := pool.Exec(t.Context(), `UPDATE environments SET status='connected' WHERE id=$1`, environment.ID); err != nil { t.Fatal(err) @@ -130,7 +130,7 @@ func TestEnvironmentFileCreateRejectionsLeaveNoReceiptOrConsumption(t *testing.T rejected := func(reason string) proto.WorkspaceWriteResultPayload { return proto.WorkspaceWriteResultPayload{Outcome: "rejected", ErrorCode: "write_rejected", Reason: reason} } - for _, tc := range []struct { + for i, tc := range []struct { body, reason, message string }{ {`{"type":"inline","data":"YWJj","path":"/workspace/n1"}`, proto.WorkspaceWriteReasonDirectory, "file path conflicts with an existing environment file"}, @@ -138,6 +138,14 @@ func TestEnvironmentFileCreateRejectionsLeaveNoReceiptOrConsumption(t *testing.T {copyBody, proto.WorkspaceWriteReasonUnsafe, "environment.files paths must not traverse symlinks or overwrite existing files"}, } { done := post(token, environment.ID, tc.body) + if i == 0 { + bind := h.read(proto.TypeAssignmentBind) + var binding proto.AssignmentBindPayload + if bind.DecodePayload(&binding) != nil || binding.EnvironmentID != environment.ID || binding.WorkspaceDirectory != "/home/user/project" { + t.Fatal("Files.create did not bind the frozen workspace before transfer", binding) + } + h.assignmentFrame(bind) + } id := serveFileWrite(h, rejected(tc.reason)) assertError(await(done), tc.message) if intent, err := FixtureFileWrite(t.Context(), h.s.pool, h.tenant, environment.ID, id); err != nil || intent.State != "rejected" { diff --git a/services/core/tests/integration/link_authority_test.go b/services/core/tests/integration/link_authority_test.go index c2c149444..395f80cd6 100644 --- a/services/core/tests/integration/link_authority_test.go +++ b/services/core/tests/integration/link_authority_test.go @@ -527,7 +527,11 @@ func TestRegisteredAgentHostAuthenticates(t *testing.T) { // connection closes, leaves the initialization unclaimed, and a later pass // completes it. func TestInitializationBindsAgentHost(t *testing.T) { - for _, environment := range []string{`{"type":"openai_hosted"}`, `{"type":"self_hosted","workspace_directory":"/workspace"}`} { + for _, tc := range []struct{ environment, workspace string }{ + {`{"type":"openai_hosted"}`, "/workspace"}, + {`{"type":"self_hosted","workspace_directory":"/home/user/project"}`, "/home/user/project"}, + } { + environment := tc.environment t.Run(environment, func(t *testing.T) { // Hosted work is admitted only on a configured deployment. s, _ := configuredStore(t) @@ -565,7 +569,7 @@ func TestInitializationBindsAgentHost(t *testing.T) { if err := peer.connect(sandbox.Bootstrap{}); err != nil { t.Fatal(err) } - if bind := within(t, peer.binds); bind.EnvironmentID != session.Environment.ID || bind.Resource == nil || *bind.Resource != resource || len(bind.AttachGrant) == 0 { + if bind := within(t, peer.binds); bind.EnvironmentID != session.Environment.ID || bind.WorkspaceDirectory != tc.workspace || bind.Resource == nil || *bind.Resource != resource || len(bind.AttachGrant) == 0 { t.Fatalf("agent host bind = %+v", bind) } awaitInitialization(t, s, tenant, session.Environment.ID, "complete") diff --git a/services/core/tests/integration/local_environment_file_write_test.go b/services/core/tests/integration/local_environment_file_write_test.go index 251617bbf..730ac4694 100644 --- a/services/core/tests/integration/local_environment_file_write_test.go +++ b/services/core/tests/integration/local_environment_file_write_test.go @@ -38,7 +38,7 @@ func awaitLocalWrite(t *testing.T, done <-chan localWriteResult) localWriteResul } func TestLocalEnvironmentFileWriteOwnsMutationBeforeDispatch(t *testing.T) { - h, w, environment := localWorker(t, false) + h, w, environment := localWorkerForSession(t, false, `{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/home/user/project"}}`) foreign := environment foreign.TenantID = uuid.NewString() if _, err := w.WriteEnvironmentFile(t.Context(), foreign, "input", nil); !errors.Is(err, sessions.ErrNotFound) { @@ -50,7 +50,7 @@ func TestLocalEnvironmentFileWriteOwnsMutationBeforeDispatch(t *testing.T) { // Environment work without a Turn binds the Session's assignment first. bind := h.read(proto.TypeAssignmentBind) var binding proto.AssignmentBindPayload - if bind.DecodePayload(&binding) != nil || bind.Assignment.SessionID != h.session.ID || bind.Assignment.Epoch != 1 || binding.EnvironmentID != environment.ID { + if bind.DecodePayload(&binding) != nil || bind.Assignment.SessionID != h.session.ID || bind.Assignment.Epoch != 1 || binding.EnvironmentID != environment.ID || binding.WorkspaceDirectory != "/home/user/project" { t.Fatal("upload did not bind the Session's assignment") } h.assignmentFrame(bind) diff --git a/services/core/tests/integration/local_environment_worker_test.go b/services/core/tests/integration/local_environment_worker_test.go index 420d71d92..8e42f1522 100644 --- a/services/core/tests/integration/local_environment_worker_test.go +++ b/services/core/tests/integration/local_environment_worker_test.go @@ -15,7 +15,12 @@ import ( func localWorker(t *testing.T, execute bool) (*dispatchHarness, *execution.Worker, sessions.Environment) { t.Helper() - h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`)) + return localWorkerForSession(t, execute, `{"agent":{"model":"test-model"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`) +} + +func localWorkerForSession(t *testing.T, execute bool, configuration string) (*dispatchHarness, *execution.Worker, sessions.Environment) { + t.Helper() + h := newDispatchHarnessForSession(t, []byte(configuration)) environment, err := sessionAdapter(h.s).GetSessionEnvironment(t.Context(), h.tenant, h.session.ID) if err != nil { t.Fatal(err) From 9ceaff25af6e31ea7067e8c7af3442a307686c1a Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 13:05:23 +0000 Subject: [PATCH 2/3] Keep the initial workspace inside the sandbox view --- apps/daemon/internal/agent/harness.go | 3 +- apps/daemon/internal/agenthost/admit.go | 31 +++++-- .../internal/agenthost/admit_linux_test.go | 15 +++- .../internal/agenthost/environment_linux.go | 11 +-- .../agenthost/environment_linux_test.go | 7 +- apps/daemon/internal/sessionview/doc.go | 2 + .../internal/sessionview/launcher_linux.go | 28 +++++-- apps/daemon/internal/sessionview/spec.go | 7 +- .../internal/sessionview/view_linux_test.go | 80 +++++++++++++++++++ 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 +- 13 files changed, 165 insertions(+), 35 deletions(-) diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 43551ae44..1f810f106 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -540,7 +540,8 @@ type PrepareRequest struct { StateKey string // Assignment is the assignment the Runtime admitted the preparation under. Assignment proto.AssignmentRef - // WorkspaceRoot is the Environment's workspace, where the Harness runs. + // WorkspaceRoot is the Environment's bound workspace, where the Harness runs. + // Admission keeps it disjoint from view-owned paths, including native state. // It is empty with environment none. WorkspaceRoot string // CapabilityRoot, Skills and MCP are the Environment's installed diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index 7aff453ec..32f6a25e7 100644 --- a/apps/daemon/internal/agenthost/admit.go +++ b/apps/daemon/internal/agenthost/admit.go @@ -138,7 +138,7 @@ func admit(cfg Config, roots *x509.CertPool, req agent.PrepareRequest, env Envir } table.Aliases[path.Base(agent.ViewAlias(i))] = processbroker.Command{Executable: server.Command, Args: slices.Clone(server.Args), Dir: dir} } - if err := checkLayout(cfg, view); err != nil { + if err := checkLayout(cfg, view, req.WorkspaceRoot); err != nil { return nil, err } endpoints, err := gateway.Plan(gw) @@ -172,9 +172,12 @@ func handoff(req agent.PrepareRequest, provider modelprovider.Provider, endpoint return req } -// checkLayout rejects a view whose overlays, masks or shim paths meet the -// agent host's own overlays: the /etc files and the CA directory. -func checkLayout(cfg Config, view agent.View) error { +// checkLayout keeps view-owned paths disjoint from the sandbox workspace and +// keeps Harness overlays, masks and shim paths off the agent host's overlays. +func checkLayout(cfg Config, view agent.View, workspace string) error { + if workspace == "/" || agent.ViewReserved(workspace) { + return unsupported("workspace overlaps a reserved view tree") + } own := []string{cfg.CADir} for _, name := range etcFiles { own = append(own, "/etc/"+name) @@ -186,9 +189,18 @@ func checkLayout(cfg Config, view agent.View) error { for _, m := range view.Masks { claimed = append(claimed, m.Path) } + if workspace != "" { + for _, paths := range [][]string{own, claimed} { + for _, p := range paths { + if p != "" && overlaps(workspace, p) { + return unsupported("workspace overlaps a view-owned path") + } + } + } + } for _, p := range claimed { for _, q := range own { - if p == q || strings.HasPrefix(p, q+"/") || strings.HasPrefix(q, p+"/") { + if overlaps(p, q) { return fmt.Errorf("%w: admit: %w: view path %s meets the agent host's %s", ErrUnsupported, agent.ErrInvalidView, p, q) } } @@ -196,6 +208,15 @@ func checkLayout(cfg Config, view agent.View) error { return nil } +// 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) +} + // checkBinding checks the Session's binding and Environment. The binding is // valid when open, an Open it carries, is, as Link encoding checks it. func checkBinding(open sandboxlink.Open, env Environment) error { diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index 0bb0c0595..17f1a617d 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -57,6 +57,11 @@ func newViewFixture(t *testing.T) *viewFixture { masked := view masked.Masks = []agent.ViewMask{{Path: "/etc/passwd"}} register(reg, "masked", &masked) + covered := view + covered.Masks = []agent.ViewMask{{Path: "/masked", Dir: true}} + covered.Overlays = []agent.ViewOverlay{{Path: "/overlay", Source: t.TempDir()}} + covered.ShimPaths = []string{"/tools/command"} + register(reg, "covered", &covered) shimmed := view shimmed.Shims = []string{"git"} register(reg, "shimmed", &shimmed) @@ -78,6 +83,14 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { "incomplete binding": {"viewed", func(r *agent.PrepareRequest) { r.LocalEnvironment = nil }, []error{ErrInvalidSession}}, "shim name without PATH": {"shimmed", func(*agent.PrepareRequest) {}, []error{ErrInvalidSession}}, "relative workspace": {"viewed", func(r *agent.PrepareRequest) { r.WorkspaceRoot = "workspace" }, []error{ErrInvalidSession}}, + "private workspace": {"viewed", func(r *agent.PrepareRequest) { r.WorkspaceRoot = "/.oac/home" }, unsupported}, + "control workspace": {"viewed", func(r *agent.PrepareRequest) { r.WorkspaceRoot = "/proc" }, unsupported}, + "host CA workspace": {"viewed", func(r *agent.PrepareRequest) { r.WorkspaceRoot = f.cfg.CADir }, unsupported}, + "host overlay ancestor": {"viewed", func(r *agent.PrepareRequest) { r.WorkspaceRoot = "/etc" }, unsupported}, + "masked workspace": {"covered", func(r *agent.PrepareRequest) { r.WorkspaceRoot = "/masked" }, unsupported}, + "masked workspace child": {"covered", func(r *agent.PrepareRequest) { r.WorkspaceRoot = "/masked/project" }, unsupported}, + "overlay workspace": {"covered", func(r *agent.PrepareRequest) { r.WorkspaceRoot = "/overlay/project" }, unsupported}, + "shim workspace ancestor": {"covered", func(r *agent.PrepareRequest) { r.WorkspaceRoot = "/tools" }, unsupported}, "credentialed stdio MCP": {"viewed", func(r *agent.PrepareRequest) { r.MCP = []agent.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools", EnvVars: []string{"TOKEN"}}}} }, []error{ErrUnsupported, agent.ErrViewHandoff}}, @@ -161,7 +174,7 @@ func TestRegistryRunsKindsWithViews(t *testing.T) { } } slices.Sort(kinds) - if !slices.Equal(kinds, []string{"masked", "shimmed", "viewed"}) { + if !slices.Equal(kinds, []string{"covered", "masked", "shimmed", "viewed"}) { t.Errorf("kinds %v, want those that declare a view", kinds) } } diff --git a/apps/daemon/internal/agenthost/environment_linux.go b/apps/daemon/internal/agenthost/environment_linux.go index 8dc0f9e94..1651b3138 100644 --- a/apps/daemon/internal/agenthost/environment_linux.go +++ b/apps/daemon/internal/agenthost/environment_linux.go @@ -90,7 +90,7 @@ func (h *Host) Environments(ref proto.AssignmentRef, bind proto.AssignmentBindPa if err != nil || err2 != nil || bind.Validate() != nil { return nil } - if bind.EnvironmentID != "" && (bind.Resource == nil || !isViewPath(bind.WorkspaceDirectory)) { + if bind.EnvironmentID != "" && (bind.Resource == nil || !isViewPath(bind.WorkspaceDirectory) || checkLayout(h.cfg, agent.View{}, bind.WorkspaceDirectory) != nil) { return nil } b := Binding{SessionID: session, AssignmentID: assignment, AssignmentEpoch: ref.Epoch, AttachGrant: slices.Clone(bind.AttachGrant)} @@ -887,15 +887,6 @@ func (w *world) finalize(ctx context.Context, root sandboxfs.NodeRef, input agen 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 { diff --git a/apps/daemon/internal/agenthost/environment_linux_test.go b/apps/daemon/internal/agenthost/environment_linux_test.go index 4b050a38e..43c2d40b0 100644 --- a/apps/daemon/internal/agenthost/environment_linux_test.go +++ b/apps/daemon/internal/agenthost/environment_linux_test.go @@ -531,7 +531,7 @@ func (s *probed) Write(b []byte) (int, error) { func TestEnvironmentOwnerKeepsWorkspaceAcrossRouters(t *testing.T) { var dials atomic.Int32 - h := &Host{owners: owners{d: deps{dial: countingDial(&dials)}}} + h := &Host{cfg: Config{CADir: "/trust"}, owners: owners{d: deps{dial: countingDial(&dials)}}} b := newBinding(newResource()) payload := bindPayload(b) payload.WorkspaceDirectory = "/projects/one" @@ -551,7 +551,7 @@ func TestEnvironmentOwnerKeepsWorkspaceAcrossRouters(t *testing.T) { t.Fatalf("owner lost at epoch %d", epoch) } } - for _, workspace := range []string{"", "relative", "/projects/../two", "C:/project"} { + for _, workspace := range []string{"", "relative", "/projects/../two", "C:/project", "/", "/.oac", "/.oac/home", "/proc/1", "/dev/shm", "/etc", "/etc/passwd", "/trust", "/trust/roots"} { fresh := newBinding(newResource()) invalid := bindPayload(fresh) invalid.WorkspaceDirectory = workspace @@ -559,6 +559,9 @@ func TestEnvironmentOwnerKeepsWorkspaceAcrossRouters(t *testing.T) { t.Fatalf("unsupported workspace accepted: %q", workspace) } } + if len(h.owners.m) != 1 { + t.Fatal("a refused bind created an owner") + } if dials.Load() != 0 { t.Fatal("bind performed I/O") } diff --git a/apps/daemon/internal/sessionview/doc.go b/apps/daemon/internal/sessionview/doc.go index 17fedc6e5..b075743fd 100644 --- a/apps/daemon/internal/sessionview/doc.go +++ b/apps/daemon/internal/sessionview/doc.go @@ -2,6 +2,8 @@ // // A view is a private mount, PID and network namespace whose root is the Session's world: a FUSE file system that the daemon serves over a /dev/fuse connection. The launcher adds the local pieces on top of the world: private directories under /.oac, trusted overlays, the command shim, a fresh /proc and a minimal /dev. The world presents a mountpoint for each piece and reports where, following the sandbox's symlinks, and the launcher mounts at those paths without following any symlink itself. A [Spec] without a world gets an empty root instead: a read-only, noexec tmpfs that holds only the mountpoints, each at its own path. The process starts with no capabilities, no_new_privs, a seccomp filter and only stdin, stdout and stderr open. Its network namespace has only loopback up. // +// With a world, the initial process starts only in a directory on that world: the launcher resolves [Process].Dir without symlinks or crossing a mount, then enters the pinned directory before starting the process. Private directories and overlays cannot become its workspace through aliases. An empty-root view and later [View.Spawn] commands retain ordinary working-directory resolution. +// // The exec guard is narrow. Mount flags alone decide which files can be executed: the world and every writable mount are nosuid and noexec, so executing a file from the file system works only from read-only mounts declared executable, such as the Harness directory, the shim and exec-flagged overlays. The seccomp filter denies creating a user namespace and every setns, so the process cannot create or enter another user namespace. Executing from a memfd and code that an allowed interpreter runs are outside this guard. // // The daemon calls [Init] first thing in main. [Start] re-executes the daemon binary as the launcher, which becomes PID 1 of the view: it builds the view, starts the process, delivers signals to every process in the view, reaps orphans and exits with the process status. Once the process has exited, Signal reports [ErrExited] and delivers nothing. When the process exits while others remain, the launcher sends them TERM unless one already went to the view, and waits for them up to [Process].Grace from the first TERM. Its exit kills what remains and tears the view down. diff --git a/apps/daemon/internal/sessionview/launcher_linux.go b/apps/daemon/internal/sessionview/launcher_linux.go index 99afa7db2..1d64d247e 100644 --- a/apps/daemon/internal/sessionview/launcher_linux.go +++ b/apps/daemon/internal/sessionview/launcher_linux.go @@ -107,7 +107,7 @@ func (l *launcher) run() error { return err } } - if err := chdir(spec.Command.Dir); err != nil { + if err := chdir(spec.Command.Dir, spec.EmptyRoot == nil); err != nil { return err } pid, err := startProcess(spec, spec.Command, []uintptr{stdinFD, stdoutFD, stderrFD}) @@ -176,7 +176,7 @@ func (l *launcher) spawn(spec *launchSpec, id uint64, files []*os.File) { if err != nil { err = &Error{Kind: ErrLauncher, Op: "read spawn", Err: err} } else { - err = chdir(c.Dir) + err = chdir(c.Dir, false) } l.mu.Lock() if err == nil && !l.running { @@ -463,8 +463,8 @@ func takeIdentity(spec *launchSpec) error { return nil } -// chdir makes dir, taken from the view's root when empty or relative, this thread's working directory, which the processes it starts inherit. Signals stay blocked meanwhile: a signal to the launcher could pick this thread, and a wait on the world that a signal interrupts goes on, holding the signal back from the threads that handle it. -func chdir(dir string) error { +// chdir makes dir, taken from the view's root when empty or relative, this thread's working directory, which the processes it starts inherit. world requires the directory to stay on the world's mount without following symlinks. Signals stay blocked meanwhile: a signal to the launcher could pick this thread, and a wait on the world that a signal interrupts goes on, holding the signal back from the threads that handle it. +func chdir(dir string, world bool) error { if !strings.HasPrefix(dir, "/") { dir = "/" + dir } @@ -473,7 +473,25 @@ func chdir(dir string) error { all.Val[i] = ^all.Val[i] } _ = unix.PthreadSigmask(unix.SIG_BLOCK, &all, &mask) - err := unix.Chdir(dir) + var err error + if world { + var root, target int + root, err = unix.Open("/", unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err == nil { + rel := strings.TrimPrefix(dir, "/") + if rel == "" { + rel = "." + } + target, err = resolve(root, rel) + unix.Close(root) + if err == nil { + err = unix.Fchdir(target) + unix.Close(target) + } + } + } else { + err = unix.Chdir(dir) + } _ = unix.PthreadSigmask(unix.SIG_SETMASK, &mask, nil) if err != nil { return &Error{Kind: ErrExec, Op: "chdir", Err: err} diff --git a/apps/daemon/internal/sessionview/spec.go b/apps/daemon/internal/sessionview/spec.go index 1a5e5b18e..7ec12bee2 100644 --- a/apps/daemon/internal/sessionview/spec.go +++ b/apps/daemon/internal/sessionview/spec.go @@ -100,9 +100,10 @@ type Process struct { Path string Args []string Env []string - Dir string - UID uint32 - GID uint32 + // Dir is the initial working directory. With a World it must resolve within the world without symlinks or crossing a mount; the launcher pins that directory before starting the process. + Dir string + UID uint32 + GID uint32 // A nil Stdin, Stdout or Stderr is a pipe whose other end the View exposes. Stdin, Stdout, Stderr *os.File // Grace is how long processes left in the view when the process exits have to exit, counted from the first TERM the view sent them, before the view ends. Zero ends the view at once. diff --git a/apps/daemon/internal/sessionview/view_linux_test.go b/apps/daemon/internal/sessionview/view_linux_test.go index c7ac45002..3e6066be2 100644 --- a/apps/daemon/internal/sessionview/view_linux_test.go +++ b/apps/daemon/internal/sessionview/view_linux_test.go @@ -290,6 +290,18 @@ func TestSpawnRunsInTheView(t *testing.T) { if err := report.Signal(syscall.SIGKILL); !errors.Is(err, ErrExited) { t.Errorf("Signal after the spawned process exited = %v, want ErrExited", err) } + private, err := spawnHelper(context.Background(), v, "cwd", "/.oac/home") + if err != nil { + t.Fatalf("Spawn in private home: %v", err) + } + privateOut, err := io.ReadAll(private.Stdout) + closeStdio(private) + if err != nil || string(privateOut) != "/.oac/home" { + t.Fatalf("private Spawn cwd = %q, %v", privateOut, err) + } + if code, err := private.Wait(); err != nil || code != 0 { + t.Fatalf("private Spawn Wait = %d, %v", code, err) + } // The directory is entered as the process's user. if err := os.Mkdir(filepath.Join(f.harness, "root-only"), 0o700); err != nil { t.Fatal(err) @@ -773,6 +785,63 @@ func TestSeccompProgram(t *testing.T) { } } +func TestWorldInitialDirectoryStaysInWorld(t *testing.T) { + requireView(t) + f := newFixture(t) + mkdir(t, filepath.Join(f.world, "data", "project")) + for name, target := range map[string]string{"alias": "/.oac/home", "parent": "data"} { + if err := os.Symlink(target, filepath.Join(f.world, name)); err != nil { + t.Fatal(err) + } + } + for _, test := range []struct { + name, dir string + err error + }{ + {"private alias", "/alias", unix.ELOOP}, + {"parent symlink", "/parent/project", unix.ELOOP}, + {"private mount", "/.oac/home", unix.EXDEV}, + {"overlay mount", "/etc/oac-overlay", unix.EXDEV}, + {"proc mount", "/proc", unix.EXDEV}, + {"custom workspace", "/data/project", nil}, + {"world root", "/", nil}, + } { + t.Run(test.name, func(t *testing.T) { + marker := filepath.Join(f.home, "started") + w := &loopbackWorld{dir: f.world} + spec := f.spec(w, "cwd") + spec.Process.Dir = test.dir + v, err := Start(t.Context(), spec) + if test.err != nil { + if v != nil { + v.Close() + } + if !errors.Is(err, ErrExec) || !errors.Is(err, test.err) { + t.Fatalf("Start = %v, want ErrExec with %v", err, test.err) + } + if _, err := os.Stat(marker); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("initial process ran: marker stat = %v", err) + } + return + } + if err != nil { + t.Fatalf("Start: %v", err) + } + defer v.Close() + out, err := io.ReadAll(v.Stdout()) + if err != nil || string(out) != test.dir { + t.Fatalf("cwd = %q, %v; want %q", out, err, test.dir) + } + if exit, err := v.Wait(); err != nil || exit != (Exit{}) { + t.Fatalf("Wait = %+v, %v", exit, err) + } + if err := os.Remove(marker); err != nil { + t.Fatal(err) + } + }) + } +} + func TestEmptyRootView(t *testing.T) { requireView(t) f := newFixture(t) @@ -1275,6 +1344,17 @@ func runHelper(mode string) int { return 1 } return 0 + case "cwd": + wd, err := os.Getwd() + if err == nil { + err = os.WriteFile("/.oac/home/started", nil, 0o600) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + fmt.Print(wd) + return 0 case "noop": return 0 case "empty": diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index c2e982225..41bb30fec 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -83,7 +83,7 @@ func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayl The reason is a fixed safe string, never submitted content, a credential or raw native diagnostics. Unsupported guarantees no native side effect and is not a successful empty operation. Installation unavailability, unknown call IDs, native failures and uncertain outcomes keep their own errors and ownership. A nil `Turn` still means that no input was submitted and the output stays with the caller; never use it as an Unsupported marker. -The wire request carries no working directory. The Environment owner checks `local_environment.workspace_directory` against the Environment's workspace and gives the Harness that directory in `PrepareRequest.WorkspaceRoot`; run the native Harness there. +The execution request carries no working directory. The Environment owner freezes the workspace from `assignment_bind.workspace_directory` before preparation and gives the Harness that directory in `PrepareRequest.WorkspaceRoot`; run the native Harness there. Workspace reads, writes, output export and read-only preparation belong to the Session's [Environment owner](../../docs/runtime-protocol.md#session-assignments), not the adapter. An adapter implements none of them. Its declaration's `LocalEnvironment` and `EnvironmentNone` state what its Executors run, and `agent.Registry.Register` composes them once with what the Runtime's owner serves (`agent.EnvironmentSupport`), keeping each only where the owner serves it. The composed `LocalEnvironment` also admits the owner's workspace reads, read-only preparation and output export. One declaration holds for every Executor of the install. @@ -251,7 +251,7 @@ An agent host runs the Harness outside the sandbox, in a per-Session view. The v - 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)); - `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. +`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. A workspace must remain entirely in the sandbox world: binding rejects overlap with the common reserved trees or agent-host overlays before any Environment effect, and Harness admission rejects overlap with its declared overlays, masks or shim paths before any native effect. Neither operation substitutes a private home or another directory. At initial launch with a sandbox world, the launcher opens the working directory beneath that world without following symlinks or crossing mounts, then enters the opened directory before forking; changing the path cannot redirect startup into a view-owned mount. An empty-root launch and `Spawn` retain their own directory rules, including native-history helpers in the private home. This startup check does not restrict where the native process may later change directory. ### Capabilities diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3fdd1c863..ec296b5c7 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: 45f460051d8c8f87045d0146f44f86fa9d3379974000870856eac7705f33347c +source_hash: 65706a3126858abbc195940c6be89fa128e363abe708c028c1b36a8c68bfa704 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、支持声明和验收。 @@ -85,7 +85,7 @@ func (s *Session) SubmitFunctionResult(context.Context, proto.FunctionResultPayl 原因必须是固定的安全字符串,绝不能是已提交内容、凭据或原始原生诊断信息。Unsupported 保证不会产生原生副作用,也不表示操作成功且为空。安装不可用、未知调用 ID、原生失败和不确定结果应保留各自的错误和所有权。nil `Turn` 仍表示没有提交任何输入,并且输出归调用方所有;绝不能将其用作 Unsupported 标记。 -线协议请求不携带工作目录。Environment owner 将 `local_environment.workspace_directory` 与 Environment 的工作区进行核对,并通过 `PrepareRequest.WorkspaceRoot` 向 Harness 提供该目录;必须在该目录中运行原生 Harness。 +执行请求不携带工作目录。Environment owner 在准备之前冻结 `assignment_bind.workspace_directory` 中的工作区,并通过 `PrepareRequest.WorkspaceRoot` 向 Harness 提供该目录;必须在该目录中运行原生 Harness。 工作区读取、写入、输出导出和只读 preparation 属于 Session 的 [Environment owner](../../../docs/zh/runtime-protocol.md#session-assignments),不属于 adapter。adapter 不实现其中任何操作。其声明中的 `LocalEnvironment` 和 `EnvironmentNone` 表示其 Executor 能运行的内容,`agent.Registry.Register` 将二者与 Runtime 的 owner 所提供的内容(`agent.EnvironmentSupport`)组合一次,仅在 owner 提供时保留。组合后的 `LocalEnvironment` 同时准入 owner 的工作区读取、只读 preparation 和输出导出。一份声明适用于该安装的每个 Executor。 @@ -253,7 +253,7 @@ agent host 在沙箱之外、在每个 Session 一个的视图中运行 Harness - shim 名称和 `ForwardEnv` 名称各自唯一,没有 shim 名为 `oac-process-shim`(该名称属于进程 relay)或以 `oac-mcp-` 开头(该前缀属于 [stdio 别名](#stdio-mcp)),变量名不含 `=`,且 `ForwardEnv` 不指定视图或 broker 设置的变量([环境](#environment)); - `Proxy` 是两个取值之一,且 `Executor` 非 nil。 -`harness.go` 只定义一次视图布局,`sessionview` 据此构建视图。agent host 在构建视图时,用声明检查它自己的 overlay,例如 `/etc/passwd`。 +`harness.go` 只定义一次视图布局,`sessionview` 据此构建视图。agent host 在构建视图时,用声明检查它自己的 overlay,例如 `/etc/passwd`。工作区必须完整地位于沙箱世界中:绑定会在任何 Environment 副作用之前拒绝与公共保留树或 agent-host overlay 的重叠;Harness 准入会在任何原生副作用之前拒绝与其声明的 overlay、mask 或 shim 路径的重叠。这两种操作都不会改用私有 home 或其他目录。带沙箱世界的初次启动中,launcher 会在该世界内打开工作目录,不跟随符号链接也不跨越挂载点,然后在 fork 前进入已打开的目录;路径被修改也不能将启动重定向到视图所属挂载点。空根启动和 `Spawn` 保留各自的目录规则,包括在私有 home 中运行原生历史 helper。此启动检查不会限制原生进程随后切换目录的位置。 ### 能力 {#capabilities} diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 5d11f40b8..cc02dfc99 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -110,7 +110,7 @@ An assignment binds one Session to the Runtime that runs it. `Envelope.assignmen Every Session frame carries the assignment: `execution_prepare`, `execution_start` and `execution_release`; `prompt_cancel`, `prompt_steer` and `function_result`; every frame of `runtime_prepare`, `workspace_read`, `workspace_write` and `workspace_export`; and `environment_quiesce` and `environment_resume`. A reply echoes its request's assignment, and a Run's frames carry the assignment that started it; Core rejects a reply or Run frame that names another. Heartbeats carry none. -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 its canonical absolute `workspace_directory`, read from the frozen Environment configuration, and waits for `assignment_status` `bound`. When the Runtime is an agent host and the Environment has a live [Link](./sandbox-link-protocol.md) resource, the bind also carries `resource`, that resource as the [bootstrap input](./sandbox-bootstrap.md#launch-input) names it, and `attach_grant`, the base64 grant with which the agent host opens services on that resource generation under this assignment and epoch. When the Environment has no live resource, Core sends the agent host no bind, and the operation that needed the bind fails. An Environment binding requires `workspace_directory`; environment `none` forbids it. The owner freezes this path before any preparation or file operation and keeps it across connection replacement. The grant is secret. Core sends neither field to any other Runtime. A bind with only one of them, or with a resource of another Environment, fails with `invalid_request`. A repeated bind of the same assignment and epoch with the same Environment, workspace, resource and grant is `bound` again; one with anything else fails with `assignment_conflict`. A bind of the bound assignment at a higher epoch supersedes the earlier epoch, with any resource or grant: the Runtime fences and cleans up the earlier epoch's work as a release does, keeping the home, then binds the new epoch and replies `bound`. A Session's Environment and workspace never change, so such a bind that names another Environment or workspace fails with `assignment_conflict` before anything is fenced. A bind of another assignment, or of a released assignment at a higher epoch, fails with `assignment_conflict`. Unfinished cleanup replies `failed` with `cleanup_unconfirmed`, and a retry at the same epoch repeats it; a release or a later bind in the meantime fails it with `assignment_stale`. The Runtime admits a Session frame only under the assignment it bound: an older epoch, or a released one, fails with `assignment_stale`; another assignment, Session or Environment fails with `assignment_conflict`. A started Run's frames, including its cancellation receipt, stay admissible under the assignment that started it until the release. A repeated function result or decision whose receipt the Runtime already recorded is answered only under the assignment that applied it; another fails with `assignment_conflict`. +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 its canonical absolute `workspace_directory`, read from the frozen Environment configuration, and waits for `assignment_status` `bound`. When the Runtime is an agent host and the Environment has a live [Link](./sandbox-link-protocol.md) resource, the bind also carries `resource`, that resource as the [bootstrap input](./sandbox-bootstrap.md#launch-input) names it, and `attach_grant`, the base64 grant with which the agent host opens services on that resource generation under this assignment and epoch. When the Environment has no live resource, Core sends the agent host no bind, and the operation that needed the bind fails. An Environment binding requires `workspace_directory`; environment `none` forbids it. The owner freezes this path before any preparation or file operation and keeps it across connection replacement. An agent host rejects a workspace that overlaps its common [view-owned paths](../contracts/agents-api/harness-onboarding.md#run-in-an-agent-host-view) with `assignment_conflict` before creating the owner or attaching to the sandbox. The grant is secret. Core sends neither field to any other Runtime. A bind with only one of them, or with a resource of another Environment, fails with `invalid_request`. A repeated bind of the same assignment and epoch with the same Environment, workspace, resource and grant is `bound` again; one with anything else fails with `assignment_conflict`. A bind of the bound assignment at a higher epoch supersedes the earlier epoch, with any resource or grant: the Runtime fences and cleans up the earlier epoch's work as a release does, keeping the home, then binds the new epoch and replies `bound`. A Session's Environment and workspace never change, so such a bind that names another Environment or workspace fails with `assignment_conflict` before anything is fenced. A bind of another assignment, or of a released assignment at a higher epoch, fails with `assignment_conflict`. Unfinished cleanup replies `failed` with `cleanup_unconfirmed`, and a retry at the same epoch repeats it; a release or a later bind in the meantime fails it with `assignment_stale`. The Runtime admits a Session frame only under the assignment it bound: an older epoch, or a released one, fails with `assignment_stale`; another assignment, Session or Environment fails with `assignment_conflict`. A started Run's frames, including its cancellation receipt, stay admissible under the assignment that started it until the release. A repeated function result or decision whose receipt the Runtime already recorded is answered only under the assignment that applied it; another fails with `assignment_conflict`. A Session's first bind resolves its Environment owner, which holds the Environment's resources and performs every effect on them; it does not change afterwards. The owner checks each `execution_prepare` configuration against the Environment, including the read-only profile, and fills the installed capabilities before an Executor starts. It applies `runtime_prepare`, lists directories for `workspace_read`, writes files for `workspace_write` and exports outputs for `workspace_export`. The Runtime's dispatcher keeps admission, transfer framing and fencing, and never substitutes another implementation. An agent host's owner works in the Session's sandbox through File and Process on a [Link](./sandbox-link-protocol.md) attachment of its own, opened under the bind's grant on first use. It lasts from the Session's first bind until its home is removed and outlives the Session's Executors and connections. Quiescing its Environment, releasing the assignment or superseding its epoch closes its attachment; a bind that supersedes the epoch takes the owner over once that attachment is closed. It runs each setup step as the Process operation whose ID is the `runtime_prepare` envelope ID. A `workspace_write` or `runtime_prepare` that cannot reach the sandbox before any effect ends `rejected` with `resource_unavailable`. It fails a Plugin whose MCP server declares literal `http_headers`, or is a stdio server with `env_vars`, before staging any of it. A file mutation or setup step whose outcome it cannot observe quarantines the owner until the home is removed: it sends no further mutation, and every later `workspace_write` and `runtime_prepare` ends `unknown`. A Session without an owner supports none of these operations, and the Runtime rejects each with its typed code: `unsupported_read_preparation` for a read-only preparation, `invalid_configuration` for an Executor configuration with a `local_environment`, `runtime_preparation_unsupported` for `runtime_prepare`, `write_unsupported` for `workspace_write`, and `read_unsupported` for `workspace_read` and `workspace_export`. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index af91f0cd1..f463d4628 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: 1c73efb6ca5e08eca71045cdc33717203afeec91fddb279dacdc58433afc9df3 +source_hash: f21adb2ee281acc794d02d5cfe4f8c398e0f0fc00745fce9048635cc275eb562 --- 此协议在 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)。 @@ -112,7 +112,7 @@ Usage frame 和最终 usage snapshot 都携带当前执行的累计测量,替 每个 Session frame 都携带分配:`execution_prepare`、`execution_start` 和 `execution_release`;`prompt_cancel`、`prompt_steer` 和 `function_result`;`runtime_prepare`、`workspace_read`、`workspace_write` 和 `workspace_export` 的每个 frame;以及 `environment_quiesce` 和 `environment_resume`。回复回显请求的分配,Run 的 frame 携带启动它的分配;Core 拒绝指明其他分配的回复或 Run frame。heartbeat 不携带分配。 -在一条连接上执行 Session 的第一个操作之前,包括没有 Turn 的 Environment 初始化和文件操作,Core 发送带 Session 的 Environment ID 及从冻结的 Environment 配置读取的规范绝对路径 `workspace_directory` 的 `assignment_bind`,并等待 `assignment_status` `bound`。当 Runtime 是 agent host 且 Environment 有存活的 [Link](./sandbox-link-protocol.md) resource 时,绑定还携带 `resource` 和 `attach_grant`:前者是该 resource,形式与[引导输入](./sandbox-bootstrap.md#launch-input)中的相同;后者是 base64 编码的 grant,agent host 凭它在此分配和 epoch 下打开该 resource generation 上的服务。若 Environment 没有存活的 resource,Core 不向 agent host 发送绑定,需要该绑定的操作失败。有 Environment 的绑定必须携带 `workspace_directory`;environment `none` 禁止携带该字段。owner 在任何准备或文件操作之前冻结此路径,并在连接替换后保留它。grant 是机密。Core 不向其他任何 Runtime 发送这两个字段。只带其中一个字段、或带其他 Environment 的 resource 的绑定以 `invalid_request` 失败。以相同的 epoch、Environment、workspace、resource 和 grant 重复绑定同一分配仍得到 `bound`;其他字段不同的同 epoch 绑定以 `assignment_conflict` 失败。以更高 epoch 绑定已绑定的分配会取代较早的 epoch,resource 和 grant 均可不同:Runtime 像释放那样 fence 并清理较早 epoch 的工作,但保留 home,然后绑定新 epoch 并回复 `bound`。Session 的 Environment 和 workspace 从不改变,因此这样的绑定若指定其他 Environment 或 workspace,会在任何 fence 之前以 `assignment_conflict` 失败。其他分配的绑定,或以更高 epoch 绑定已释放的分配,以 `assignment_conflict` 失败。清理未完成时回复 `failed` 和 `cleanup_unconfirmed`,以同一 epoch 重试会重复清理;期间到达的释放或更晚的绑定使其以 `assignment_stale` 失败。Runtime 只在其已绑定的分配下准入 Session frame:较旧的 epoch 或已释放的分配以 `assignment_stale` 失败;其他分配、Session 或 Environment 以 `assignment_conflict` 失败。已启动 Run 的 frame,包括其取消回执,在释放前仍可在启动它的分配下准入。Runtime 已记录回执的重复函数结果或决策只在应用它的分配下得到回答;其他分配以 `assignment_conflict` 失败。 +在一条连接上执行 Session 的第一个操作之前,包括没有 Turn 的 Environment 初始化和文件操作,Core 发送带 Session 的 Environment ID 及从冻结的 Environment 配置读取的规范绝对路径 `workspace_directory` 的 `assignment_bind`,并等待 `assignment_status` `bound`。当 Runtime 是 agent host 且 Environment 有存活的 [Link](./sandbox-link-protocol.md) resource 时,绑定还携带 `resource` 和 `attach_grant`:前者是该 resource,形式与[引导输入](./sandbox-bootstrap.md#launch-input)中的相同;后者是 base64 编码的 grant,agent host 凭它在此分配和 epoch 下打开该 resource generation 上的服务。若 Environment 没有存活的 resource,Core 不向 agent host 发送绑定,需要该绑定的操作失败。有 Environment 的绑定必须携带 `workspace_directory`;environment `none` 禁止携带该字段。owner 在任何准备或文件操作之前冻结此路径,并在连接替换后保留它。若工作区与 agent host 公共的[视图所属路径](../../contracts/agents-api/zh/harness-onboarding.md#run-in-an-agent-host-view)重叠,agent host 会在创建 owner 或附着到沙箱之前以 `assignment_conflict` 拒绝绑定。grant 是机密。Core 不向其他任何 Runtime 发送这两个字段。只带其中一个字段、或带其他 Environment 的 resource 的绑定以 `invalid_request` 失败。以相同的 epoch、Environment、workspace、resource 和 grant 重复绑定同一分配仍得到 `bound`;其他字段不同的同 epoch 绑定以 `assignment_conflict` 失败。以更高 epoch 绑定已绑定的分配会取代较早的 epoch,resource 和 grant 均可不同:Runtime 像释放那样 fence 并清理较早 epoch 的工作,但保留 home,然后绑定新 epoch 并回复 `bound`。Session 的 Environment 和 workspace 从不改变,因此这样的绑定若指定其他 Environment 或 workspace,会在任何 fence 之前以 `assignment_conflict` 失败。其他分配的绑定,或以更高 epoch 绑定已释放的分配,以 `assignment_conflict` 失败。清理未完成时回复 `failed` 和 `cleanup_unconfirmed`,以同一 epoch 重试会重复清理;期间到达的释放或更晚的绑定使其以 `assignment_stale` 失败。Runtime 只在其已绑定的分配下准入 Session frame:较旧的 epoch 或已释放的分配以 `assignment_stale` 失败;其他分配、Session 或 Environment 以 `assignment_conflict` 失败。已启动 Run 的 frame,包括其取消回执,在释放前仍可在启动它的分配下准入。Runtime 已记录回执的重复函数结果或决策只在应用它的分配下得到回答;其他分配以 `assignment_conflict` 失败。 Session 的第一次绑定确定其 Environment owner,此后不再改变;owner 持有 Environment 的资源,并执行对这些资源的每个作用。owner 根据 Environment 检查每个 `execution_prepare` 配置(包括只读 profile),并在 Executor 启动前填入已安装的能力。它应用 `runtime_prepare`,为 `workspace_read` 列举目录,为 `workspace_write` 写入文件,为 `workspace_export` 导出输出。Runtime 的 dispatcher 保留准入、传输分帧和 fencing,从不替换为其他实现。agent host 的 owner 通过自己的一个 [Link](./sandbox-link-protocol.md) attachment,用 File 和 Process 在 Session 的沙箱中工作;该 attachment 在首次使用时凭绑定的 grant 打开。owner 从 Session 的第一次绑定存续到其 home 被删除,比 Session 的 Executor 和连接存续得更久。其 Environment 被 quiesce、分配被释放或其 epoch 被取代时关闭其 attachment;取代该 epoch 的绑定在该 attachment 关闭后接管 owner。它把每个 setup 步骤作为 Process 操作运行,操作 ID 即 `runtime_prepare` 的 envelope ID。在产生任何作用前无法连到沙箱的 `workspace_write` 或 `runtime_prepare` 以 `rejected` 和 `resource_unavailable` 结束。若 Plugin 的 MCP server 声明了字面量 `http_headers`,或是带 `env_vars` 的 stdio server,owner 会在暂存其任何内容之前使其失败。无法观察到结果的文件变更或 setup 步骤会隔离 owner,直到 home 被删除:它不再发送任何变更,之后每个 `workspace_write` 和 `runtime_prepare` 都以 `unknown` 结束。没有 owner 的 Session 不支持上述任何操作,Runtime 以各自的类型化错误码拒绝:只读 preparation 为 `unsupported_read_preparation`;带 `local_environment` 的 Executor 配置为 `invalid_configuration`;`runtime_prepare` 为 `runtime_preparation_unsupported`;`workspace_write` 为 `write_unsupported`;`workspace_read` 和 `workspace_export` 为 `read_unsupported`。 From 91c1a88da1f2801b4e7d3e06761fe059f13d421f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 8 Oct 2026 13:16:59 +0000 Subject: [PATCH 3/3] Clarify the sandbox environment qualification inputs --- apps/daemon/internal/agenthostqualify/qualify_linux_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index e6ab2dceb..d9f6e474b 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -153,6 +153,7 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, code, _ := rand.Int(rand.Reader, big.NewInt(90)) exit := code.Int64() + 3 prompt := "Use your tools for each step.\n" + + "QUALIFY_VALUE and QUALIFY_EXIT are already set in the sandbox environment. Do not assign or modify either variable.\n" + "1. Create the file " + name + " in the current directory with exactly this content: " + content + "\n" + "2. Run this shell command exactly once: echo \"$QUALIFY_VALUE\"; exit \"$QUALIFY_EXIT\"\n" + " It prints a value and exits with a non-zero status; that is expected.\n" +