fix: validate env-prefixed agent launch binaries
This commit is contained in:
parent
d6863e2ba0
commit
1500782b4c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue