diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 39e591614..bb7a6ff2a 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -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 diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 3685b9be7..2ccaaea2b 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -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")