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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/codex/recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion apps/daemon/internal/agent/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 28 additions & 7 deletions apps/daemon/internal/agenthost/admit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -186,16 +189,34 @@ 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)
}
}
}
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 {
Expand Down
27 changes: 20 additions & 7 deletions apps/daemon/internal/agenthost/admit_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -77,7 +82,15 @@ 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}},
"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}},
Expand All @@ -88,7 +101,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})
Expand All @@ -113,7 +126,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())
}
Expand All @@ -131,7 +144,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"}},
Expand Down Expand Up @@ -161,15 +174,15 @@ 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)
}
}

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
Expand Down Expand Up @@ -215,7 +228,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)
Expand Down
18 changes: 12 additions & 6 deletions apps/daemon/internal/agenthost/agenthost_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}},
}
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
54 changes: 23 additions & 31 deletions apps/daemon/internal/agenthost/environment_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{}
Expand All @@ -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) || checkLayout(h.cfg, agent.View{}, bind.WorkspaceDirectory) != nil) {
return nil
}
b := Binding{SessionID: session, AssignmentID: assignment, AssignmentEpoch: ref.Epoch, AttachGrant: slices.Clone(bind.AttachGrant)}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -338,15 +339,15 @@ 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]})
}
}
for i := range manifest.Skills {
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
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -886,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 {
Expand Down
Loading
Loading