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
41 changes: 18 additions & 23 deletions apps/daemon/internal/agenthost/admit.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type plan struct {
executables processbroker.Executables
}

// checkConfig validates cfg and loads the roots in its CA directory.
// checkConfig validates cfg and loads the roots in its CA bundle.
func checkConfig(cfg Config) (*x509.CertPool, error) {
switch {
case !isHostPath(cfg.StateDir):
Expand All @@ -56,34 +56,29 @@ func checkConfig(cfg Config) (*x509.CertPool, error) {
return nil, invalidConfig("no Harness declarations")
case !isHostPath(cfg.Shim):
return nil, invalidConfig("shim %q is not absolute and clean", cfg.Shim)
case !isHostPath(cfg.CADir) || cfg.CADir == "/" || agent.ViewReserved(cfg.CADir):
return nil, invalidConfig("CA directory %q", cfg.CADir)
case !isHostPath(cfg.CAFile) || cfg.CAFile == "/" || agent.ViewReserved(cfg.CAFile):
return nil, invalidConfig("CA file %q", cfg.CAFile)
}
return loadRoots(cfg.CADir)
return loadRoots(cfg.CAFile)
}

// loadRoots reads every certificate in dir. Each entry is a regular file of
// PEM certificates, so the view presents exactly what the gateway trusts.
func loadRoots(dir string) (*x509.CertPool, error) {
entries, err := os.ReadDir(dir)
// loadRoots reads the bundle presented in the view, so the gateway uses the
// same trust source without falling back to another root store.
func loadRoots(name string) (*x509.CertPool, error) {
info, err := os.Lstat(name)
if err != nil {
return nil, fmt.Errorf("%w: CA directory: %w", ErrInvalidConfig, err)
return nil, fmt.Errorf("%w: CA file: %w", ErrInvalidConfig, err)
}
if len(entries) == 0 {
return nil, invalidConfig("CA directory %s is empty", dir)
if !info.Mode().IsRegular() {
return nil, invalidConfig("CA file %s is not a regular file", name)
}
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("%w: CA file: %w", ErrInvalidConfig, err)
}
roots := x509.NewCertPool()
for _, e := range entries {
if !e.Type().IsRegular() {
return nil, invalidConfig("CA entry %s is not a regular file", e.Name())
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, fmt.Errorf("%w: CA directory: %w", ErrInvalidConfig, err)
}
if !roots.AppendCertsFromPEM(data) {
return nil, invalidConfig("CA entry %s holds no PEM certificate", e.Name())
}
if !roots.AppendCertsFromPEM(data) {
return nil, invalidConfig("CA file %s holds no PEM certificate", name)
}
return roots, nil
}
Expand Down Expand Up @@ -178,7 +173,7 @@ 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}
own := []string{cfg.CAFile}
for _, name := range etcFiles {
own = append(own, "/etc/"+name)
}
Expand Down
100 changes: 99 additions & 1 deletion apps/daemon/internal/agenthost/admit_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ package agenthost

import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"net/http"
"net/http/httptest"
Expand All @@ -14,6 +19,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"

"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent"
"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processbroker"
Expand Down Expand Up @@ -85,7 +91,9 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) {
"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 CA workspace": {"viewed", func(r *agent.PrepareRequest) { r.WorkspaceRoot = f.cfg.CAFile }, unsupported},
"host CA workspace ancestor": {"viewed", func(r *agent.PrepareRequest) { r.WorkspaceRoot = filepath.Dir(f.cfg.CAFile) }, unsupported},
"host CA workspace child": {"viewed", func(r *agent.PrepareRequest) { r.WorkspaceRoot = f.cfg.CAFile + "/project" }, 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},
Expand Down Expand Up @@ -247,3 +255,93 @@ func TestReleaseRemovesTheHome(t *testing.T) {
t.Errorf("the Session directory remains: %v", err)
}
}

func TestCABundleRejectsInvalidFiles(t *testing.T) {
cfg := newConfig(t, agent.NewRegistry(), testCA())
link := filepath.Join(t.TempDir(), "roots.pem")
if err := os.Symlink(cfg.CAFile, link); err != nil {
t.Fatal(err)
}
for _, name := range []string{"", "relative", "/", "/.oac/roots.pem", cfg.CAFile + ".missing", filepath.Dir(cfg.CAFile), link} {
bad := cfg
bad.CAFile = name
if _, err := checkConfig(bad); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("CAFile %q: %v", name, err)
}
}
for _, data := range [][]byte{nil, []byte("not PEM"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("not DER")})} {
if err := os.WriteFile(cfg.CAFile, data, 0o644); err != nil {
t.Fatal(err)
}
if _, err := checkConfig(cfg); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("invalid bundle: %v", err)
}
}
}

