fix: validate env-prefixed agent launch binaries

This commit is contained in:
whoisasx 2026-07-04 20:34:01 +05:30
parent d6863e2ba0
commit 1500782b4c
2 changed files with 130 additions and 2 deletions

View File

@ -1427,18 +1427,39 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
// lookPath (exec.LookPath in prod) before any runtime work happens. Adapters
// that can't resolve their binary now return ports.ErrAgentBinaryNotFound from
// GetLaunchCommand directly; this guard is a defense-in-depth for adapters
// that return an argv[0] like "claude" without verifying.
// that return an argv[0] like "claude" without verifying. Some adapters prefix
// their command with `env KEY=value`; in that case validate the first real
// executable after the environment assignments.
func (m *Manager) validateAgentBinary(argv []string) error {
if len(argv) == 0 {
return fmt.Errorf("agent: empty launch argv: %w", ports.ErrAgentBinaryNotFound)
}
bin := argv[0]
bin, ok := launchBinary(argv)
if !ok {
return fmt.Errorf("agent: launch argv missing binary: %w", ports.ErrAgentBinaryNotFound)
}
if _, err := m.lookPath(bin); err != nil {
return fmt.Errorf("agent binary %q: %w", bin, ports.ErrAgentBinaryNotFound)
}
return nil
}
func launchBinary(argv []string) (string, bool) {
if len(argv) == 0 {
return "", false
}
if filepath.Base(argv[0]) != "env" {
return argv[0], true
}
for _, arg := range argv[1:] {
if strings.Contains(arg, "=") {
continue
}
return arg, true
}
return "", false
}
func (m *Manager) validateRuntimePrerequisites() error {
if runtime.GOOS == "windows" {
return nil

View File

@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
@ -202,6 +203,15 @@ func (fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionIn
return ports.SessionInfo{}, false, nil
}
type launchArgvAgent struct {
fakeAgent
argv []string
}
func (a launchArgvAgent) GetLaunchCommand(context.Context, ports.LaunchConfig) ([]string, error) {
return a.argv, nil
}
// fakeAgents resolves every harness to the same fakeAgent.
type fakeAgents struct{}
@ -1452,6 +1462,103 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
requireNoPromptDir(t, dataDir, "mer-1")
}
func TestSpawn_ValidatesBinaryAfterEnvPrefix(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
lookedUp := []string{}
lookPath := func(name string) (string, error) {
lookedUp = append(lookedUp, name)
switch name {
case "tmux":
return "/bin/tmux", nil
case "opencode":
return "/usr/local/bin/opencode", nil
default:
return "", fmt.Errorf("exec: %q: not found", name)
}
}
agent := launchArgvAgent{argv: []string{"env", "OPENCODE_CONFIG=/tmp/ao/opencode.json", "opencode", "--agent", "ao-mer-1"}}
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
t.Fatalf("Spawn: %v", err)
}
if !reflect.DeepEqual(lookedUp, []string{"tmux", "opencode"}) {
t.Fatalf("lookups = %#v, want tmux then opencode", lookedUp)
}
if rt.created != 1 {
t.Fatalf("runtime.Create calls = %d, want 1", rt.created)
}
if !reflect.DeepEqual(rt.lastCfg.Argv, agent.argv) {
t.Fatalf("runtime argv = %#v, want original argv %#v", rt.lastCfg.Argv, agent.argv)
}
}
func TestSpawn_RejectsMissingBinaryAfterEnvPrefix(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
lookedUp := []string{}
lookPath := func(name string) (string, error) {
lookedUp = append(lookedUp, name)
if name == "tmux" {
return "/bin/tmux", nil
}
return "", fmt.Errorf("exec: %q: not found", name)
}
agent := launchArgvAgent{argv: []string{"env", "OPENCODE_CONFIG=/tmp/ao/opencode.json", "opencode", "--agent", "ao-mer-1"}}
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err)
}
if !reflect.DeepEqual(lookedUp, []string{"tmux", "opencode"}) {
t.Fatalf("lookups = %#v, want tmux then opencode", lookedUp)
}
if rt.created != 0 {
t.Fatal("runtime.Create must NOT run when the env-prefixed agent binary is missing")
}
if ws.destroyed != 1 {
t.Fatal("workspace must be torn down when the pre-launch binary check fails")
}
if rec, present := st.sessions["mer-1"]; present {
t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec)
}
}
func TestSpawn_RejectsEnvPrefixWithoutBinary(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
agent := launchArgvAgent{argv: []string{"env", "OPENCODE_CONFIG=/tmp/ao/opencode.json"}}
m := New(Deps{
Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st,
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
LookPath: func(name string) (string, error) {
if name == "tmux" {
return "/bin/tmux", nil
}
return "/bin/" + name, nil
},
})
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err)
}
if rt.created != 0 {
t.Fatal("runtime.Create must NOT run when env-prefixed argv has no binary")
}
if ws.destroyed != 1 {
t.Fatal("workspace must be torn down when env-prefixed argv has no binary")
}
}
func TestSpawn_RejectsMissingTmuxBeforeSessionRow(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows uses ConPTY, not tmux")