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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ

### Fixed

- Fix all Claude sessions inside moat freezing at once on macOS during a package install — previously, the shared credential-injecting proxy daemon inherited the host's default `RLIMIT_NOFILE` soft limit (typically 256 on macOS), so a burst of concurrent connections through the single proxy (`bun install` opens up to 64 parallel connections) could exhaust its file descriptors and stall every run's `claude.ai` traffic until the burst cleared, then recover. The daemon now raises its file-descriptor soft limit toward 65536 (capped at the hard limit) at startup, before the proxy accepts connections. ([#439](https://github.com/majorcontext/moat/pull/439))
- Fix the injected agent context misrepresenting network access — previously, the "Moat Environment" instructions file (CLAUDE.md/AGENTS.md) listed grant/rule hosts under "Allowed hosts" regardless of policy, so under the default `permissive` policy (where all outbound traffic is allowed) it read as an egress allowlist restricting the agent to those few hosts. The Network Policy section is now policy-aware: `permissive` states that all outbound access is allowed (surfacing only explicit per-path rules, which still apply), and `strict` is described as the allowlist it actually is. The context also now reflects **Docker/DIND availability**, the resolved **workspace mode** (bind vs. ephemeral `volume`), and **installed tool dependencies** — all previously omitted, which could lead an agent to assume capabilities were absent. ([#431](https://github.com/majorcontext/moat/pull/431))
- Fix `moat logs -f` silently doing nothing — previously, follow mode printed a debug-log line ("not yet implemented", only visible with `--verbose`) and exited 0 as if it had streamed, so `-f` looked like it worked. moat now prints a visible notice that follow mode isn't supported yet and shows the current logs. ([#413](https://github.com/majorcontext/moat/pull/413))
- Fix non-deterministic Dockerfile generation causing spurious image rebuilds — previously, dependency `ENV` lines were emitted in random map order, so the generated Dockerfile changed between runs and missed Docker's layer cache. `ENV` keys are now sorted. ([#413](https://github.com/majorcontext/moat/pull/413))
Expand Down
13 changes: 13 additions & 0 deletions cmd/moat/cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ func runDaemon(_ *cobra.Command, _ []string) error {
daemonDir = filepath.Join(config.GlobalConfigDir(), "proxy")
}

// Raise the file-descriptor limit before the proxy starts accepting. The
// daemon is the single shared egress proxy for every run, so a burst of
// concurrent connections from one container (e.g. `bun install` opens up to
// 64 parallel connections) must not exhaust its FDs and stall every other
// run's traffic. Best-effort: a failure is non-fatal.
if oldSoft, newSoft, ferr := daemon.RaiseFileLimit(); ferr != nil {
log.Warn("could not read daemon file-descriptor limit", "error", ferr)
} else if newSoft > oldSoft {
log.Info("raised daemon file-descriptor limit", "from", oldSoft, "to", newSoft)
} else {
log.Debug("daemon file-descriptor limit unchanged", "soft", oldSoft)
}

// Expose build version to daemon package so the health endpoint can
// report it. This allows detecting version skew between daemon and CLI.
daemon.BuildCommit = commit
Expand Down
69 changes: 69 additions & 0 deletions internal/daemon/filelimit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//go:build unix

package daemon

import "golang.org/x/sys/unix"

// desiredNofile is the soft RLIMIT_NOFILE the proxy daemon targets at startup.
//
// The daemon is the single shared egress proxy for every active run, so a burst
// of concurrent connections from one container must not exhaust its file
// descriptors and stall every other run's traffic. A package install is the
// common trigger: `bun install` opens up to 64 parallel connections (Bun's
// default since v1.1.33), each consuming FDs on both the client and upstream
// sides, and several runs can install at once. The frequent 1024 soft default
// (and macOS's 256) is easily exhausted by that; 65536 gives ample headroom.
const desiredNofile = 65536

// RaiseFileLimit raises this process's RLIMIT_NOFILE soft limit toward
// desiredNofile, capped at the hard limit. It returns the previous soft limit
// and the soft limit in effect afterward (always >= oldSoft). It is a no-op
// (equal values) when the soft limit is already at or above the target.
//
// A limit that cannot be raised is not an error — the process keeps its
// existing, already-in-effect limit and err stays nil. err is non-nil only when
// the current limit cannot be read. Some systems, notably macOS, reject a soft
// limit above kern.maxfilesperproc even when the hard limit is nominally
// higher; when the full target is rejected, the fallback binary-searches the
// highest value the system will accept between the old soft limit and the
// target.
func RaiseFileLimit() (oldSoft, newSoft uint64, err error) {
var lim unix.Rlimit
if err = unix.Getrlimit(unix.RLIMIT_NOFILE, &lim); err != nil {
return 0, 0, err
}
oldSoft = lim.Cur

target := uint64(desiredNofile)
if lim.Max < target { // the hard limit caps the soft limit
target = lim.Max
}
if oldSoft >= target {
return oldSoft, oldSoft, nil // already sufficient
}

// Fast path: the full target is usually accepted.
lim.Cur = target
if unix.Setrlimit(unix.RLIMIT_NOFILE, &lim) == nil {
return oldSoft, target, nil
}

// Fallback: the target was rejected (e.g. macOS caps the soft limit at
// kern.maxfilesperproc, below the hard limit). Binary-search the highest
// value in (oldSoft, target) the system accepts. lo is always known-good
// (oldSoft is currently in effect), hi is always known-bad (a rejected
// value). Each successful Setrlimit leaves the process at that value, so
// once the search narrows, the process's soft limit equals best.
best := oldSoft
lo, hi := oldSoft, target
for hi-lo > 1 {
mid := lo + (hi-lo)/2
lim.Cur = mid
if unix.Setrlimit(unix.RLIMIT_NOFILE, &lim) == nil {
best, lo = mid, mid
} else {
hi = mid
}
}
return oldSoft, best, nil
}
10 changes: 10 additions & 0 deletions internal/daemon/filelimit_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build !unix

package daemon

// RaiseFileLimit is a no-op on platforms without POSIX rlimits. The proxy
// daemon runs on the host (macOS or Linux, both unix), so this stub exists only
// to keep the package building on other GOOS values.
func RaiseFileLimit() (oldSoft, newSoft uint64, err error) {
return 0, 0, nil
}
64 changes: 64 additions & 0 deletions internal/daemon/filelimit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//go:build unix

package daemon

import (
"testing"

"golang.org/x/sys/unix"
)

func currentNofile(t *testing.T) (soft, hard uint64) {
t.Helper()
var lim unix.Rlimit
if err := unix.Getrlimit(unix.RLIMIT_NOFILE, &lim); err != nil {
t.Fatalf("Getrlimit: %v", err)
}
return lim.Cur, lim.Max
}

func TestRaiseFileLimit(t *testing.T) {
beforeSoft, hard := currentNofile(t)

oldSoft, newSoft, err := RaiseFileLimit()
if err != nil {
t.Fatalf("RaiseFileLimit: %v", err)
}
if oldSoft != beforeSoft {
t.Errorf("reported oldSoft %d, want the current soft limit %d", oldSoft, beforeSoft)
}
if newSoft < oldSoft {
t.Errorf("newSoft %d must never be below oldSoft %d (the limit is only ever raised)", newSoft, oldSoft)
}
if newSoft > hard {
t.Errorf("newSoft %d must not exceed the hard limit %d", newSoft, hard)
}
// The process's actual soft limit must reflect the reported new value.
gotSoft, _ := currentNofile(t)
if gotSoft != newSoft {
t.Errorf("process soft limit is %d, want the reported newSoft %d", gotSoft, newSoft)
}
}

// TestRaiseFileLimitNonLowering asserts a second call never reduces the limit —
// it either reaches the target once and no-ops, or (on systems that cap the
// soft limit below the hard limit, e.g. macOS) stays put. A limit that cannot
// be raised further is reported as success, not an error.
func TestRaiseFileLimitNonLowering(t *testing.T) {
_, firstNew, err := RaiseFileLimit()
if err != nil {
t.Fatalf("first RaiseFileLimit: %v", err)
}

_, secondNew, err := RaiseFileLimit()
if err != nil {
t.Fatalf("second RaiseFileLimit: %v", err)
}
if secondNew < firstNew {
t.Errorf("second call lowered the soft limit: %d -> %d", firstNew, secondNew)
}
afterSoft, _ := currentNofile(t)
if afterSoft < firstNew {
t.Errorf("process soft limit dropped after a second call: %d -> %d", firstNew, afterSoft)
}
}
Loading