mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
* fix(core): workspace init flow mishandles absolute paths and tilde The conversational workspace init flow had three bugs: 1. looksLikeLocalDir's catch-all matched everything that wasn't a URL, including slash commands like /dir and /workspace. This caused absolute paths (e.g. /Users/foo) to be forwarded to the agent as unknown commands instead of being bound as workspaces. 2. The init flow required two messages to bind a workspace — the first message always showed the hint, even if it was already a valid path or git URL. 3. Bare tilde (~) was not expanded by resolveLocalDirPath, and tilde paths like ~/project were not handled when the message started with / (due to bug #1). Fixes: - Rewrite looksLikeLocalDir to check /word against builtinCommands, correctly distinguishing /dir (command) from /root (path). - Process the first message immediately if it's a valid path or URL. - Handle bare ~ in both looksLikeLocalDir and resolveLocalDirPath. - Use MsgWsBindSuccess for local dir binding instead of MsgWsCloneSuccess. - Replace hardcoded English messages with i18n calls. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(core): tilde paths rejected by workspace init escape check resolveLocalDirPath's escape check used filepath.IsAbs on the original target ("~/joyspace"), which returns false in Go. The tilde-expanded absolute path was then compared against baseDir and rejected as "escaping" the workspace directory. Skip the escape check for tilde-prefixed paths since they expand to absolute paths under the user's home. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
707 lines
23 KiB
Go
707 lines
23 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
type namedTestAgent struct {
|
|
name string
|
|
}
|
|
|
|
func (a *namedTestAgent) Name() string { return a.name }
|
|
func (a *namedTestAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
|
|
return &stubAgentSession{}, nil
|
|
}
|
|
func (a *namedTestAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) { return nil, nil }
|
|
func (a *namedTestAgent) Stop() error { return nil }
|
|
|
|
// mockChannelResolver implements both Platform and ChannelNameResolver.
|
|
type mockChannelResolver struct {
|
|
name string
|
|
names map[string]string
|
|
}
|
|
|
|
func (m *mockChannelResolver) Name() string {
|
|
if m.name != "" {
|
|
return m.name
|
|
}
|
|
return "mock"
|
|
}
|
|
func (m *mockChannelResolver) Start(MessageHandler) error { return nil }
|
|
func (m *mockChannelResolver) Reply(_ context.Context, _ any, _ string) error { return nil }
|
|
func (m *mockChannelResolver) Send(_ context.Context, _ any, _ string) error { return nil }
|
|
func (m *mockChannelResolver) Stop() error { return nil }
|
|
func (m *mockChannelResolver) ResolveChannelName(channelID string) (string, error) {
|
|
if name, ok := m.names[channelID]; ok {
|
|
return name, nil
|
|
}
|
|
return "", fmt.Errorf("unknown channel %s", channelID)
|
|
}
|
|
|
|
func newTestEngineWithMultiWorkspace(t *testing.T, baseDir string) *Engine {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
bindingPath := filepath.Join(tmpDir, "bindings.json")
|
|
e := NewEngine("test", nil, nil, "", LangEnglish)
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
return e
|
|
}
|
|
|
|
func newTestEngineWithMultiWorkspaceAgent(t *testing.T, baseDir string) *Engine {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
bindingPath := filepath.Join(tmpDir, "bindings.json")
|
|
sessionPath := filepath.Join(tmpDir, "sessions.json")
|
|
agentName := "shared-binding-test-agent"
|
|
RegisterAgent(agentName, func(opts map[string]any) (Agent, error) {
|
|
return &namedTestAgent{name: agentName}, nil
|
|
})
|
|
e := NewEngine("test", &namedTestAgent{name: agentName}, nil, sessionPath, LangEnglish)
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
return e
|
|
}
|
|
|
|
func TestMultiWorkspaceResolution_ConventionMatch(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelName := "my-project"
|
|
channelID := "C001"
|
|
|
|
// Create a directory matching the channel name
|
|
if err := os.MkdirAll(filepath.Join(baseDir, channelName), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
p := &mockChannelResolver{names: map[string]string{channelID: channelName}}
|
|
|
|
ws, name, err := e.resolveWorkspace(p, channelID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if name != channelName {
|
|
t.Errorf("expected channel name %q, got %q", channelName, name)
|
|
}
|
|
// resolveWorkspace returns normalizeWorkspacePath'd result; use it for comparison
|
|
expectedWS := normalizeWorkspacePath(filepath.Join(baseDir, channelName))
|
|
if ws != expectedWS {
|
|
t.Errorf("expected workspace %q, got %q", expectedWS, ws)
|
|
}
|
|
|
|
// Verify auto-binding was persisted
|
|
b := e.workspaceBindings.Lookup("project:test", workspaceChannelKey(p.Name(), channelID))
|
|
if b == nil {
|
|
t.Fatal("expected binding to be created by convention match")
|
|
}
|
|
if b.Workspace != expectedWS {
|
|
t.Errorf("binding workspace = %q, want %q", b.Workspace, expectedWS)
|
|
}
|
|
}
|
|
|
|
func TestMultiWorkspaceResolution_NoMatch(t *testing.T) {
|
|
baseDir := t.TempDir() // empty directory — no convention match possible
|
|
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
p := &mockChannelResolver{names: map[string]string{"C002": "nonexistent-project"}}
|
|
|
|
ws, name, err := e.resolveWorkspace(p, "C002")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if ws != "" {
|
|
t.Errorf("expected empty workspace, got %q", ws)
|
|
}
|
|
if name != "nonexistent-project" {
|
|
t.Errorf("expected channel name %q, got %q", "nonexistent-project", name)
|
|
}
|
|
}
|
|
|
|
func TestMultiWorkspaceResolution_ExistingBinding(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelID := "C003"
|
|
channelName := "bound-channel"
|
|
|
|
// Create the workspace directory the binding points to
|
|
wsDir := filepath.Join(baseDir, "some-workspace")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
e.workspaceBindings.Bind("project:test", channelID, channelName, wsDir)
|
|
|
|
// Platform that does NOT know this channel — binding should still work
|
|
p := &mockChannelResolver{names: map[string]string{}}
|
|
|
|
ws, name, err := e.resolveWorkspace(p, channelID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// resolveWorkspace normalizes the path
|
|
expectedWS := normalizeWorkspacePath(wsDir)
|
|
if ws != expectedWS {
|
|
t.Errorf("expected workspace %q, got %q", expectedWS, ws)
|
|
}
|
|
if name != channelName {
|
|
t.Errorf("expected channel name %q, got %q", channelName, name)
|
|
}
|
|
}
|
|
|
|
func TestMultiWorkspaceResolution_SharedBinding(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelID := "C003S"
|
|
channelName := "shared-channel"
|
|
|
|
wsDir := filepath.Join(baseDir, "shared-workspace")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, channelID, channelName, wsDir)
|
|
|
|
p := &mockChannelResolver{names: map[string]string{}}
|
|
|
|
ws, name, err := e.resolveWorkspace(p, channelID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
expectedWS := normalizeWorkspacePath(wsDir)
|
|
if ws != expectedWS {
|
|
t.Errorf("expected workspace %q, got %q", expectedWS, ws)
|
|
}
|
|
if name != channelName {
|
|
t.Errorf("expected channel name %q, got %q", channelName, name)
|
|
}
|
|
}
|
|
|
|
func TestMultiWorkspaceResolution_SharedBindingDoesNotCrossPlatforms(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelID := "C003X"
|
|
|
|
wsDir := filepath.Join(baseDir, "shared-workspace")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, workspaceChannelKey("mock-a", channelID), "shared-channel", wsDir)
|
|
|
|
pA := &mockChannelResolver{name: "mock-a", names: map[string]string{}}
|
|
pB := &mockChannelResolver{name: "mock-b", names: map[string]string{}}
|
|
|
|
ws, _, err := e.resolveWorkspace(pA, channelID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for matching platform: %v", err)
|
|
}
|
|
if ws != normalizeWorkspacePath(wsDir) {
|
|
t.Fatalf("expected shared binding for matching platform, got %q", ws)
|
|
}
|
|
|
|
ws, name, err := e.resolveWorkspace(pB, channelID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for other platform: %v", err)
|
|
}
|
|
if ws != "" || name != "" {
|
|
t.Fatalf("expected no shared binding for other platform, got workspace=%q channelName=%q", ws, name)
|
|
}
|
|
}
|
|
|
|
func TestMultiWorkspaceResolution_MissingDirRemovesBinding(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelID := "C004"
|
|
channelName := "stale-channel"
|
|
missingDir := filepath.Join(baseDir, "deleted-workspace")
|
|
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
e.workspaceBindings.Bind("project:test", channelID, channelName, missingDir)
|
|
|
|
p := &mockChannelResolver{names: map[string]string{}}
|
|
|
|
ws, name, err := e.resolveWorkspace(p, channelID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if ws != "" {
|
|
t.Errorf("expected empty workspace for missing dir, got %q", ws)
|
|
}
|
|
if name != channelName {
|
|
t.Errorf("expected channel name %q, got %q", channelName, name)
|
|
}
|
|
|
|
// Verify binding was removed
|
|
if b := e.workspaceBindings.Lookup("project:test", channelID); b != nil {
|
|
t.Error("expected binding to be removed after missing directory")
|
|
}
|
|
}
|
|
|
|
func TestMultiWorkspaceResolution_MissingDirKeepsSharedBinding(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelID := "C004S"
|
|
channelName := "shared-stale-channel"
|
|
missingDir := filepath.Join(baseDir, "deleted-shared-workspace")
|
|
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, channelID, channelName, missingDir)
|
|
|
|
p := &mockChannelResolver{names: map[string]string{}}
|
|
|
|
ws, name, err := e.resolveWorkspace(p, channelID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if ws != "" {
|
|
t.Errorf("expected empty workspace for missing dir, got %q", ws)
|
|
}
|
|
if name != channelName {
|
|
t.Errorf("expected channel name %q, got %q", channelName, name)
|
|
}
|
|
if b := e.workspaceBindings.Lookup(sharedWorkspaceBindingsKey, channelID); b == nil {
|
|
t.Error("expected shared binding to remain after missing directory")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveKeyForSessionKey_MissingSharedBindingFallsBack(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelID := "C005SM"
|
|
missingDir := filepath.Join(baseDir, "missing-shared-workspace")
|
|
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, channelID, "shared-channel", missingDir)
|
|
|
|
sessionKey := "mock:" + channelID + ":user"
|
|
if got := e.interactiveKeyForSessionKey(sessionKey); got != sessionKey {
|
|
t.Fatalf("interactiveKeyForSessionKey() = %q, want %q", got, sessionKey)
|
|
}
|
|
}
|
|
|
|
func TestInteractiveKeyForSessionKey_SharedBinding(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelID := "C005S"
|
|
wsDir := filepath.Join(baseDir, "shared-workspace")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, channelID, "shared-channel", wsDir)
|
|
|
|
sessionKey := "mock:" + channelID + ":user"
|
|
want := normalizeWorkspacePath(wsDir) + ":" + sessionKey
|
|
if got := e.interactiveKeyForSessionKey(sessionKey); got != want {
|
|
t.Fatalf("interactiveKeyForSessionKey() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSessionContextForKey_MissingSharedBindingFallsBack(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelID := "C006SM"
|
|
missingDir := filepath.Join(baseDir, "missing-shared-workspace")
|
|
|
|
e := newTestEngineWithMultiWorkspaceAgent(t, baseDir)
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, channelID, "shared-channel", missingDir)
|
|
|
|
agent, sessions := e.sessionContextForKey("mock:" + channelID + ":user")
|
|
if agent != e.agent {
|
|
t.Fatal("expected base agent for missing shared binding")
|
|
}
|
|
if sessions != e.sessions {
|
|
t.Fatal("expected base session manager for missing shared binding")
|
|
}
|
|
if got := e.workspacePool.Get(normalizeWorkspacePath(missingDir)); got != nil {
|
|
t.Fatal("did not expect workspace pool entry for missing shared binding")
|
|
}
|
|
}
|
|
|
|
func TestSessionContextForKey_SharedBinding(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
channelID := "C006S"
|
|
wsDir := filepath.Join(baseDir, "shared-workspace")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e := newTestEngineWithMultiWorkspaceAgent(t, baseDir)
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, channelID, "shared-channel", wsDir)
|
|
|
|
agent, sessions := e.sessionContextForKey("mock:" + channelID + ":user")
|
|
if agent == nil {
|
|
t.Fatal("expected workspace agent, got nil")
|
|
}
|
|
if agent == e.agent {
|
|
t.Fatal("expected workspace-specific agent, got base agent")
|
|
}
|
|
if sessions == nil {
|
|
t.Fatal("expected workspace session manager, got nil")
|
|
}
|
|
if sessions == e.sessions {
|
|
t.Fatal("expected workspace session manager, got base session manager")
|
|
}
|
|
if got := e.workspacePool.Get(normalizeWorkspacePath(wsDir)); got == nil || got.agent == nil || got.sessions == nil {
|
|
t.Fatal("expected workspace pool entry to be created for shared binding")
|
|
}
|
|
}
|
|
|
|
func TestExtractRepoName(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"https://github.com/org/my-repo.git", "my-repo"},
|
|
{"https://github.com/org/my-repo", "my-repo"},
|
|
{"git@github.com:org/my-repo.git", "my-repo"},
|
|
{"git@github.com:org/my-repo", "my-repo"},
|
|
{"https://gitlab.com/group/subgroup/project.git", "project"},
|
|
{"ssh://git@github.com/org/repo.git", "repo"},
|
|
{"https://github.com/org/repo", "repo"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := extractRepoName(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("extractRepoName(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLooksLikeGitURL(t *testing.T) {
|
|
valid := []string{
|
|
"https://github.com/org/repo",
|
|
"http://github.com/org/repo",
|
|
"git@github.com:org/repo.git",
|
|
"ssh://git@github.com/org/repo",
|
|
}
|
|
for _, s := range valid {
|
|
if !looksLikeGitURL(s) {
|
|
t.Errorf("looksLikeGitURL(%q) = false, want true", s)
|
|
}
|
|
}
|
|
|
|
invalid := []string{
|
|
"not-a-url",
|
|
"ftp://files.example.com/repo",
|
|
"/local/path/to/repo",
|
|
"",
|
|
"github.com/org/repo",
|
|
}
|
|
for _, s := range invalid {
|
|
if looksLikeGitURL(s) {
|
|
t.Errorf("looksLikeGitURL(%q) = true, want false", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLooksLikeLocalDir(t *testing.T) {
|
|
valid := []string{
|
|
"/absolute/path",
|
|
"/root",
|
|
"/.cache",
|
|
"~",
|
|
"~/home/project",
|
|
"./relative",
|
|
"../parent",
|
|
"my-project",
|
|
"byted-sheet",
|
|
}
|
|
for _, s := range valid {
|
|
if !looksLikeLocalDir(s) {
|
|
t.Errorf("looksLikeLocalDir(%q) = false, want true", s)
|
|
}
|
|
}
|
|
|
|
invalid := []string{
|
|
"",
|
|
"https://github.com/org/repo",
|
|
"git@github.com:org/repo.git",
|
|
"ssh://git@github.com/org/repo",
|
|
"http://example.com",
|
|
"/new",
|
|
"/list",
|
|
"/dir",
|
|
"/help",
|
|
"/workspace bind my-project",
|
|
"/stop",
|
|
}
|
|
for _, s := range invalid {
|
|
if looksLikeLocalDir(s) {
|
|
t.Errorf("looksLikeLocalDir(%q) = true, want false", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceInitFlow_SlashCommandCleansUpExistingFlow(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
e := newTestEngineWithMultiWorkspace(t, baseDir)
|
|
p := &mockChannelResolver{names: map[string]string{"C010": "test-channel"}}
|
|
|
|
channelID := "C010"
|
|
channelKey := workspaceChannelKey(p.Name(), channelID)
|
|
|
|
// Seed a flow in "awaiting_url" state to simulate a prior regular message
|
|
// that triggered the init flow.
|
|
e.initFlowsMu.Lock()
|
|
e.initFlows[channelKey] = &workspaceInitFlow{
|
|
state: "awaiting_url",
|
|
channelName: "test-channel",
|
|
}
|
|
e.initFlowsMu.Unlock()
|
|
|
|
msg := &Message{SessionKey: "mock:" + channelID + ":user1", Content: "/workspace bind my-project"}
|
|
|
|
consumed := e.handleWorkspaceInitFlow(p, msg, "test-channel")
|
|
if consumed {
|
|
t.Fatal("expected handleWorkspaceInitFlow to return false for slash command, but it returned true")
|
|
}
|
|
|
|
// Verify the flow was cleaned up.
|
|
e.initFlowsMu.Lock()
|
|
_, stillExists := e.initFlows[channelKey]
|
|
e.initFlowsMu.Unlock()
|
|
if stillExists {
|
|
t.Error("expected init flow to be deleted after slash command, but it still exists")
|
|
}
|
|
}
|
|
|
|
// runAsTestAgent is a stub agent that reports run_as_user and run_as_env
|
|
// via the interface methods getOrCreateWorkspaceAgent uses for propagation.
|
|
// It exists specifically to test TestMultiWorkspaceAgent_PropagatesRunAsUser
|
|
// below — a regression guard for the bug discovered on 2026-04-08 where
|
|
// multi-workspace mode silently dropped run_as_user between the parent
|
|
// (project-level) agent and per-workspace agent instances, causing all
|
|
// coding sessions to run as the supervisor user instead of the configured
|
|
// target user.
|
|
type runAsTestAgent struct {
|
|
*namedTestAgent
|
|
runAsUser string
|
|
runAsEnv []string
|
|
}
|
|
|
|
func (a *runAsTestAgent) GetRunAsUser() string { return a.runAsUser }
|
|
func (a *runAsTestAgent) GetRunAsEnv() []string {
|
|
if len(a.runAsEnv) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, len(a.runAsEnv))
|
|
copy(out, a.runAsEnv)
|
|
return out
|
|
}
|
|
|
|
// TestMultiWorkspaceAgent_PropagatesRunAsUser is a regression guard for the
|
|
// bug where Engine.getOrCreateWorkspaceAgent constructed per-workspace agents
|
|
// with a fresh opts map that lost the run_as_user and run_as_env fields from
|
|
// the parent project's agent options.
|
|
//
|
|
// Before the fix: per-workspace agents were created with opts containing
|
|
// only work_dir/model/mode. The project-level run_as_user injected into
|
|
// proj.Agent.Options by cmd/cc-connect/main.go was not propagated, so
|
|
// spawned sessions used the legacy (supervisor-user) path despite the
|
|
// preflight saying otherwise.
|
|
//
|
|
// After the fix: getOrCreateWorkspaceAgent asserts on the parent agent's
|
|
// GetRunAsUser() and GetRunAsEnv() interface methods (same pattern as
|
|
// GetModel/GetMode) and copies both into the workspace opts.
|
|
//
|
|
// See docs/spikes/2026-04-08-spike-3-4-results.md and
|
|
// docs/plans/2026-04-08-diderot-master-plan.md in the partseeker/data-worklog
|
|
// repo for the context that motivated this fix.
|
|
func TestMultiWorkspaceAgent_PropagatesRunAsUser(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
workspaceDir := filepath.Join(baseDir, "loader")
|
|
if err := os.MkdirAll(workspaceDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
agentName := "runas-propagation-test-agent"
|
|
var capturedOpts []map[string]any
|
|
RegisterAgent(agentName, func(opts map[string]any) (Agent, error) {
|
|
// Copy the opts map since the caller may reuse it.
|
|
snapshot := make(map[string]any, len(opts))
|
|
for k, v := range opts {
|
|
snapshot[k] = v
|
|
}
|
|
capturedOpts = append(capturedOpts, snapshot)
|
|
return &runAsTestAgent{
|
|
namedTestAgent: &namedTestAgent{name: agentName},
|
|
runAsUser: "partseeker-coder",
|
|
runAsEnv: []string{"CUSTOM_VAR", "ANOTHER_VAR"},
|
|
}, nil
|
|
})
|
|
|
|
// Parent agent: reports run_as_user = "partseeker-coder" and a two-entry
|
|
// run_as_env extension. The per-workspace agent must inherit both.
|
|
parent := &runAsTestAgent{
|
|
namedTestAgent: &namedTestAgent{name: agentName},
|
|
runAsUser: "partseeker-coder",
|
|
runAsEnv: []string{"CUSTOM_VAR", "ANOTHER_VAR"},
|
|
}
|
|
e := NewEngine("test", parent, nil, "", LangEnglish)
|
|
e.SetMultiWorkspace(baseDir, filepath.Join(t.TempDir(), "bindings.json"))
|
|
|
|
// Trigger per-workspace agent creation via the path the production
|
|
// code uses when a message arrives for a resolved workspace.
|
|
_, _, err := e.getOrCreateWorkspaceAgent(workspaceDir)
|
|
if err != nil {
|
|
t.Fatalf("getOrCreateWorkspaceAgent: %v", err)
|
|
}
|
|
|
|
if len(capturedOpts) != 1 {
|
|
t.Fatalf("expected exactly 1 CreateAgent call, got %d", len(capturedOpts))
|
|
}
|
|
opts := capturedOpts[0]
|
|
|
|
gotUser, _ := opts["run_as_user"].(string)
|
|
if gotUser != "partseeker-coder" {
|
|
t.Errorf("run_as_user propagated to workspace opts = %q, want %q", gotUser, "partseeker-coder")
|
|
}
|
|
|
|
gotEnv, _ := opts["run_as_env"].([]string)
|
|
wantEnv := []string{"CUSTOM_VAR", "ANOTHER_VAR"}
|
|
if len(gotEnv) != len(wantEnv) {
|
|
t.Fatalf("run_as_env length = %d, want %d; got = %v", len(gotEnv), len(wantEnv), gotEnv)
|
|
}
|
|
for i := range wantEnv {
|
|
if gotEnv[i] != wantEnv[i] {
|
|
t.Errorf("run_as_env[%d] = %q, want %q", i, gotEnv[i], wantEnv[i])
|
|
}
|
|
}
|
|
|
|
// work_dir is still propagated (regression guard for the existing
|
|
// behaviour the fix must not break).
|
|
if gotDir, _ := opts["work_dir"].(string); gotDir != workspaceDir {
|
|
t.Errorf("work_dir propagated = %q, want %q", gotDir, workspaceDir)
|
|
}
|
|
}
|
|
|
|
// TestMultiWorkspaceAgent_NoPropagationWhenParentHasNoRunAs verifies that
|
|
// workspace agents do not get spurious run_as_user or run_as_env entries
|
|
// when the parent agent does not report them. This is the "isolation not
|
|
// configured" path — the vast majority of cc-connect deployments, which
|
|
// must remain unchanged.
|
|
func TestMultiWorkspaceAgent_NoPropagationWhenParentHasNoRunAs(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
workspaceDir := filepath.Join(baseDir, "loader")
|
|
if err := os.MkdirAll(workspaceDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
agentName := "runas-none-test-agent"
|
|
var capturedOpts []map[string]any
|
|
RegisterAgent(agentName, func(opts map[string]any) (Agent, error) {
|
|
snapshot := make(map[string]any, len(opts))
|
|
for k, v := range opts {
|
|
snapshot[k] = v
|
|
}
|
|
capturedOpts = append(capturedOpts, snapshot)
|
|
return &namedTestAgent{name: agentName}, nil
|
|
})
|
|
|
|
// Parent agent is the plain namedTestAgent with no GetRunAsUser method.
|
|
// The interface assertion in getOrCreateWorkspaceAgent must skip silently.
|
|
parent := &namedTestAgent{name: agentName}
|
|
e := NewEngine("test", parent, nil, "", LangEnglish)
|
|
e.SetMultiWorkspace(baseDir, filepath.Join(t.TempDir(), "bindings.json"))
|
|
|
|
_, _, err := e.getOrCreateWorkspaceAgent(workspaceDir)
|
|
if err != nil {
|
|
t.Fatalf("getOrCreateWorkspaceAgent: %v", err)
|
|
}
|
|
|
|
if len(capturedOpts) != 1 {
|
|
t.Fatalf("expected exactly 1 CreateAgent call, got %d", len(capturedOpts))
|
|
}
|
|
opts := capturedOpts[0]
|
|
|
|
if _, exists := opts["run_as_user"]; exists {
|
|
t.Errorf("run_as_user should not be present in opts when parent has no isolation; got %v", opts["run_as_user"])
|
|
}
|
|
if _, exists := opts["run_as_env"]; exists {
|
|
t.Errorf("run_as_env should not be present in opts when parent has no isolation; got %v", opts["run_as_env"])
|
|
}
|
|
}
|
|
|
|
// TestCommandContextWithWorkspace_BoundChannel exercises the helper that
|
|
// executeSkill / executeCustomCommand use to route slash commands to the
|
|
// per-channel workspace agent. The previous implementation always handed
|
|
// back the global e.agent, so any /bug, /mode, custom command etc. would
|
|
// run in the project-default work_dir even if the user had bound the
|
|
// channel via /workspace bind.
|
|
func TestCommandContextWithWorkspace_BoundChannel(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
wsDir := filepath.Join(baseDir, "bound-workspace")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
e := newTestEngineWithMultiWorkspaceAgent(t, baseDir)
|
|
channelID := "C-bound"
|
|
channelKey := "test-platform:" + channelID
|
|
e.workspaceBindings.Bind("project:test", channelKey, "bound-channel", wsDir)
|
|
|
|
p := &mockChannelResolver{name: "test-platform", names: map[string]string{}}
|
|
msg := &Message{
|
|
Platform: "test-platform",
|
|
ChannelKey: channelID,
|
|
SessionKey: channelKey + ":U-001",
|
|
}
|
|
|
|
agent, sessions, interactiveKey, workspaceDir, err := e.commandContextWithWorkspace(p, msg)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if agent == nil || sessions == nil {
|
|
t.Fatalf("expected non-nil workspace agent/sessions, got agent=%v sessions=%v", agent, sessions)
|
|
}
|
|
if agent == e.agent {
|
|
t.Errorf("agent should be a workspace-scoped agent, but got the global e.agent")
|
|
}
|
|
if sessions == e.sessions {
|
|
t.Errorf("sessions should be a workspace-scoped manager, but got the global e.sessions")
|
|
}
|
|
wantWS := normalizeWorkspacePath(wsDir)
|
|
if workspaceDir != wantWS {
|
|
t.Errorf("workspaceDir = %q, want %q", workspaceDir, wantWS)
|
|
}
|
|
wantKey := wantWS + ":" + msg.SessionKey
|
|
if interactiveKey != wantKey {
|
|
t.Errorf("interactiveKey = %q, want %q", interactiveKey, wantKey)
|
|
}
|
|
}
|
|
|
|
// TestCommandContextWithWorkspace_UnboundChannelFallsBack guards the
|
|
// fallback path: when no binding exists for the channel, the helper must
|
|
// keep returning the global agent/sessions and an empty workspaceDir so
|
|
// behaviour outside multi-workspace bindings is unchanged.
|
|
func TestCommandContextWithWorkspace_UnboundChannelFallsBack(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
e := newTestEngineWithMultiWorkspaceAgent(t, baseDir)
|
|
|
|
p := &mockChannelResolver{name: "test-platform", names: map[string]string{}}
|
|
msg := &Message{
|
|
Platform: "test-platform",
|
|
ChannelKey: "C-unbound",
|
|
SessionKey: "test-platform:C-unbound:U-001",
|
|
}
|
|
|
|
agent, sessions, interactiveKey, workspaceDir, err := e.commandContextWithWorkspace(p, msg)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if agent != e.agent {
|
|
t.Errorf("expected global e.agent when no binding exists, got different agent")
|
|
}
|
|
if sessions != e.sessions {
|
|
t.Errorf("expected global e.sessions when no binding exists, got different manager")
|
|
}
|
|
if workspaceDir != "" {
|
|
t.Errorf("expected empty workspaceDir when unbound, got %q", workspaceDir)
|
|
}
|
|
if interactiveKey != msg.SessionKey {
|
|
t.Errorf("expected interactiveKey to equal sessionKey when unbound, got %q want %q", interactiveKey, msg.SessionKey)
|
|
}
|
|
}
|