From 79486373edcd2fdec83973ef06a3f3f63743873f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 10 Oct 2026 04:38:51 +0000 Subject: [PATCH] Batch native filesystem metadata within each syscall --- apps/daemon/internal/sessionview/doc.go | 2 + apps/daemon/internal/sessionview/spec.go | 4 + .../daemon/internal/sessionview/view_linux.go | 10 +- apps/daemon/internal/worldfs/doc.go | 10 +- .../internal/worldfs/export_linux_test.go | 6 + apps/daemon/internal/worldfs/ops_linux.go | 13 + .../daemon/internal/worldfs/prefetch_linux.go | 183 +++++++++++++ .../internal/worldfs/prefetch_linux_test.go | 152 +++++++++++ apps/daemon/internal/worldfs/scope_linux.go | 244 ++++++++++++++++++ .../internal/worldfs/scope_linux_test.go | 152 +++++++++++ apps/daemon/internal/worldfs/world_linux.go | 58 ++++- .../internal/worldfs/world_linux_test.go | 46 +++- go.mod | 1 + go.sum | 6 + 14 files changed, 869 insertions(+), 18 deletions(-) create mode 100644 apps/daemon/internal/worldfs/export_linux_test.go create mode 100644 apps/daemon/internal/worldfs/prefetch_linux.go create mode 100644 apps/daemon/internal/worldfs/prefetch_linux_test.go create mode 100644 apps/daemon/internal/worldfs/scope_linux.go create mode 100644 apps/daemon/internal/worldfs/scope_linux_test.go diff --git a/apps/daemon/internal/sessionview/doc.go b/apps/daemon/internal/sessionview/doc.go index b47364303..e9701c132 100644 --- a/apps/daemon/internal/sessionview/doc.go +++ b/apps/daemon/internal/sessionview/doc.go @@ -12,6 +12,8 @@ // // A view that declares a [Shim] also runs the Session's process relay, the shim binary in relay mode (package processshim). The launcher starts it before the process, under the same restrictions and as the same user, with a listening socket at processshim.SocketPath on a read-only mount and its end of a socket pair whose other end is [View.Relay]. The relay is the only process that receives the descriptors a shim hands over; the broker outside the view holds only its end of the pair. The launcher's drain ignores the relay, which ends with the view. // +// The world receives the View cgroup identity and the launcher PID namespace identity with its FUSE connection, so a world implementation can correlate kernel request thread IDs with that View. +// // Each view runs in a cgroup v2 of its own, which Start creates in [Spec].CgroupParent. The launcher is cloned into it with CLONE_INTO_CGROUP, so no process of the view ever runs outside it. The cgroup hierarchy is the only record of what the views own: [Recover] ends every cgroup an earlier owner of the parent left, with all its processes. // // Teardown never waits unconditionally. Once the launcher has exited, the view shuts its end of the relay connection down, ends every process in its cgroup with cgroup.kill and stops the world server, which ends the requests still pending on the view's FUSE connection so that a process blocked on the world can exit. Only when the world server and the launcher have ended and the cgroup is empty, all within 30 seconds, does it remove the cgroup. Otherwise Wait and Close report [ErrCleanup] and the cgroup stays for Recover; nothing touches it after the teardown. When Start's context ends during the build, Start kills the launcher, stops waiting for it and tears the view down the same way, so a launcher blocked on the world cannot hold Start past this bound. diff --git a/apps/daemon/internal/sessionview/spec.go b/apps/daemon/internal/sessionview/spec.go index 7ec12bee2..1387d6295 100644 --- a/apps/daemon/internal/sessionview/spec.go +++ b/apps/daemon/internal/sessionview/spec.go @@ -49,6 +49,10 @@ type WorldMount struct { UID, GID uint32 // Mountpoints are the view paths the launcher mounts over. The world presents each one with its type, whether or not the sandbox has the path, and keeps it and its ancestors stable for the view's lifetime: a mountpoint that disappears or changes identity detaches what is mounted on it. Mountpoints []Mountpoint + // Cgroup is the cgroup ID every process of the view runs in, and PidNS the identity (st_dev, st_ino of /proc//ns/pid) of the PID namespace the launcher mounted in, which the FUSE headers name threads in. Zero values mean unknown. + Cgroup uint64 + PidNSDev uint64 + PidNSIno uint64 } // Presentation is how the world presents the mountpoints. diff --git a/apps/daemon/internal/sessionview/view_linux.go b/apps/daemon/internal/sessionview/view_linux.go index 83a241b30..0988a8552 100644 --- a/apps/daemon/internal/sessionview/view_linux.go +++ b/apps/daemon/internal/sessionview/view_linux.go @@ -259,7 +259,15 @@ func (v *View) handshake(ctx context.Context, spec *Spec) error { } } else { v.dev = files[0] - world, present, err := spec.World(ctx, v.dev, WorldMount{Options: fuseOptions, Flags: fuseFlags, UID: spec.Process.UID, GID: spec.Process.GID, Mountpoints: mps}) + wm := WorldMount{Options: fuseOptions, Flags: fuseFlags, UID: spec.Process.UID, GID: spec.Process.GID, Mountpoints: mps} + var st unix.Stat_t + if err := unix.Stat(v.cgroup, &st); err == nil { + wm.Cgroup = st.Ino + } + if err := unix.Stat(fmt.Sprintf("/proc/%d/ns/pid", v.cmd.Process.Pid), &st); err == nil { + wm.PidNSDev, wm.PidNSIno = uint64(st.Dev), st.Ino + } + world, present, err := spec.World(ctx, v.dev, wm) if err != nil { return &Error{Kind: ErrWorld, Op: "serve", Err: err} } diff --git a/apps/daemon/internal/worldfs/doc.go b/apps/daemon/internal/worldfs/doc.go index 9d59345ec..9d1335e82 100644 --- a/apps/daemon/internal/worldfs/doc.go +++ b/apps/daemon/internal/worldfs/doc.go @@ -1,4 +1,4 @@ -// Package worldfs serves a Session's world, the sandbox file system, as the FUSE file system a sessionview launcher mounts at the view's root. Every kernel request becomes at most one File request (see internal/sandboxfs), apart from the redial and lock recovery described below, so processes in the view read and write sandbox files natively; nothing on the agent host shows through, and nothing is created in the sandbox to support the view. +// Package worldfs serves a Session's world, the sandbox file system, as the FUSE file system a sessionview launcher mounts at the view's root. Kernel requests become File requests (see internal/sandboxfs), with syscall-local metadata batching and the redial and lock recovery described below, so processes in the view read and write sandbox files natively; nothing on the agent host shows through, and nothing is created in the sandbox to support the view. // // [World.Serve] implements [sessionview.World]. Within the context sessionview.Start passes, it dials the attachment's File stream, checks Describe, attaches the world export (sandboxfs.WorldExport) and presents the view's mountpoints; it then serves the launcher's /dev/fuse connection with go-fuse's raw API. // @@ -49,6 +49,14 @@ // // Entry, attribute and negative timeouts are zero, every open returns FOPEN_DIRECT_IO, and the frontend never asks for the writeback cache, KEEP_CACHE or CACHE_DIR. A private mapping of a world file works, and the kernel reads its pages with READ when they fault; a shared mapping fails with ENODEV, since the frontend does not negotiate DIRECT_IO_ALLOW_MMAP. default_permissions stays off: the service decides access. The frontend negotiates only BIG_WRITES, MAX_PAGES (requests up to the service's read and write limit), PARALLEL_DIROPS, ATOMIC_O_TRUNC, POSIX_LOCKS and FLOCK_LOCKS. // +// # Metadata batching +// +// On Linux amd64, one process-wide set of BPF raw tracepoints observes eligible non-mutating metadata syscalls in registered View cgroups. sessionview supplies each world its cgroup and the mount's PID namespace identity. The first matching kernel LOOKUP may use File.Walk for a plain absolute path; later LOOKUP and node GETATTR requests consume matching parent/name or node results once, only while the kernel still names the same thread and syscall epoch. The kernel continues to execute the original syscall. Handle checks, permissions, synthetic mountpoints and pinned ancestor checks retain their ordinary paths. No metadata is reused across syscalls. +// +// BPF availability is an internal optimization condition, not a File capability or installation prerequisite: when loading is unavailable, every request follows the same ordinary FUSE mapping. The programs require the namespace PID helper (Linux 5.7 or later), kernel BPF support and permission to load and attach them. Other architectures use the ordinary mapping. While any scoped world remains, all host syscalls pay the programs' initial cgroup filter; the last world removes all programs and maps. +// +// Kernel scope hints occupy a bounded LRU map and can be discarded at any time. Missing or changed hints stop consumption immediately. Userspace retains at most a bounded set of operations; unused Walk references are forgotten when their operation ends, when a periodic map check sees that its epoch ended, or on world teardown. That periodic cleanup never decides whether metadata can be consumed. Unregistering a View removes its hints; an in-flight tracepoint can leave a disposable hint until LRU eviction or the last world's teardown, but hints hold no remote references. +// // # Errors // // A sandboxfs errno maps to its Linux errno through one table, StaleNode and StaleHandle map to ESTALE, and every other failure maps to EIO. Nothing a request did not do is reported as done. diff --git a/apps/daemon/internal/worldfs/export_linux_test.go b/apps/daemon/internal/worldfs/export_linux_test.go new file mode 100644 index 000000000..c199746ec --- /dev/null +++ b/apps/daemon/internal/worldfs/export_linux_test.go @@ -0,0 +1,6 @@ +//go:build linux + +package worldfs + +// ScopeErr exposes unavailable scope diagnostics to external package tests. +func (w *World) ScopeErr() error { return w.fs.scopeErr } diff --git a/apps/daemon/internal/worldfs/ops_linux.go b/apps/daemon/internal/worldfs/ops_linux.go index c770af09b..79f44b29b 100644 --- a/apps/daemon/internal/worldfs/ops_linux.go +++ b/apps/daemon/internal/worldfs/ops_linux.go @@ -29,6 +29,13 @@ func (f *frontend) Lookup(_ <-chan struct{}, in *fuse.InHeader, name string, out if p.synthetic() { return fuse.ENOENT } + if pf, ok := f.lookupScoped(in.Pid, p, name); ok { + if pf.err != nil { + return status(pf.err) + } + f.adopt(pf.entry, out) + return fuse.OK + } r, err := call(f, f.ctx, (*sandboxfs.Client).Lookup, &sandboxfs.LookupRequest{Parent: p.ref, Name: []byte(name)}) if err != nil { return status(err) @@ -74,6 +81,12 @@ func (f *frontend) GetAttr(_ <-chan struct{}, in *fuse.GetAttrIn, out *fuse.Attr if !st.Ok() { return st } + if t.Kind == sandboxfs.TargetNode { + if a, ok := f.attrScoped(in.Pid, n); ok { + f.fill(n, a, &out.Attr) + return fuse.OK + } + } r, err := call(f, f.ctx, (*sandboxfs.Client).GetAttr, &sandboxfs.GetAttrRequest{Target: t}) if err != nil { return status(err) diff --git a/apps/daemon/internal/worldfs/prefetch_linux.go b/apps/daemon/internal/worldfs/prefetch_linux.go new file mode 100644 index 000000000..c85ff7918 --- /dev/null +++ b/apps/daemon/internal/worldfs/prefetch_linux.go @@ -0,0 +1,183 @@ +//go:build linux + +package worldfs + +import ( + "strings" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" +) + +// Operation-local prefetch driven by the kernel scope records of scope_linux.go. +// +// An operation is one eligible syscall instance of one thread, identified by the record's epoch. Its +// entries serve exactly the (parent, name) and node the kernel asks for, once each, and only while the +// kernel still holds the same record: a consumption after the syscall exited is impossible, because the +// exit removed or replaced the record before the thread could run again. Entries never consumed are +// forgotten when the operation is replaced, when the reaper finds its record gone, or when the world stops. + +type prefetchKey struct { + parent sandboxfs.NodeRef + name string +} + +// prefetched is one walked step: the entry, or the failure that stopped the walk. +type prefetched struct { + entry sandboxfs.Entry + err error +} + +type operation struct { + epoch uint64 + entries map[prefetchKey]prefetched + attrs map[sandboxfs.NodeRef]sandboxfs.Attr +} + +const reapEvery = 500 * time.Millisecond + +// lookupScoped answers a LOOKUP of (p, name) from the thread's current operation, starting one with a Walk when the thread's syscall record names a plain path whose first component is this request. ok is false when the request takes its ordinary path. +func (f *frontend) lookupScoped(tid uint32, p *inode, name string) (prefetched, bool) { + if !f.scoped.Load() { + return prefetched{}, false + } + rec, live := lookupScope(f.cgroup, tid) + f.mu.Lock() + op := f.operations[tid] + if op != nil && (!live || op.epoch != rec.epoch) { + f.endLocked(tid) + op = nil + } + if op != nil { + pf, ok := f.takeLocked(op, p.ref, name) + f.mu.Unlock() + return pf, ok + } + f.mu.Unlock() + if !live || p != f.root { + return prefetched{}, false + } + // Pinned chains are contiguous from the root, so a first component that is not pinned leads to no pinned or synthetic node. + var names [][]byte + for i, c := range strings.Split(strings.TrimPrefix(rec.path, "/"), "/") { + if c == "" || c == "." || c == ".." || uint64(len(c)) > uint64(f.caps.MaxNameBytes) || i == 0 && f.root.fixed[c] != nil || len(names) == int(f.caps.MaxWalkComponents) { + break + } + names = append(names, []byte(c)) + } + if len(names) == 0 || string(names[0]) != name { + return prefetched{}, false + } + r, err := call(f, f.ctx, (*sandboxfs.Client).Walk, &sandboxfs.WalkRequest{Parent: f.root.ref, Names: names}) + if err != nil { + // The Walk failed where a Lookup of the first component would have: the same failure answers the request. + return prefetched{err: err}, true + } + op = &operation{epoch: rec.epoch, entries: map[prefetchKey]prefetched{}, attrs: map[sandboxfs.NodeRef]sandboxfs.Attr{}} + parent := f.root.ref + for i, e := range r.Entries { + op.entries[prefetchKey{parent, string(names[i])}] = prefetched{entry: e} + op.attrs[e.Node] = e.Attr + parent = e.Node + } + if r.Failure != nil && len(r.Entries) < len(names) { + op.entries[prefetchKey{parent, string(names[len(r.Entries)])}] = prefetched{err: r.Failure} + } + f.mu.Lock() + defer f.mu.Unlock() + f.endLocked(tid) + if f.closed.Load() || f.dead.Load() { + f.forgetLocked(op) + return prefetched{}, false + } + // Finished threads can leave entries until the reaper runs. Bound that + // userspace set independently of the concurrently active kernel records. + if len(f.operations) >= maxScopes { + pf, ok := f.takeLocked(op, p.ref, name) + f.forgetLocked(op) + return pf, ok + } + // The thread is still inside this syscall, since its request is unanswered, so its record is unchanged; the record is checked again anyway before anything later is consumed. + if f.operations == nil { + f.operations = map[uint32]*operation{} + } + f.operations[tid] = op + pf, ok := f.takeLocked(op, p.ref, name) + return pf, ok +} + +// attrScoped answers a GETATTR of node n from the thread's current operation while its kernel record is unchanged. +func (f *frontend) attrScoped(tid uint32, n *inode) (sandboxfs.Attr, bool) { + if !f.scoped.Load() { + return sandboxfs.Attr{}, false + } + f.mu.Lock() + defer f.mu.Unlock() + op := f.operations[tid] + if op == nil { + return sandboxfs.Attr{}, false + } + if rec, live := lookupScope(f.cgroup, tid); !live || rec.epoch != op.epoch { + f.endLocked(tid) + return sandboxfs.Attr{}, false + } + a, ok := op.attrs[n.ref] + if ok { + delete(op.attrs, n.ref) + } + return a, ok +} + +// takeLocked consumes the prefetched step for (parent, name). The caller holds mu. +func (f *frontend) takeLocked(op *operation, parent sandboxfs.NodeRef, name string) (prefetched, bool) { + k := prefetchKey{parent, name} + p, ok := op.entries[k] + if ok { + delete(op.entries, k) + } + return p, ok +} + +// endLocked drops the operation of tid and forgets what it still holds. The caller holds mu. +func (f *frontend) endLocked(tid uint32) { + if op := f.operations[tid]; op != nil { + delete(f.operations, tid) + f.forgetLocked(op) + } +} + +func (f *frontend) forgetLocked(op *operation) { + for _, p := range op.entries { + if p.err == nil { + f.unref(p.entry.Node, 1) + } + } +} + +// reap forgets what operations whose syscall has ended left behind, until the drainer stops. Its period bounds resource retention only; consumption never depends on it. +func (f *frontend) reap() { + defer close(f.reaped) + t := time.NewTicker(reapEvery) + defer t.Stop() + for { + select { + case <-f.drainCtx.Done(): + return + case <-t.C: + } + f.mu.Lock() + for tid, op := range f.operations { + if rec, live := lookupScope(f.cgroup, tid); !live || rec.epoch != op.epoch { + f.endLocked(tid) + } + } + f.mu.Unlock() + } +} + +// endAll ends every operation when the world stops. The caller holds mu. +func (f *frontend) endAll() { + for tid := range f.operations { + f.endLocked(tid) + } +} diff --git a/apps/daemon/internal/worldfs/prefetch_linux_test.go b/apps/daemon/internal/worldfs/prefetch_linux_test.go new file mode 100644 index 000000000..fe9fe73f2 --- /dev/null +++ b/apps/daemon/internal/worldfs/prefetch_linux_test.go @@ -0,0 +1,152 @@ +//go:build linux + +package worldfs_test + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" + "golang.org/x/sys/unix" +) + +// Proof for syscall-local prefetch: real statx syscalls on a real FUSE mount of the real file service, observed by the BPF scope programs. + +type opCounts map[sandboxfs.Op]int64 + +func (m *mounted) snapshot() opCounts { + c := opCounts{} + for op := sandboxfs.OpDescribe; op <= sandboxfs.OpOpenTree; op++ { + c[op] = m.ops[op].Load() + } + return c +} + +func (m *mounted) delta(t *testing.T, since opCounts, want opCounts) { + t.Helper() + now := m.snapshot() + for op, n := range want { + if got := now[op] - since[op]; got != n { + t.Errorf("%s requests = %d, want %d", op, got, n) + } + } +} + +func statx(t *testing.T, path string) (unix.Statx_t, error) { + t.Helper() + var st unix.Statx_t + err := unix.Statx(unix.AT_FDCWD, path, unix.AT_SYMLINK_NOFOLLOW|unix.AT_STATX_SYNC_AS_STAT, unix.STATX_ALL, &st) + return st, err +} + +func sameStat(a, b unix.Statx_t) bool { + return a.Mode == b.Mode && a.Ino == b.Ino && a.Size == b.Size && a.Uid == b.Uid && a.Gid == b.Gid && a.Nlink == b.Nlink && a.Mtime == b.Mtime && a.Ctime == b.Ctime && a.Dev_major == b.Dev_major && a.Dev_minor == b.Dev_minor && a.Mnt_id == b.Mnt_id +} + +func TestScopedStatx(t *testing.T) { + requireFUSE(t) + backing := t.TempDir() + if err := os.MkdirAll(filepath.Join(backing, "a", "b"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(backing, "a", "b", "c.txt"), "content") + m, err := serve(t, backing, 0, sessionview.Mountpoint{Path: "/bin/sh"}) + if err != nil { + t.Fatalf("Serve: %v", err) + } + if err := m.world.ScopeErr(); err != nil { + t.Fatalf("scopes unavailable: %v", err) + } + // A view sees the world at /; one locked thread of its own enters the mount as its root, so absolute paths are world paths as in a view, and the thread ends with the goroutine, leaving the cleanups an ordinary thread. + done := make(chan struct{}) + go func() { + defer close(done) + runtime.LockOSThread() + if err := unix.Unshare(unix.CLONE_FS); err != nil { + t.Error(err) + return + } + if err := unix.Chdir(m.dir); err != nil { + t.Error(err) + return + } + scopedStatx(t, m, backing) + }() + <-done +} + +func scopedStatx(t *testing.T, m *mounted, backing string) { + p := "/a/b/c.txt" + sandbox := func(fn func() error) error { + done := make(chan error, 1) + go func() { done <- fn() }() // another thread: not chrooted + return <-done + } + + // A relative path has no scope: one LOOKUP per component and one GETATTR, as the Uncached profile requires. + s0 := m.snapshot() + base, err := statx(t, "a/b/c.txt") + if err != nil { + t.Error(err) + return + } + if err := unix.Chroot(m.dir); err != nil { + t.Error(err) + return + } + if err != nil { + t.Fatal(err) + } + m.delta(t, s0, opCounts{sandboxfs.OpLookup: 3, sandboxfs.OpGetAttr: 1, sandboxfs.OpWalk: 0}) + + // An absolute path is scoped: the first LOOKUP runs one Walk and the rest of the syscall consumes it. Same statx output. + s1 := m.snapshot() + got, err := statx(t, p) + if err != nil { + t.Error(err) + return + } + if !sameStat(base, got) { + t.Errorf("scoped statx differs:\n%+v\n%+v", base, got) + } + m.delta(t, s1, opCounts{sandboxfs.OpWalk: 1, sandboxfs.OpLookup: 0, sandboxfs.OpGetAttr: 0}) + + // Missing leaf: the Walk failure answers the leaf LOOKUP with ENOENT. + s2 := m.snapshot() + if _, err := statx(t, "/a/b/missing"); err != unix.ENOENT { + t.Errorf("missing: %v, want ENOENT", err) + } + m.delta(t, s2, opCounts{sandboxfs.OpWalk: 1, sandboxfs.OpLookup: 0, sandboxfs.OpGetAttr: 0}) + + // No cross-syscall reuse: a sandbox-side rename between two statx calls is observed by the second, which is a new scope and a new Walk. + if err := sandbox(func() error { + return os.Rename(filepath.Join(backing, "a", "b", "c.txt"), filepath.Join(backing, "a", "b", "e.txt")) + }); err != nil { + t.Fatal(err) + } + // The cached positive dentry of c.txt is revalidated by the Walk's ENOENT (consumed), then the kernel looks the name up afresh: that one Lookup is the ordinary request. + s3 := m.snapshot() + if _, err := statx(t, p); err != unix.ENOENT { + t.Errorf("after rename: %v, want ENOENT", err) + } + m.delta(t, s3, opCounts{sandboxfs.OpWalk: 1, sandboxfs.OpLookup: 1}) + + // A pinned first component is never walked: the original pinned path answers. + s4 := m.snapshot() + if _, err := statx(t, "/bin/sh"); err != nil { + t.Errorf("pinned: %v", err) + } + m.delta(t, s4, opCounts{sandboxfs.OpWalk: 0}) + + // Readlink of a regular file: one Walk, the kernel answers EINVAL itself. One component is asked a second time (observed: one Lookup), which the consumed entry no longer answers. + s5 := m.snapshot() + if _, err := os.Readlink("/a/b/e.txt"); err == nil { + t.Errorf("readlink of a regular file succeeded") + } + m.delta(t, s5, opCounts{sandboxfs.OpWalk: 1, sandboxfs.OpReadlink: 0}) + t.Logf("readlink extra Lookup requests = %d", m.snapshot()[sandboxfs.OpLookup]-s5[sandboxfs.OpLookup]) + +} diff --git a/apps/daemon/internal/worldfs/scope_linux.go b/apps/daemon/internal/worldfs/scope_linux.go new file mode 100644 index 000000000..0bf813d3b --- /dev/null +++ b/apps/daemon/internal/worldfs/scope_linux.go @@ -0,0 +1,244 @@ +//go:build linux + +package worldfs + +import ( + "encoding/binary" + "errors" + "fmt" + "runtime" + "sync" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" + "github.com/cilium/ebpf/link" +) + +// Syscall-local metadata prediction observed with BPF. +// +// One raw-tracepoint program set per daemon process publishes, for threads of registered View cgroups only, +// the pathname of an eligible non-mutating syscall (statx, newfstatat, readlink, readlinkat, openat with +// O_PATH) at sys_enter, keyed by (cgroup, thread ID in the View's PID namespace), with an epoch unique to +// that syscall instance; every sys_enter of the thread first removes its previous record, and sys_exit and +// sched_process_exit remove it synchronously. The kernel runs the real syscall unchanged. A FUSE request +// consults the record of its own thread (FUSE names the thread in the mount's PID namespace): the first +// LOOKUP of a syscall whose record names a plain path runs one Walk inside that blocked syscall, and later +// LOOKUP/GETATTR of the same syscall consume its entries only while the record's epoch is unchanged. Once +// the syscall exited, the record is gone or carries a new epoch, so nothing of a finished operation can be +// consumed; a bounded reaper forgets what finished operations left. No event delivery is trusted. +// +// When the programs cannot be loaded (no capability, LSM, kernel configuration), the world serves every +// request as before: the scope is an acceleration of the same File requests, not a protocol capability. + +const ( + scopeKeySize = 16 // cgroup u64, thread ID u32, pad u32 + scopeValueSize = 280 // counter u64, epoch u64, syscall u64, path [256]byte + scopePathMax = 256 + maxScopes = 4096 + maxViews = 256 +) + +type scopeRecord struct { + epoch uint64 + nr uint64 + path string +} + +// scopes is the process-wide loader, shared by every world of the daemon and reference counted. +var scopes struct { + mu sync.Mutex + refs int + coll *ebpf.Collection + links []link.Link + err error +} + +// acquireScopes loads and attaches the programs for the first world and reports whether scopes are available. +func acquireScopes() (bool, error) { + if runtime.GOARCH != "amd64" { + return false, fmt.Errorf("worldfs: syscall scope register layout unavailable on %s", runtime.GOARCH) + } + scopes.mu.Lock() + defer scopes.mu.Unlock() + if scopes.refs > 0 { + scopes.refs++ + return true, nil + } + if scopes.err != nil { + return false, scopes.err + } + coll, err := ebpf.NewCollection(scopeSpec()) + if err != nil { + scopes.err = fmt.Errorf("worldfs: load scope programs: %w", err) + return false, scopes.err + } + var links []link.Link + for prog, tp := range map[string][]string{"scope_enter": {"sys_enter"}, "scope_clear": {"sys_exit", "sched_process_exit"}} { + for _, name := range tp { + l, err := link.AttachRawTracepoint(link.RawTracepointOptions{Name: name, Program: coll.Programs[prog]}) + if err != nil { + for _, l := range links { + l.Close() + } + coll.Close() + scopes.err = fmt.Errorf("worldfs: attach %s: %w", name, err) + return false, scopes.err + } + links = append(links, l) + } + } + scopes.coll, scopes.links, scopes.refs = coll, links, 1 + return true, nil +} + +func releaseScopes() { + scopes.mu.Lock() + defer scopes.mu.Unlock() + if scopes.refs--; scopes.refs > 0 { + return + } + for _, l := range scopes.links { + l.Close() + } + scopes.coll.Close() + scopes.coll, scopes.links = nil, nil +} + +// registerView publishes the View's cgroup with its PID namespace identity. The caller holds a reference. +func registerView(cgroup, nsDev, nsIno uint64) error { + var v [16]byte + binary.LittleEndian.PutUint64(v[0:], nsDev) + binary.LittleEndian.PutUint64(v[8:], nsIno) + return scopes.coll.Maps["views"].Update(cgroup, v, ebpf.UpdateAny) +} + +func unregisterView(cgroup uint64) { + _ = scopes.coll.Maps["views"].Delete(cgroup) + // These records are disposable hints, not remote references. An enter + // already in progress can still publish after this scan; the bounded LRU + // lets later active scopes replace that hint instead of exhausting capacity. + m := scopes.coll.Maps["scopes"] + var key [scopeKeySize]byte + var value [scopeValueSize]byte + var remove [][scopeKeySize]byte + it := m.Iterate() + for it.Next(&key, &value) { + if binary.LittleEndian.Uint64(key[:8]) == cgroup { + remove = append(remove, key) + } + } + for _, key := range remove { + _ = m.Delete(key) + } +} + +// lookupScope returns the current record of the thread, if the kernel holds one. +func lookupScope(cgroup uint64, tid uint32) (scopeRecord, bool) { + scopes.mu.Lock() + defer scopes.mu.Unlock() + if scopes.coll == nil { + return scopeRecord{}, false + } + var k [scopeKeySize]byte + binary.LittleEndian.PutUint64(k[0:], cgroup) + binary.LittleEndian.PutUint32(k[8:], tid) + var v [scopeValueSize]byte + if err := scopes.coll.Maps["scopes"].Lookup(k, &v); err != nil { + if !errors.Is(err, ebpf.ErrKeyNotExist) { + // Any failure means no scope: the request takes its ordinary path. + return scopeRecord{}, false + } + return scopeRecord{}, false + } + path := v[24:] + n := 0 + for n < len(path) && path[n] != 0 { + n++ + } + return scopeRecord{epoch: binary.LittleEndian.Uint64(v[8:]), nr: binary.LittleEndian.Uint64(v[16:]), path: string(path[:n])}, true +} + +// scopeSpec is the program set. Register use: R6 ctx, R7 syscall number, R8 scratch record, R9 regs then user pointer. Stack: fp-16 key (cgroup, tid, pad), fp-24 pidns info, fp-32 scratch word, fp-40 array index. amd64 pt_regs offsets: rdx 96, rsi 104, rdi 112. +func scopeSpec() *ebpf.CollectionSpec { + prefix := func() asm.Instructions { + return asm.Instructions{ + asm.Mov.Reg(asm.R6, asm.R1), + asm.FnGetCurrentCgroupId.Call(), + asm.StoreMem(asm.RFP, -16, asm.R0, asm.DWord), + asm.LoadMapPtr(asm.R1, 0).WithReference("views"), + asm.Mov.Reg(asm.R2, asm.RFP), asm.Add.Imm(asm.R2, -16), + asm.FnMapLookupElem.Call(), + asm.JEq.Imm(asm.R0, 0, "exit"), + asm.LoadMem(asm.R1, asm.R0, 0, asm.DWord), + asm.LoadMem(asm.R2, asm.R0, 8, asm.DWord), + asm.Mov.Reg(asm.R3, asm.RFP), asm.Add.Imm(asm.R3, -24), + asm.Mov.Imm(asm.R4, 8), + asm.FnGetNsCurrentPidTgid.Call(), + asm.JNE.Imm(asm.R0, 0, "exit"), + asm.LoadMem(asm.R1, asm.RFP, -24, asm.Word), + asm.StoreMem(asm.RFP, -8, asm.R1, asm.Word), + asm.StoreImm(asm.RFP, -4, 0, asm.Word), + // Every observed syscall entry or exit of the thread first drops its previous record. + asm.LoadMapPtr(asm.R1, 0).WithReference("scopes"), + asm.Mov.Reg(asm.R2, asm.RFP), asm.Add.Imm(asm.R2, -16), + asm.FnMapDeleteElem.Call(), + } + } + exit := asm.Instructions{asm.Mov.Imm(asm.R0, 0).WithSymbol("exit"), asm.Return()} + enter := append(prefix(), asm.Instructions{ + asm.LoadMem(asm.R7, asm.R6, 8, asm.DWord), // syscall number + asm.LoadMem(asm.R9, asm.R6, 0, asm.DWord), // struct pt_regs * + asm.JEq.Imm(asm.R7, 89, "rdi"), // readlink + asm.JEq.Imm(asm.R7, 262, "rsi"), // newfstatat + asm.JEq.Imm(asm.R7, 267, "rsi"), // readlinkat + asm.JEq.Imm(asm.R7, 332, "rsi"), // statx + asm.JNE.Imm(asm.R7, 257, "exit"), // openat: O_PATH only + asm.Mov.Reg(asm.R1, asm.RFP), asm.Add.Imm(asm.R1, -32), asm.Mov.Imm(asm.R2, 8), asm.Mov.Reg(asm.R3, asm.R9), asm.Add.Imm(asm.R3, 96), + asm.FnProbeReadKernel.Call(), + asm.JNE.Imm(asm.R0, 0, "exit"), + asm.LoadMem(asm.R1, asm.RFP, -32, asm.DWord), + asm.JSet.Imm(asm.R1, 0x200000, "rsi"), + asm.Ja.Label("exit"), + asm.Mov.Reg(asm.R3, asm.R9).WithSymbol("rsi"), asm.Add.Imm(asm.R3, 104), asm.Ja.Label("read"), + asm.Mov.Reg(asm.R3, asm.R9).WithSymbol("rdi"), asm.Add.Imm(asm.R3, 112), + asm.Mov.Reg(asm.R1, asm.RFP).WithSymbol("read"), asm.Add.Imm(asm.R1, -32), asm.Mov.Imm(asm.R2, 8), + asm.FnProbeReadKernel.Call(), + asm.JNE.Imm(asm.R0, 0, "exit"), + asm.LoadMem(asm.R9, asm.RFP, -32, asm.DWord), // user pathname pointer + asm.JEq.Imm(asm.R9, 0, "exit"), + asm.StoreImm(asm.RFP, -40, 0, asm.Word), + asm.LoadMapPtr(asm.R1, 0).WithReference("scratch"), + asm.Mov.Reg(asm.R2, asm.RFP), asm.Add.Imm(asm.R2, -40), + asm.FnMapLookupElem.Call(), + asm.JEq.Imm(asm.R0, 0, "exit"), + asm.Mov.Reg(asm.R8, asm.R0), + asm.StoreMem(asm.R8, 16, asm.R7, asm.DWord), + asm.Mov.Reg(asm.R1, asm.R8), asm.Add.Imm(asm.R1, 24), asm.Mov.Imm(asm.R2, scopePathMax), asm.Mov.Reg(asm.R3, asm.R9), + asm.FnProbeReadUserStr.Call(), + asm.JSLE.Imm(asm.R0, 1, "exit"), // failed or empty + asm.JSGE.Imm(asm.R0, scopePathMax, "exit"), // truncated + asm.LoadMem(asm.R1, asm.R8, 24, asm.Byte), + asm.JNE.Imm(asm.R1, '/', "exit"), // only absolute paths + // epoch: per-CPU counter with the CPU in the top bits, unique per syscall instance. + asm.LoadMem(asm.R1, asm.R8, 0, asm.DWord), asm.Add.Imm(asm.R1, 1), asm.StoreMem(asm.R8, 0, asm.R1, asm.DWord), + asm.FnGetSmpProcessorId.Call(), + asm.LSh.Imm(asm.R0, 48), + asm.LoadMem(asm.R1, asm.R8, 0, asm.DWord), asm.Or.Reg(asm.R1, asm.R0), asm.StoreMem(asm.R8, 8, asm.R1, asm.DWord), + asm.LoadMapPtr(asm.R1, 0).WithReference("scopes"), + asm.Mov.Reg(asm.R2, asm.RFP), asm.Add.Imm(asm.R2, -16), asm.Mov.Reg(asm.R3, asm.R8), asm.Mov.Imm(asm.R4, 0), + asm.FnMapUpdateElem.Call(), + }...) + enter = append(enter, exit...) + clear := append(prefix(), exit...) + return &ebpf.CollectionSpec{ + Maps: map[string]*ebpf.MapSpec{ + "views": {Name: "oac_views", Type: ebpf.Hash, KeySize: 8, ValueSize: 16, MaxEntries: maxViews}, + "scopes": {Name: "oac_scopes", Type: ebpf.LRUHash, KeySize: scopeKeySize, ValueSize: scopeValueSize, MaxEntries: maxScopes}, + "scratch": {Name: "oac_scratch", Type: ebpf.PerCPUArray, KeySize: 4, ValueSize: scopeValueSize, MaxEntries: 1}, + }, + Programs: map[string]*ebpf.ProgramSpec{ + "scope_enter": {Name: "oac_scope_enter", Type: ebpf.RawTracepoint, AttachTo: "sys_enter", License: "GPL", Instructions: enter}, + "scope_clear": {Name: "oac_scope_clear", Type: ebpf.RawTracepoint, AttachTo: "sys_exit", License: "GPL", Instructions: clear}, + }, + } +} diff --git a/apps/daemon/internal/worldfs/scope_linux_test.go b/apps/daemon/internal/worldfs/scope_linux_test.go new file mode 100644 index 000000000..8f1abe55a --- /dev/null +++ b/apps/daemon/internal/worldfs/scope_linux_test.go @@ -0,0 +1,152 @@ +//go:build linux + +package worldfs + +import ( + "encoding/binary" + "errors" + "os" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" + "github.com/cilium/ebpf" + "golang.org/x/sys/unix" +) + +func TestScopeLifetimeAndEviction(t *testing.T) { + if os.Getenv("OAC_TEST_WORLDFS") != "1" { + t.Skip("requires the worldfs test container") + } + ok, err := acquireScopes() + if !ok || err != nil { + t.Fatalf("scope programs: %v", err) + } + held := true + defer func() { + if held { + releaseScopes() + } + }() + ok, err = acquireScopes() + if !ok || err != nil { + t.Fatal(err) + } + var ids []ebpf.ProgramID + for _, p := range scopes.coll.Programs { + info, err := p.Info() + if err != nil { + t.Fatal(err) + } + id, ok := info.ID() + if !ok { + t.Fatal("no program ID") + } + ids = append(ids, id) + } + releaseScopes() + if scopes.refs != 1 || scopes.coll == nil || len(scopes.links) != 3 { + t.Fatal("first release detached shared programs") + } + for _, id := range ids { + p, err := ebpf.NewProgramFromID(id) + if err != nil { + t.Fatal(err) + } + p.Close() + } + + const cg = ^uint64(0) + const tid = uint32(42) + var key [scopeKeySize]byte + binary.LittleEndian.PutUint64(key[:8], cg) + binary.LittleEndian.PutUint32(key[8:], tid) + var value [scopeValueSize]byte + binary.LittleEndian.PutUint64(value[8:], 123) + m := scopes.coll.Maps["scopes"] + if err := m.Update(key, value, ebpf.UpdateAny); err != nil { + t.Fatal(err) + } + for i := uint32(100); i < 100+maxScopes*2; i++ { + k := key + binary.LittleEndian.PutUint32(k[8:], i) + if err := m.Update(k, value, ebpf.UpdateAny); err != nil { + t.Fatal(err) + } + } + if _, live := lookupScope(cg, tid); live { + t.Fatal("old untouched hint survived two capacities of newer hints") + } + + // Eviction and epoch replacement must dispose of acquired references instead + // of answering a later request from the old operation. + f := New(nil).fs + defer f.cancel() + f.scoped.Store(true) + f.cgroup = cg + ref := sandboxfs.NodeRef{ID: 99, Generation: 1} + makeOp := func() *operation { + return &operation{epoch: 123, entries: map[prefetchKey]prefetched{{parent: ref, name: "child"}: {entry: sandboxfs.Entry{Node: ref}}}, attrs: map[sandboxfs.NodeRef]sandboxfs.Attr{ref: {}}} + } + f.operations = map[uint32]*operation{tid: makeOp()} + if _, ok := f.attrScoped(tid, &inode{ref: ref}); ok { + t.Fatal("evicted scope returned an attribute") + } + if f.forgets[ref] != 1 || len(f.operations) != 0 { + t.Fatal("evicted operation did not release its unused reference") + } + binary.LittleEndian.PutUint64(value[8:], 124) + if err := m.Update(key, value, ebpf.UpdateAny); err != nil { + t.Fatal(err) + } + f.operations[tid] = makeOp() + if _, ok := f.attrScoped(tid, &inode{ref: ref}); ok { + t.Fatal("replacement epoch returned an old attribute") + } + if f.forgets[ref] != 2 { + t.Fatal("replaced operation did not release its unused reference") + } + + f.operations[tid] = makeOp() + f.reaped = make(chan struct{}) + go f.reap() + deadline := time.Now().Add(2 * time.Second) + for { + f.mu.Lock() + done := len(f.operations) == 0 && f.forgets[ref] == 3 + f.mu.Unlock() + if done { + break + } + if time.Now().After(deadline) { + t.Fatal("reaper retained a finished operation") + } + time.Sleep(10 * time.Millisecond) + } + f.stopDrain() + <-f.reaped + unregisterView(cg) + if _, live := lookupScope(cg, tid); live { + t.Fatal("unregister retained its existing hint") + } + // Drop the final reference and verify that the kernel programs disappear. + releaseScopes() + held = false + if scopes.refs != 0 || scopes.coll != nil || len(scopes.links) != 0 { + t.Fatal("last release retained programs") + } + for _, id := range ids { + p, err := ebpf.NewProgramFromID(id) + if err == nil { + p.Close() + t.Fatalf("program %d still live", id) + } + if !errors.Is(err, unix.ENOENT) { + t.Fatal(err) + } + } + if ok, err := acquireScopes(); !ok || err != nil { + t.Fatalf("reload: %v", err) + } + held = true +} diff --git a/apps/daemon/internal/worldfs/world_linux.go b/apps/daemon/internal/worldfs/world_linux.go index 4ea412318..a09265333 100644 --- a/apps/daemon/internal/worldfs/world_linux.go +++ b/apps/daemon/internal/worldfs/world_linux.go @@ -106,16 +106,21 @@ type frontend struct { view sandboxfs.Identity // the identity the view's processes run as caps sandboxfs.Capabilities - mu sync.Mutex - nodes map[uint64]*inode - byRef map[sandboxfs.NodeRef]*inode - lastID uint64 - handles map[uint64]*handle - lastFh uint64 - root *inode - born sandboxfs.Timestamp - forgets map[sandboxfs.NodeRef]uint64 // references the kernel released, not yet sent - releases []cleanup // releases for the drainer: of handles the kernel closed whose Release went unanswered, and of acquisitions in doubt + mu sync.Mutex + nodes map[uint64]*inode + byRef map[sandboxfs.NodeRef]*inode + lastID uint64 + handles map[uint64]*handle + lastFh uint64 + root *inode + born sandboxfs.Timestamp + forgets map[sandboxfs.NodeRef]uint64 // references the kernel released, not yet sent + releases []cleanup // releases for the drainer: of handles the kernel closed whose Release went unanswered, and of acquisitions in doubt + operations map[uint32]*operation // prefetched steps by operation thread + scoped atomic.Bool // the view is registered with the scope programs + cgroup uint64 // the view's cgroup ID + scopeErr error + reaped chan struct{} kick chan struct{} // wakes the drainer drainCtx context.Context @@ -147,6 +152,19 @@ func (f *frontend) serve(ctx context.Context, dev *os.File, mount sessionview.Wo return sessionview.Presentation{}, fmt.Errorf("%w: serve: the world already serves a view", ErrConnect) } f.view = sandboxfs.Identity{UID: mount.UID, GID: mount.GID} + if mount.Cgroup != 0 && mount.PidNSIno != 0 { + if ok, err := acquireScopes(); ok { + if err := registerView(mount.Cgroup, mount.PidNSDev, mount.PidNSIno); err == nil { + f.cgroup = mount.Cgroup + f.scoped.Store(true) + } else { + releaseScopes() + f.scopeErr = err + } + } else { + f.scopeErr = err + } + } now := time.Now() f.born = sandboxfs.Timestamp{Sec: now.Unix(), Nsec: uint32(now.Nanosecond())} p, opts, err := f.attach(ctx, mount.Mountpoints) @@ -210,6 +228,10 @@ func (f *frontend) start(dev *os.File, opts *fuse.MountOptions) error { return fmt.Errorf("%w: init: %w", ErrConnect, err) } go f.drain() + if f.scoped.Load() { + f.reaped = make(chan struct{}) + go f.reap() + } go func() { srv.Serve() close(f.served) @@ -288,6 +310,9 @@ func (f *frontend) stop() error { errs = append(errs, fmt.Errorf("%w: stop: the view's mount still exists", ErrConnect)) } f.closed.Store(true) + f.mu.Lock() + f.endAll() + f.mu.Unlock() ctx, cancel := context.WithTimeout(f.ctx, detachWait) defer cancel() // At the deadline every request still on f.ctx ends too, such as a redial that holds the stream. @@ -304,8 +329,21 @@ func (f *frontend) stop() error { return errors.Join(errs...) } +// unscope withdraws the view from the scope programs; later records of its threads are never consulted. +func (f *frontend) unscope() { + if f.scoped.Swap(false) { + unregisterView(f.cgroup) + releaseScopes() + } +} + // shutdown ends every request and redial on f.ctx and closes the stream. client returns no stream after it. func (f *frontend) shutdown() { + f.stopDrain() + if f.reaped != nil { + <-f.reaped + } + f.unscope() f.cancel() f.drop() } diff --git a/apps/daemon/internal/worldfs/world_linux_test.go b/apps/daemon/internal/worldfs/world_linux_test.go index 3affdfc33..47baf56e0 100644 --- a/apps/daemon/internal/worldfs/world_linux_test.go +++ b/apps/daemon/internal/worldfs/world_linux_test.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync/atomic" "syscall" "testing" @@ -55,6 +56,16 @@ func TestMain(m *testing.M) { // runHelper edits the workspace the way editors do, then runs the shim through the sandbox's /bin symlink. func runHelper() int { + // Absolute-path metadata syscalls of a View process, each one prefetch operation. The file is written next, so the first statx is a miss at the leaf and the mutation is not in any operation's scope. + var st unix.Statx_t + for range 3 { + _ = unix.Statx(unix.AT_FDCWD, "/data/sub/dir", unix.AT_SYMLINK_NOFOLLOW, unix.STATX_ALL, &st) + _, _ = os.Readlink("/data/sub/dir") + } + if err := unix.Statx(unix.AT_FDCWD, "/data/f.txt", unix.AT_SYMLINK_NOFOLLOW, unix.STATX_ALL, &st); err != unix.ENOENT { + fmt.Fprintln(os.Stderr, "statx before write:", err) + return 1 + } if err := os.WriteFile("/data/f.tmp", []byte("edited in the view"), 0o644); err != nil { fmt.Fprintln(os.Stderr, err) return 1 @@ -63,6 +74,10 @@ func runHelper() int { fmt.Fprintln(os.Stderr, err) return 1 } + if err := unix.Statx(unix.AT_FDCWD, "/data/f.txt", unix.AT_SYMLINK_NOFOLLOW, unix.STATX_ALL, &st); err != nil || st.Size != 18 { + fmt.Fprintln(os.Stderr, "statx after rename:", err, st.Size) + return 1 + } out, err := exec.Command("/bin/sh").Output() if err != nil { fmt.Fprintln(os.Stderr, err) @@ -85,18 +100,25 @@ type mounted struct { srv *fileservicetest.Server present sessionview.Presentation readDirs atomic.Int64 // ReadDir requests the world sent - stopped bool // the test unmounted and stopped the world itself + ops [64]atomic.Int64 + stopped bool // the test unmounted and stopped the world itself } -// counted counts the ReadDir requests written to a stream, one frame per write. +// counted counts the requests written to a stream by tag, one frame per write. type counted struct { io.ReadWriteCloser readDirs *atomic.Int64 + ops *[64]atomic.Int64 } func (c counted) Write(b []byte) (int, error) { - if len(b) >= 6 && binary.BigEndian.Uint16(b[4:6]) == uint16(sandboxfs.OpReadDir) { - c.readDirs.Add(1) + if len(b) >= 6 { + if op := binary.BigEndian.Uint16(b[4:6]); op < 64 { + c.ops[op].Add(1) + if op == uint16(sandboxfs.OpReadDir) { + c.readDirs.Add(1) + } + } } return c.ReadWriteCloser.Write(b) } @@ -125,9 +147,21 @@ func serve(t *testing.T, backing string, id uint32, mps ...sessionview.Mountpoin if err != nil { return nil, err } - return counted{rw, &m.readDirs}, nil + return counted{rw, &m.readDirs, &m.ops}, nil }) - ws, p, err := m.world.Serve(context.Background(), dev, sessionview.WorldMount{UID: id, GID: id, Mountpoints: mps}) + wm := sessionview.WorldMount{UID: id, GID: id, Mountpoints: mps} + // Register this process's own cgroup and PID namespace, as the View launcher would. + var st unix.Stat_t + if cg, err := os.ReadFile("/proc/self/cgroup"); err == nil { + if _, path, ok := strings.Cut(strings.TrimSpace(string(cg)), "::"); ok && unix.Stat("/sys/fs/cgroup"+path, &st) == nil { + wm.Cgroup = st.Ino + } + } + if unix.Stat("/proc/self/ns/pid", &st) == nil { + wm.PidNSDev, wm.PidNSIno = uint64(st.Dev), st.Ino + } + t.Logf("scope registration: cgroup=%d pidns=%d:%d", wm.Cgroup, wm.PidNSDev, wm.PidNSIno) + ws, p, err := m.world.Serve(context.Background(), dev, wm) m.present = p if err == nil { // The kernel asks the server about a file's first poll, and the Go runtime polls each file this process opens without releasing its P, which the world needs to answer. diff --git a/go.mod b/go.mod index ce32e8bfb..9a4fc8358 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/MiniMax-AI/OpenAgentCore go 1.26.8 require ( + github.com/cilium/ebpf v0.21.0 github.com/containerd/errdefs v1.0.0 github.com/creack/pty v1.1.24 github.com/go-chi/chi/v5 v5.3.2 diff --git a/go.sum b/go.sum index 00afefb82..a7efae6dd 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cilium/ebpf v0.21.0 h1:4dpx1J/B/1apeTmWBH5BkVLayHTkFrMovVPnHEk+l3k= +github.com/cilium/ebpf v0.21.0/go.mod h1:1kHKv6Kvh5a6TePP5vvvoMa1bclRyzUXELSs272fmIQ= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -13,6 +15,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= @@ -88,6 +92,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=