agent-orchestrator/backend/internal/session_manager/provision_test.go

178 lines
5.8 KiB
Go

package sessionmanager
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
func TestSpawnEnvProjectVarsCannotOverrideInternal(t *testing.T) {
env := spawnEnv("mer-1", "mer", "issue-9", "/data", map[string]string{
"FOO": "bar",
EnvSessionID: "hacked", // a project must not override AO-internal vars
EnvProjectID: "hacked",
})
if env["FOO"] != "bar" {
t.Fatalf("FOO = %q, want bar", env["FOO"])
}
if env[EnvSessionID] != "mer-1" {
t.Fatalf("AO_SESSION_ID = %q, want mer-1 (internal wins)", env[EnvSessionID])
}
if env[EnvProjectID] != "mer" {
t.Fatalf("AO_PROJECT_ID = %q, want mer (internal wins)", env[EnvProjectID])
}
}
func TestHookPATH(t *testing.T) {
sep := string(os.PathListSeparator)
daemonExe := filepath.Join("/opt", "aod", "ao")
daemonDir := filepath.Dir(daemonExe)
exeOK := func() (string, error) { return daemonExe, nil }
cases := []struct {
name string
executable func() (string, error)
daemonPATH string
projectEnv map[string]string
want string
wantErr bool
}{
{
name: "prepends daemon dir to inherited PATH",
executable: exeOK,
daemonPATH: "/usr/bin" + sep + "/bin",
want: daemonDir + sep + "/usr/bin" + sep + "/bin",
},
{
name: "project PATH override is the base",
executable: exeOK,
daemonPATH: "/usr/bin",
projectEnv: map[string]string{"PATH": "/proj/bin"},
want: daemonDir + sep + "/proj/bin",
},
{
name: "empty base PATH yields the daemon dir alone",
executable: exeOK,
want: daemonDir,
},
{
name: "unresolvable executable fails",
executable: func() (string, error) { return "", errors.New("no exe") },
daemonPATH: "/usr/bin",
wantErr: true,
},
{
// A daemon binary not named "ao" cannot anchor `ao` resolution by
// having its directory prepended, so the pin must be refused.
name: "executable not named ao fails",
executable: func() (string, error) { return filepath.Join("/opt", "aod", "ao-daemon"), nil },
daemonPATH: "/usr/bin",
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
getenv := func(key string) string {
if key == "PATH" {
return tc.daemonPATH
}
return ""
}
got, err := HookPATH(tc.executable, getenv, tc.projectEnv)
if tc.wantErr {
if err == nil {
t.Fatalf("HookPATH = %q, want error", got)
}
return
}
if err != nil {
t.Fatalf("HookPATH: %v", err)
}
if got != tc.want {
t.Fatalf("HookPATH = %q, want %q", got, tc.want)
}
})
}
}
func TestEffectiveHarnessAndAgentConfig(t *testing.T) {
cfg := domain.ProjectConfig{
AgentConfig: domain.AgentConfig{Model: "base", Permissions: domain.PermissionModeAuto},
Worker: domain.RoleOverride{Harness: domain.HarnessCodex, AgentConfig: domain.AgentConfig{Model: "worker"}},
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
}
// Explicit harness always wins.
if h := effectiveHarness(domain.HarnessAider, domain.KindWorker, cfg); h != domain.HarnessAider {
t.Fatalf("explicit harness = %q, want aider", h)
}
// Empty harness falls back to the role override per kind.
if h := effectiveHarness("", domain.KindWorker, cfg); h != domain.HarnessCodex {
t.Fatalf("worker harness = %q, want codex", h)
}
if h := effectiveHarness("", domain.KindOrchestrator, cfg); h != domain.HarnessClaudeCode {
t.Fatalf("orchestrator harness = %q, want claude-code", h)
}
// Role override merges over the base agent config (set fields win; unset keep base).
got := effectiveAgentConfig(domain.KindWorker, cfg)
if got.Model != "worker" || got.Permissions != domain.PermissionModeAuto {
t.Fatalf("merged worker config = %#v, want model=worker permissions=auto", got)
}
// Orchestrator has no agent-config override, so the base config is used as-is.
if got := effectiveAgentConfig(domain.KindOrchestrator, cfg); got.Model != "base" {
t.Fatalf("orchestrator config = %#v, want base", got)
}
}
func TestApplySymlinks(t *testing.T) {
project := t.TempDir()
workspace := t.TempDir()
if err := os.WriteFile(filepath.Join(project, ".env"), []byte("X=1"), 0o644); err != nil {
t.Fatal(err)
}
// A present source is linked; a missing source is skipped, not an error.
if err := applySymlinks(project, workspace, []string{".env", "missing.txt"}); err != nil {
t.Fatalf("applySymlinks: %v", err)
}
target := filepath.Join(workspace, ".env")
if data, err := os.ReadFile(target); err != nil || string(data) != "X=1" {
t.Fatalf("symlinked .env = %q err=%v", data, err)
}
if _, err := os.Lstat(filepath.Join(workspace, "missing.txt")); !os.IsNotExist(err) {
t.Fatal("missing source should not have been linked")
}
}
func TestApplySymlinksRejectsParentTraversal(t *testing.T) {
project := t.TempDir()
workspace := t.TempDir()
// A "..", "/" or "../" segment escapes the project tree and must be refused
// before any stat/link runs, so a project config cannot link in arbitrary
// host files.
for _, bad := range []string{"../escape", "/etc/passwd", "a/../../b", ".."} {
if err := applySymlinks(project, workspace, []string{bad}); err == nil {
t.Fatalf("applySymlinks(%q) accepted an unsafe path", bad)
}
}
}
func TestRunPostCreate(t *testing.T) {
workspace := t.TempDir()
if err := runPostCreate(context.Background(), workspace, []string{"echo hi > out.txt"}); err != nil {
t.Fatalf("runPostCreate: %v", err)
}
if _, err := os.Stat(filepath.Join(workspace, "out.txt")); err != nil {
t.Fatalf("post-create command did not run in workspace: %v", err)
}
// A failing command surfaces an error.
if err := runPostCreate(context.Background(), workspace, []string{"exit 3"}); err == nil {
t.Fatal("expected error from failing post-create command")
}
}