func TestGatewayUsesOnlyTheCABundleRoots(t *testing.T) {
upstream := httptest.NewTLSServer(http.NotFoundHandler())
defer upstream.Close()
// A separate valid issuer must not fall back to the upstream's issuer.
pub, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
template := x509.Certificate{
SerialNumber: upstream.Certificate().SerialNumber,
NotBefore: upstream.Certificate().NotBefore, NotAfter: upstream.Certificate().NotAfter,
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, pub, key)
if err != nil {
t.Fatal(err)
}
other, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
ca *x509.Certificate
trusted bool
}{
{"trusted", upstream.Certificate(), true}, {"untrusted", other, false},
} {
t.Run(tc.name, func(t *testing.T) {
f := newViewFixture(t)
cfg := newConfig(t, f.cfg.Harnesses, tc.ca)
roots, err := checkConfig(cfg)
if err != nil {
t.Fatal(err)
}
p, err := admit(cfg, roots, prepared(request("viewed", upstream.URL, "fixture-key")), Environment{}, nil)
if err != nil {
t.Fatal(err)
}
transport := &http.Transport{TLSClientConfig: &tls.Config{RootCAs: p.gateway.RootCAs}}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
response, err := client.Get(upstream.URL)
if response != nil {
response.Body.Close()
}
if tc.trusted && err != nil {
t.Fatalf("trusted TLS: %v", err)
}
var unknown x509.UnknownAuthorityError
if !tc.trusted && !errors.As(err, &unknown) {
t.Fatalf("untrusted TLS: %v, want unknown authority", err)
}
})
}
}

func TestHarnessCannotCoverCABundle(t *testing.T) {
f := newViewFixture(t)
for _, target := range []string{f.cfg.CAFile, filepath.Dir(f.cfg.CAFile), f.cfg.CAFile + "/child"} {
view := agent.View{Overlays: []agent.ViewOverlay{{Path: target, Source: t.TempDir()}}}
if err := checkLayout(f.cfg, view, ""); !errors.Is(err, agent.ErrInvalidView) {
t.Errorf("overlay %s: %v", target, err)
}
}
}
8 changes: 4 additions & 4 deletions apps/daemon/internal/agenthost/agenthost.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ type Config struct {
Harnesses *agent.Registry
// Shim is the absolute host path of the static oac-process-shim binary.
Shim string
// CADir is an absolute host directory of regular PEM files: the roots the
// agent host trusts. The gateway trusts exactly these for upstream TLS,
// and the view presents the directory read-only at the same path.
CADir string
// CAFile is an absolute host path to a regular PEM CA bundle. The gateway
// trusts these roots for upstream TLS, and the view presents the same file
// read-only at the same path.
CAFile string
// Log receives each view's presentation report and each failure of a
// Session, which no Turn reports. Nil discards it.
Log *slog.Logger
Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/internal/agenthost/agenthost_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}

// newConfig returns a Config whose CA directory holds ca. Its ViewCgroups is
// newConfig returns a Config whose CA bundle holds ca. Its ViewCgroups is
// a plain directory, which Open rejects.
func newConfig(t *testing.T, reg *agent.Registry, ca *x509.Certificate) Config {
t.Helper()
Expand All @@ -67,7 +67,7 @@ func newConfig(t *testing.T, reg *agent.Registry, ca *x509.Certificate) Config {
t.Fatal(err)
}
return Config{StateDir: t.TempDir(), UIDs: UIDRange{First: 70000, Count: 8}, ViewCgroups: t.TempDir(), RelayURL: "ws://127.0.0.1:9",
RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: reg, Shim: exe, CADir: dir}
RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: reg, Shim: exe, CAFile: filepath.Join(dir, "ca.pem")}
}

