Skip to content

Commit 550a0af

Browse files
committed
cli: auto-route mutating cmds to ctl when a daemon holds the lock; apt-in-userns
- When a lock-taking command (checkout/commit/exec/tag/gc/tournament) can't acquire the repo lock because a daemon/supervise is running, transparently route it through that session's control socket instead of failing with 'another session is active'. Socket discovery: AGENTENV_SOCKET, then <root>/agentenv.sock, then the self-rollback <root>/work/current/.agentenv/ control.sock. So 'agentenv checkout <id>' just works either way. - examples/claude-code: bake APT::Sandbox::User "root" so apt-get works inside the rootless sandbox (userns forbids apt's setgroups privilege-drop to _apt → 'setgroups Operation not permitted').
1 parent 373f41b commit 550a0af

2 files changed

Lines changed: 57 additions & 0 deletions

File tree

examples/claude-code/Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ ENV AGENTENV_FORWARD=ANTHROPIC_*,CLAUDE_*,HTTP_PROXY,HTTPS_PROXY,NO_PROXY
7373
# caches (.cache/.npm/.local, apt lists) — so the snapshot DAG shows only the
7474
# changes Claude makes to your project. Set AGENTENV_IGNORE only to add more.
7575

76+
# Make apt work INSIDE the rootless sandbox. In a user namespace, apt's
77+
# privilege-drop to the _apt user fails ("setgroups ... Operation not
78+
# permitted" — userns disallows setgroups after the uid map). Disabling apt's
79+
# sandbox user avoids the setgroups call. Baked into the image so it's seeded
80+
# into the managed rootfs; Claude Code can then run apt-get install inside.
81+
RUN printf 'APT::Sandbox::User "root";\n' > /etc/apt/apt.conf.d/99-agentenv-no-sandbox
82+
7683
# Register the agentenv MCP server so Claude Code can roll back its OWN
7784
# environment via the agentenv__checkout tool. It talks to the control socket
7885
# INSIDE the sandbox (supervise --self-rollback puts it at /.agentenv/control.sock),

internal/cli/run.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,44 @@ import (
1010
"errors"
1111
"fmt"
1212
"os"
13+
"path/filepath"
1314

1415
"github.com/css521/agentenv/internal/repo"
1516
)
1617

18+
// ctlRoutable reports whether a mutating command has an out-of-band (ctl)
19+
// equivalent it can be transparently routed to when a daemon/supervise holds
20+
// the repo lock. Session-establishing commands (init/supervise/daemon/shell)
21+
// are excluded — they must own the lock themselves.
22+
func ctlRoutable(name string) bool {
23+
switch name {
24+
case "checkout", "commit", "exec", "tag", "gc", "tournament":
25+
return true
26+
}
27+
return false
28+
}
29+
30+
// liveControlSocket returns a control socket to route through when the lock is
31+
// busy, or "" if none is reachable. Checks, in order: AGENTENV_SOCKET, the
32+
// daemon socket (<root>/agentenv.sock), and the self-rollback in-sandbox socket
33+
// (<root>/work/current/.agentenv/control.sock).
34+
func liveControlSocket(root string) string {
35+
candidates := []string{
36+
os.Getenv("AGENTENV_SOCKET"),
37+
filepath.Join(root, "agentenv.sock"),
38+
filepath.Join(root, "work", "current", ".agentenv", "control.sock"),
39+
}
40+
for _, p := range candidates {
41+
if p == "" {
42+
continue
43+
}
44+
if fi, err := os.Stat(p); err == nil && fi.Mode()&os.ModeSocket != 0 {
45+
return p
46+
}
47+
}
48+
return ""
49+
}
50+
1751
// Run parses os.Args[1:], dispatches, and returns the exit code main should
1852
// surface. It does NOT call os.Exit itself — that bypasses deferred Release()
1953
// of the repo lock (and any other defers a handler set up). main() is the only
@@ -80,6 +114,22 @@ func Run(version string) int {
80114
var err error
81115
lock, err = repo.AcquireLock(root)
82116
if err != nil {
117+
// The lock is held by a running daemon/supervise. Rather than fail
118+
// with "another session is active", transparently route this command
119+
// through that session's control socket — so `agentenv checkout <id>`
120+
// just works whether or not a daemon is up. Only commands with an
121+
// out-of-band equivalent are routed; the rest still report the error.
122+
if sock := liveControlSocket(root); sock != "" && ctlRoutable(name) {
123+
if e := cmdCtl(append([]string{"--socket", sock, name}, rest...)); e != nil {
124+
var ee ExitError
125+
if errors.As(e, &ee) {
126+
return int(ee)
127+
}
128+
fmt.Fprintln(os.Stderr, "error:", e)
129+
return 1
130+
}
131+
return 0
132+
}
83133
fmt.Fprintln(os.Stderr, "error:", err)
84134
return 1
85135
}

0 commit comments

Comments
 (0)