Fix "Unable to attach mayor" timeout caused by claude being installed as a shell alias rather than in PATH. Non-interactive shells spawned by tmux cannot resolve aliases, causing the session to exit immediately. Changes: - Add resolveClaudePath() to find claude at ~/.claude/local/claude - Apply path resolution in RuntimeConfigFromPreset() for claude preset - Make hasClaudeChild() recursive (now hasClaudeDescendant()) to search entire process subtree as defensive improvement - Update fillRuntimeDefaults() to use DefaultRuntimeConfig() for consistent path resolution Fixes https://github.com/steveyegge/gastown/issues/703 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,12 @@ func skipIfAgentBinaryMissing(t *testing.T, agents ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
// isClaudeCommand checks if a command is claude (either "claude" or a path ending in "/claude").
|
||||
// This handles the case where resolveClaudePath returns the full path to the claude binary.
|
||||
func isClaudeCommand(cmd string) bool {
|
||||
return cmd == "claude" || strings.HasSuffix(cmd, "/claude")
|
||||
}
|
||||
|
||||
func TestTownConfigRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
@@ -821,8 +827,8 @@ func TestRuntimeConfigDefaults(t *testing.T) {
|
||||
if rc.Provider != "claude" {
|
||||
t.Errorf("Provider = %q, want %q", rc.Provider, "claude")
|
||||
}
|
||||
if rc.Command != "claude" {
|
||||
t.Errorf("Command = %q, want %q", rc.Command, "claude")
|
||||
if !isClaudeCommand(rc.Command) {
|
||||
t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command)
|
||||
}
|
||||
if len(rc.Args) != 1 || rc.Args[0] != "--dangerously-skip-permissions" {
|
||||
t.Errorf("Args = %v, want [--dangerously-skip-permissions]", rc.Args)
|
||||
@@ -835,42 +841,58 @@ func TestRuntimeConfigDefaults(t *testing.T) {
|
||||
func TestRuntimeConfigBuildCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
rc *RuntimeConfig
|
||||
want string
|
||||
name string
|
||||
rc *RuntimeConfig
|
||||
wantContains []string // Parts the command should contain
|
||||
isClaudeCmd bool // Whether command should be claude (or path to claude)
|
||||
}{
|
||||
{
|
||||
name: "nil config uses defaults",
|
||||
rc: nil,
|
||||
want: "claude --dangerously-skip-permissions",
|
||||
name: "nil config uses defaults",
|
||||
rc: nil,
|
||||
wantContains: []string{"--dangerously-skip-permissions"},
|
||||
isClaudeCmd: true,
|
||||
},
|
||||
{
|
||||
name: "default config",
|
||||
rc: DefaultRuntimeConfig(),
|
||||
want: "claude --dangerously-skip-permissions",
|
||||
name: "default config",
|
||||
rc: DefaultRuntimeConfig(),
|
||||
wantContains: []string{"--dangerously-skip-permissions"},
|
||||
isClaudeCmd: true,
|
||||
},
|
||||
{
|
||||
name: "custom command",
|
||||
rc: &RuntimeConfig{Command: "aider", Args: []string{"--no-git"}},
|
||||
want: "aider --no-git",
|
||||
name: "custom command",
|
||||
rc: &RuntimeConfig{Command: "aider", Args: []string{"--no-git"}},
|
||||
wantContains: []string{"aider", "--no-git"},
|
||||
isClaudeCmd: false,
|
||||
},
|
||||
{
|
||||
name: "multiple args",
|
||||
rc: &RuntimeConfig{Command: "claude", Args: []string{"--model", "opus", "--no-confirm"}},
|
||||
want: "claude --model opus --no-confirm",
|
||||
name: "multiple args",
|
||||
rc: &RuntimeConfig{Command: "claude", Args: []string{"--model", "opus", "--no-confirm"}},
|
||||
wantContains: []string{"--model", "opus", "--no-confirm"},
|
||||
isClaudeCmd: true,
|
||||
},
|
||||
{
|
||||
name: "empty command uses default",
|
||||
rc: &RuntimeConfig{Command: "", Args: nil},
|
||||
want: "claude --dangerously-skip-permissions",
|
||||
name: "empty command uses default",
|
||||
rc: &RuntimeConfig{Command: "", Args: nil},
|
||||
wantContains: []string{"--dangerously-skip-permissions"},
|
||||
isClaudeCmd: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.rc.BuildCommand()
|
||||
if got != tt.want {
|
||||
t.Errorf("BuildCommand() = %q, want %q", got, tt.want)
|
||||
// Check command contains expected parts
|
||||
for _, part := range tt.wantContains {
|
||||
if !strings.Contains(got, part) {
|
||||
t.Errorf("BuildCommand() = %q, should contain %q", got, part)
|
||||
}
|
||||
}
|
||||
// Check if command starts with claude (or path to claude)
|
||||
if tt.isClaudeCmd {
|
||||
parts := strings.Fields(got)
|
||||
if len(parts) > 0 && !isClaudeCommand(parts[0]) {
|
||||
t.Errorf("BuildCommand() = %q, command should be claude or path to claude", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -879,48 +901,64 @@ func TestRuntimeConfigBuildCommand(t *testing.T) {
|
||||
func TestRuntimeConfigBuildCommandWithPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
rc *RuntimeConfig
|
||||
prompt string
|
||||
want string
|
||||
name string
|
||||
rc *RuntimeConfig
|
||||
prompt string
|
||||
wantContains []string // Parts the command should contain
|
||||
isClaudeCmd bool // Whether command should be claude (or path to claude)
|
||||
}{
|
||||
{
|
||||
name: "no prompt",
|
||||
rc: DefaultRuntimeConfig(),
|
||||
prompt: "",
|
||||
want: "claude --dangerously-skip-permissions",
|
||||
name: "no prompt",
|
||||
rc: DefaultRuntimeConfig(),
|
||||
prompt: "",
|
||||
wantContains: []string{"--dangerously-skip-permissions"},
|
||||
isClaudeCmd: true,
|
||||
},
|
||||
{
|
||||
name: "with prompt",
|
||||
rc: DefaultRuntimeConfig(),
|
||||
prompt: "gt prime",
|
||||
want: `claude --dangerously-skip-permissions "gt prime"`,
|
||||
name: "with prompt",
|
||||
rc: DefaultRuntimeConfig(),
|
||||
prompt: "gt prime",
|
||||
wantContains: []string{"--dangerously-skip-permissions", `"gt prime"`},
|
||||
isClaudeCmd: true,
|
||||
},
|
||||
{
|
||||
name: "prompt with quotes",
|
||||
rc: DefaultRuntimeConfig(),
|
||||
prompt: `Hello "world"`,
|
||||
want: `claude --dangerously-skip-permissions "Hello \"world\""`,
|
||||
name: "prompt with quotes",
|
||||
rc: DefaultRuntimeConfig(),
|
||||
prompt: `Hello "world"`,
|
||||
wantContains: []string{"--dangerously-skip-permissions", `"Hello \"world\""`},
|
||||
isClaudeCmd: true,
|
||||
},
|
||||
{
|
||||
name: "config initial prompt used if no override",
|
||||
rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"},
|
||||
prompt: "",
|
||||
want: `aider "/help"`,
|
||||
name: "config initial prompt used if no override",
|
||||
rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"},
|
||||
prompt: "",
|
||||
wantContains: []string{"aider", `"/help"`},
|
||||
isClaudeCmd: false,
|
||||
},
|
||||
{
|
||||
name: "override takes precedence over config",
|
||||
rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"},
|
||||
prompt: "custom prompt",
|
||||
want: `aider "custom prompt"`,
|
||||
name: "override takes precedence over config",
|
||||
rc: &RuntimeConfig{Command: "aider", Args: []string{}, InitialPrompt: "/help"},
|
||||
prompt: "custom prompt",
|
||||
wantContains: []string{"aider", `"custom prompt"`},
|
||||
isClaudeCmd: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.rc.BuildCommandWithPrompt(tt.prompt)
|
||||
if got != tt.want {
|
||||
t.Errorf("BuildCommandWithPrompt(%q) = %q, want %q", tt.prompt, got, tt.want)
|
||||
// Check command contains expected parts
|
||||
for _, part := range tt.wantContains {
|
||||
if !strings.Contains(got, part) {
|
||||
t.Errorf("BuildCommandWithPrompt(%q) = %q, should contain %q", tt.prompt, got, part)
|
||||
}
|
||||
}
|
||||
// Check if command starts with claude (or path to claude)
|
||||
if tt.isClaudeCmd {
|
||||
parts := strings.Fields(got)
|
||||
if len(parts) > 0 && !isClaudeCommand(parts[0]) {
|
||||
t.Errorf("BuildCommandWithPrompt(%q) = %q, command should be claude or path to claude", tt.prompt, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1051,11 +1089,13 @@ func TestResolveAgentConfigWithOverride(t *testing.T) {
|
||||
if name != "claude-haiku" {
|
||||
t.Fatalf("name = %q, want %q", name, "claude-haiku")
|
||||
}
|
||||
if rc.Command != "claude" {
|
||||
t.Fatalf("rc.Command = %q, want %q", rc.Command, "claude")
|
||||
if !isClaudeCommand(rc.Command) {
|
||||
t.Fatalf("rc.Command = %q, want claude or path ending in /claude", rc.Command)
|
||||
}
|
||||
if got := rc.BuildCommand(); got != "claude --model haiku --dangerously-skip-permissions" {
|
||||
t.Fatalf("BuildCommand() = %q, want %q", got, "claude --model haiku --dangerously-skip-permissions")
|
||||
got := rc.BuildCommand()
|
||||
// Check command includes expected flags (path to claude may vary)
|
||||
if !strings.Contains(got, "--model haiku") || !strings.Contains(got, "--dangerously-skip-permissions") {
|
||||
t.Fatalf("BuildCommand() = %q, want command with --model haiku and --dangerously-skip-permissions", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1407,8 +1447,9 @@ func TestResolveRoleAgentConfig_FallsBackOnInvalidAgent(t *testing.T) {
|
||||
|
||||
// Should fall back to default (claude) when agent is invalid
|
||||
rc := ResolveRoleAgentConfig(constants.RoleRefinery, townRoot, rigPath)
|
||||
if rc.Command != "claude" {
|
||||
t.Errorf("expected fallback to claude, got: %s", rc.Command)
|
||||
// Command can be "claude" or full path to claude
|
||||
if rc.Command != "claude" && !strings.HasSuffix(rc.Command, "/claude") {
|
||||
t.Errorf("expected fallback to claude or path ending in /claude, got: %s", rc.Command)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1490,8 +1531,8 @@ func TestLoadRuntimeConfigFallsBackToDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Non-existent path should use defaults
|
||||
rc := LoadRuntimeConfig("/nonexistent/path")
|
||||
if rc.Command != "claude" {
|
||||
t.Errorf("Command = %q, want %q (default)", rc.Command, "claude")
|
||||
if !isClaudeCommand(rc.Command) {
|
||||
t.Errorf("Command = %q, want claude or path ending in /claude (default)", rc.Command)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1964,7 +2005,12 @@ func TestLookupAgentConfigWithRigSettings(t *testing.T) {
|
||||
t.Errorf("lookupAgentConfig(%s) returned nil", tt.name)
|
||||
}
|
||||
|
||||
if rc.Command != tt.expectedCommand {
|
||||
// For claude commands, allow either "claude" or path ending in /claude
|
||||
if tt.expectedCommand == "claude" {
|
||||
if !isClaudeCommand(rc.Command) {
|
||||
t.Errorf("lookupAgentConfig(%s).Command = %s, want claude or path ending in /claude", tt.name, rc.Command)
|
||||
}
|
||||
} else if rc.Command != tt.expectedCommand {
|
||||
t.Errorf("lookupAgentConfig(%s).Command = %s, want %s", tt.name, rc.Command, tt.expectedCommand)
|
||||
}
|
||||
})
|
||||
@@ -2008,8 +2054,8 @@ func TestResolveRoleAgentConfig(t *testing.T) {
|
||||
t.Run("rig RoleAgents overrides town RoleAgents", func(t *testing.T) {
|
||||
rc := ResolveRoleAgentConfig("witness", townRoot, rigPath)
|
||||
// Should get claude-haiku from rig's RoleAgents
|
||||
if rc.Command != "claude" {
|
||||
t.Errorf("Command = %q, want %q", rc.Command, "claude")
|
||||
if !isClaudeCommand(rc.Command) {
|
||||
t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command)
|
||||
}
|
||||
cmd := rc.BuildCommand()
|
||||
if !strings.Contains(cmd, "--model haiku") {
|
||||
@@ -2035,9 +2081,9 @@ func TestResolveRoleAgentConfig(t *testing.T) {
|
||||
|
||||
t.Run("town-level role (no rigPath) uses town RoleAgents", func(t *testing.T) {
|
||||
rc := ResolveRoleAgentConfig("mayor", townRoot, "")
|
||||
// mayor is in town's RoleAgents
|
||||
if rc.Command != "claude" {
|
||||
t.Errorf("Command = %q, want %q", rc.Command, "claude")
|
||||
// mayor is in town's RoleAgents - command can be "claude" or full path to claude
|
||||
if rc.Command != "claude" && !strings.HasSuffix(rc.Command, "/claude") {
|
||||
t.Errorf("Command = %q, want claude or path ending in /claude", rc.Command)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user