// register declares kind with view, or without one when view is nil, as
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agenthost/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
//
// Each view presents the closure directories read-only and executable, the
// Session home read-write and noexec, the agent host's /etc/passwd, group,
// hosts, resolv.conf and nsswitch.conf, the agent host's CA directory at its
// hosts, resolv.conf and nsswitch.conf, the agent host's CA bundle read-only at its
// host path, then the adapter's overlays and masks and the process shim with
// its relay. The view owns /proc, /sys and /dev. Everything else is the
// world, or nothing in an empty-root view.
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agenthost/environment_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ func (s *probed) Write(b []byte) (int, error) {

func TestEnvironmentOwnerKeepsWorkspaceAcrossRouters(t *testing.T) {
var dials atomic.Int32
h := &Host{cfg: Config{CADir: "/trust"}, owners: owners{d: deps{dial: countingDial(&dials)}}}
h := &Host{cfg: Config{CAFile: "/trust"}, owners: owners{d: deps{dial: countingDial(&dials)}}}
b := newBinding(newResource())
payload := bindPayload(b)
payload.WorkspaceDirectory = "/projects/one"
Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/internal/agenthost/launch_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ func (h *ownedView) end(waitErr error) {
}

// spec builds the view over world: the closure and home directories, the
// agent host's /etc files and CA directory, the adapter's overlays and masks,
// agent host's /etc files and CA bundle, the adapter's overlays and masks,
// and the shim under each name and path of the process broker's table.
func (s *session) spec(world sessionview.World, opts clirunner.StartOptions, stdio [3]*os.File) sessionview.Spec {
view, x := s.plan.view, s.plan.executables
Expand All @@ -424,7 +424,7 @@ func (s *session) spec(world sessionview.World, opts clirunner.StartOptions, std
for _, name := range etcFiles {
overlays = append(overlays, sessionview.Overlay{Path: "/etc/" + name, Source: s.dir.entry(etcEntry, name)})
}
overlays = append(overlays, sessionview.Overlay{Path: s.cfg.CADir, Source: s.cfg.CADir})
overlays = append(overlays, sessionview.Overlay{Path: s.cfg.CAFile, Source: s.cfg.CAFile})
for _, o := range view.Overlays {
overlays = append(overlays, sessionview.Overlay{Path: o.Path, Source: o.Source, Exec: o.Exec})
}
Expand Down
11 changes: 7 additions & 4 deletions apps/daemon/internal/agenthost/view_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) {
Proxy: agent.ViewProxyEnv,
Executor: func(_ context.Context, req agent.PrepareRequest, s agent.ViewSession) (agent.Executor, error) {
e := &testExecutor{session: s, dir: workDir,
env: []string{harnessEnv + "=1", modelEnv + "=" + req.Prepared.Provider.BaseURL, caEnv + "=" + cfg.CADir, proxyEnv + "=" + s.Proxy}}
env: []string{harnessEnv + "=1", modelEnv + "=" + req.Prepared.Provider.BaseURL, caEnv + "=" + cfg.CAFile, proxyEnv + "=" + s.Proxy}}
if req.LocalEnvironment != nil {
e.dir = req.WorkspaceRoot
}
Expand Down Expand Up @@ -759,7 +759,7 @@ func (t *testTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, e
}
}

var harnessChecks = []string{"world rename", "model through the gateway", "no direct route", "world is noexec", "masks", "home", "passwd", "CA directory"}
var harnessChecks = []string{"world rename", "model through the gateway", "no direct route", "world is noexec", "masks", "home", "passwd", "CA bundle"}

// workDir is where the Harness of an empty-root view runs.
const workDir = agent.ViewPrivateRoot + "/" + agent.ViewHomeName + "/" + agent.ViewWorkName
Expand Down Expand Up @@ -836,11 +836,14 @@ func runHarness(args []string) int {
}
return nil
},
"CA directory": func() error {
data, err := os.ReadFile(filepath.Join(os.Getenv(caEnv), "ca.pem"))
"CA bundle": func() error {
data, err := os.ReadFile(os.Getenv(caEnv))
if block, _ := pem.Decode(data); err != nil || block == nil {
return fmt.Errorf("no CA certificate: %v", err)
}
if err := os.WriteFile(os.Getenv(caEnv), data, 0o644); !errors.Is(err, syscall.EROFS) {
return fmt.Errorf("CA bundle write: %v, want read-only filesystem", err)
}
return nil
},
}
Expand Down
6 changes: 3 additions & 3 deletions apps/daemon/internal/agenthostqualify/qualify_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ const (
keyEnv = "OAC_QUALIFY_KEY_FILE"
runDir = "/run/qualify"
shim = "/opt/oac/bin/oac-process-shim"
// caDir holds the agent-host image's roots, one regular PEM file each.
caDir = "/usr/share/ca-certificates/mozilla"
// caFile holds the agent-host image's system CA bundle.
caFile = "/etc/ssl/certs/ca-certificates.crt"
// workspace is the sandbox directory every Session works in.
workspace = "/workspace/custom-project"
turnLimit = 10 * time.Minute
Expand Down Expand Up @@ -95,7 +95,7 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) {
sb := startSandbox(t)
reg := agent.NewRegistry()
cfg := agenthost.Config{StateDir: t.TempDir(), ViewCgroups: sessionviewtest.CgroupParent(t), UIDs: agenthost.UIDRange{First: 70000, Count: 8}, RelayURL: sb.url, TLS: sb.tls,
RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: reg, Shim: shim, CADir: caDir,
RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: reg, Shim: shim, CAFile: caFile,
Log: slog.New(slog.NewTextHandler(os.Stderr, nil))}
sb.auth.AddRuntime(cfg.Credential, cfg.RuntimeID)
sb.ready(t, cfg)
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agenthostqualify/rig_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestViewCgroupDelegation(t *testing.T) {
t.Skipf("set %s=1 and run the test with scripts/qualify-agent-host.sh", gateEnv)
}
cfg := agenthost.Config{StateDir: t.TempDir(), ViewCgroups: "/sys/fs/cgroup", UIDs: agenthost.UIDRange{First: 70000, Count: 8}, RelayURL: "ws://127.0.0.1:1",
RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: agent.NewRegistry(), Shim: shim, CADir: caDir}
RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: agent.NewRegistry(), Shim: shim, CAFile: caFile}
if h, err := agenthost.Open(cfg); err == nil {
h.Close()
t.Fatalf("Open accepted the undelegated %s", cfg.ViewCgroups)
Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/internal/cli/agent_host_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import (
const (
agentHostManifest = "/opt/oac/harnesses.json"
agentHostShim = "/opt/oac/bin/oac-process-shim"
agentHostCADir = "/usr/share/ca-certificates/mozilla"
agentHostCAFile = "/etc/ssl/certs/ca-certificates.crt"
// agentHostState keeps each Session's home across restarts.
agentHostState = "/var/lib/oac/agent-host"
// agentHostCgroup is where the agent host mounts the container's own
Expand Down Expand Up @@ -127,7 +127,7 @@ func serveAgentHost(parent context.Context, rc *runContext, args []string, decla
}
host, err := agenthost.Open(agenthost.Config{StateDir: agentHostState, UIDs: agentHostUIDs, ViewCgroups: views,
RelayURL: wsOrigin + "/api/v1/sandbox-link", RuntimeID: sandboxwire.ID(runtimeID), Credential: []byte(identity.Credential),
Harnesses: harnesses, Shim: agentHostShim, CADir: agentHostCADir, Log: obslog.Bg()})
Harnesses: harnesses, Shim: agentHostShim, CAFile: agentHostCAFile, Log: obslog.Bg()})
if err != nil {
return fmt.Errorf("agent-host: %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/internal/cli/agent_host_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ func TestAgentHostReportsItsDeclarations(t *testing.T) {
issuer := httptest.NewTLSServer(nil)
issuer.Close()
for path, content := range map[string][]byte{
agentHostManifest: []byte(`{"node": "/usr/local/bin/node", "harnesses": {}}`),
filepath.Join(agentHostCADir, "ca.crt"): pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuer.Certificate().Raw}),
agentHostManifest: []byte(`{"node": "/usr/local/bin/node", "harnesses": {}}`),
agentHostCAFile: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuer.Certificate().Raw}),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
Expand Down
2 changes: 2 additions & 0 deletions contracts/agents-api/harness-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ An agent host runs the Harness outside the sandbox, in a per-Session view. The v

The view owns the native process's kernel interfaces: a fresh `/proc`, read-only `/sys` and minimal `/dev`. A fresh read-only cgroup2 mount at `/sys/fs/cgroup` is rooted in the view's cgroup namespace, so `/proc/self/cgroup` and the visible hierarchy describe the same process group without exposing ancestor or sibling cgroups. The view mounts sysfs in its own network namespace; it never binds the host's `/sys` tree. Native file tools see these reserved kernel paths locally. Commands forwarded through the Process protocol and public File operations continue to use the sandbox's filesystem, including its `/sys`; workspace files remain in the sandbox world. These mounts also exist in an empty-root view.

The agent host uses the image's system CA bundle at `/etc/ssl/certs/ca-certificates.crt` for the credential gateway's upstream TLS verification and presents that same file read-only at the same view path. Native libraries that discover this bundle read it locally. The agent host does not override a Harness's bundled trust store or change the CA files used by tools running in the sandbox.

### Capabilities

A view runs every request that the kind's declaration admits, so the adapter declares only what its view runs, and dispatch checks each request against that declaration. The agent host serves a local Environment and environment none, and every view runs the Environment's installed Skills and [stdio MCP](#stdio-mcp). The Environment owner fills `PrepareRequest.Skills` and `CapabilityRoot` as sandbox paths, and the adapter hands them to its Harness; only the Harness reads them, through the view, and the adapter opens none of them on the agent host. The agent host rejects a stdio binding that needs a credential with `ErrViewHandoff`.
Expand Down
Loading
Loading