package acp import ( "context" "os" "path/filepath" "runtime" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" ) // cfgRepoStub 只实现 ListConfigFiles;其余方法走嵌入的 nil 接口(调用即 panic)。 type cfgRepoStub struct { Repository files []*ConfigFile } func (s cfgRepoStub) ListConfigFiles(context.Context, uuid.UUID) ([]*ConfigFile, error) { return s.files, nil } func TestMaterializeAgentHome(t *testing.T) { t.Parallel() enc, err := crypto.NewEncryptor([]byte("0123456789abcdef0123456789abcdef")) require.NoError(t, err) kind := &AgentKind{ID: uuid.New(), ClientType: ClientCodex} content := []byte(`model = "gpt-5.5"`) encrypted, err := enc.Encrypt(content) require.NoError(t, err) root := t.TempDir() s := &Supervisor{ repo: cfgRepoStub{files: []*ConfigFile{{AgentKindID: kind.ID, RelPath: ".codex/config.toml", EncryptedContent: encrypted}}}, crypto: enc, cfg: SupervisorConfig{AgentHomesDir: root}, } sess := &Session{ID: uuid.New(), UserID: uuid.New()} home, err := s.materializeAgentHome(context.Background(), sess, kind) require.NoError(t, err) // per-user home:// assert.Equal(t, filepath.Join(root, sess.UserID.String(), kind.ID.String()), home) got, err := os.ReadFile(filepath.Join(home, ".codex", "config.toml")) require.NoError(t, err) assert.Equal(t, content, got) // 二次物化幂等(覆写) _, err = s.materializeAgentHome(context.Background(), sess, kind) require.NoError(t, err) } func TestMaterializeAgentHome_PerUserIsolation(t *testing.T) { t.Parallel() enc, err := crypto.NewEncryptor([]byte("0123456789abcdef0123456789abcdef")) require.NoError(t, err) kind := &AgentKind{ID: uuid.New(), ClientType: ClientCodex} encrypted, err := enc.Encrypt([]byte("x = 1")) require.NoError(t, err) root := t.TempDir() s := &Supervisor{ repo: cfgRepoStub{files: []*ConfigFile{{AgentKindID: kind.ID, RelPath: ".codex/config.toml", EncryptedContent: encrypted}}}, crypto: enc, cfg: SupervisorConfig{AgentHomesDir: root}, } // 同一 agent-kind、两个不同用户 → 两个互不相同的 home 目录。 sessA := &Session{ID: uuid.New(), UserID: uuid.New()} sessB := &Session{ID: uuid.New(), UserID: uuid.New()} homeA, err := s.materializeAgentHome(context.Background(), sessA, kind) require.NoError(t, err) homeB, err := s.materializeAgentHome(context.Background(), sessB, kind) require.NoError(t, err) assert.NotEqual(t, homeA, homeB) // 两个 home 各自物化了配置文件。 _, err = os.Stat(filepath.Join(homeA, ".codex", "config.toml")) require.NoError(t, err) _, err = os.Stat(filepath.Join(homeB, ".codex", "config.toml")) require.NoError(t, err) } func TestMaterializeAgentHome_RejectsEscape(t *testing.T) { t.Parallel() enc, err := crypto.NewEncryptor([]byte("0123456789abcdef0123456789abcdef")) require.NoError(t, err) encrypted, err := enc.Encrypt([]byte("x")) require.NoError(t, err) kind := &AgentKind{ID: uuid.New(), ClientType: ClientGeneric} s := &Supervisor{ repo: cfgRepoStub{files: []*ConfigFile{{AgentKindID: kind.ID, RelPath: "../escape.txt", EncryptedContent: encrypted}}}, crypto: enc, cfg: SupervisorConfig{AgentHomesDir: t.TempDir()}, } sess := &Session{ID: uuid.New(), UserID: uuid.New()} _, err = s.materializeAgentHome(context.Background(), sess, kind) require.Error(t, err) assert.Contains(t, err.Error(), "escapes agent home") } func TestAgentHomeEnv_ByClientType(t *testing.T) { t.Parallel() home := filepath.Join(string(filepath.Separator), "data", "agent-homes", "x") env := agentHomeEnv(home, ClientClaudeCode) assert.Equal(t, home, env["HOME"]) assert.Equal(t, filepath.Join(home, ".claude"), env["CLAUDE_CONFIG_DIR"]) env = agentHomeEnv(home, ClientCodex) assert.Equal(t, filepath.Join(home, ".codex"), env["CODEX_HOME"]) env = agentHomeEnv(home, ClientGemini) assert.Equal(t, home, env["GEMINI_CLI_HOME"]) env = agentHomeEnv(home, ClientGeneric) assert.Equal(t, map[string]string{"HOME": home}, stripWinEnv(env)) if runtime.GOOS == "windows" { assert.Equal(t, home, agentHomeEnv(home, ClientGeneric)["USERPROFILE"]) } } // stripWinEnv 去掉平台相关的 USERPROFILE,便于跨平台断言。 func stripWinEnv(env map[string]string) map[string]string { out := map[string]string{} for k, v := range env { if k == "USERPROFILE" { continue } out[k] = v } return out }