diff --git a/internal/runtime/bootstrap.go b/internal/runtime/bootstrap.go index bb59d290b..942141f6e 100644 --- a/internal/runtime/bootstrap.go +++ b/internal/runtime/bootstrap.go @@ -1,5 +1,7 @@ package runtime +import "fmt" + // BootstrapInput is the portable contract every runtime needs to provision // agent content into the sandbox. Implementations live outside this package // (runner adapter, tests). @@ -16,3 +18,17 @@ type BootstrapInput interface { SkillDirs() []string PluginDirs() []string } + +// validateAgentNameMatch returns an error when requestedName and +// definitionName are both non-empty and do not match. Both ClaudeRuntime +// and PiRuntime call this shared helper so the mismatch message is defined +// in one place. +func validateAgentNameMatch(requestedName, definitionName string) error { + if requestedName == "" || definitionName == "" { + return nil + } + if definitionName != requestedName { + return fmt.Errorf("agent name mismatch: requested %q but definition declares %q", requestedName, definitionName) + } + return nil +} diff --git a/internal/runtime/claude.go b/internal/runtime/claude.go index 6c43a691a..e23173ea8 100644 --- a/internal/runtime/claude.go +++ b/internal/runtime/claude.go @@ -47,6 +47,20 @@ func (r ClaudeRuntime) Bootstrap(input BootstrapInput) error { return fmt.Errorf("agent path is required") } + // Validate the frontmatter name: field against the requested agent name. + // Claude Code resolves --agent by frontmatter name, not filename, so a + // mismatch means the runtime silently falls back to the default agent, + // producing an unconstrained run (#6764). + if agentName := input.AgentName(); agentName != "" { + if data, readErr := os.ReadFile(agentPath); readErr == nil { + if def, parseErr := parsePiAgent(data); parseErr != nil { + fmt.Fprintf(os.Stderr, "Agent name validation: skipped for %s: %v\n", agentPath, parseErr) + } else if err := validateAgentNameMatch(agentName, def.Name); err != nil { + return err + } + } + } + sandboxName := input.SandboxName() configDir := r.ConfigDir() diff --git a/internal/runtime/claude_test.go b/internal/runtime/claude_test.go index 2ab1f8734..42062bc8b 100644 --- a/internal/runtime/claude_test.go +++ b/internal/runtime/claude_test.go @@ -105,6 +105,92 @@ func TestAgentDestName(t *testing.T) { } } +func TestValidateAgentNameMatch(t *testing.T) { + tests := []struct { + name string + requestedName string + definitionName string + wantErr string // empty means no error expected + }{ + { + name: "matching names pass", + requestedName: "code", + definitionName: "code", + wantErr: "", + }, + { + name: "mismatched names fail", + requestedName: "coder", + definitionName: "code", + wantErr: `agent name mismatch: requested "coder" but definition declares "code"`, + }, + { + name: "empty requested name skips validation", + requestedName: "", + definitionName: "code", + wantErr: "", + }, + { + name: "empty definition name skips validation", + requestedName: "coder", + definitionName: "", + wantErr: "", + }, + { + name: "both empty skips validation", + requestedName: "", + definitionName: "", + wantErr: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateAgentNameMatch(tc.requestedName, tc.definitionName) + if tc.wantErr == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + } + }) + } +} + +func TestBootstrap_AgentNameMismatch(t *testing.T) { + stubDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(stubDir, "openshell"), []byte("#!/bin/sh\nexit 0\n"), 0o755)) + t.Setenv("PATH", stubDir) + + agentFile := filepath.Join(t.TempDir(), "agent.md") + require.NoError(t, os.WriteFile(agentFile, []byte("---\nname: code\n---\n# Code agent"), 0o644)) + + err := ClaudeRuntime{}.Bootstrap(bootstrapInput{ + sandboxName: "test-sandbox", + agentPath: agentFile, + agentName: "coder", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "agent name mismatch") + assert.Contains(t, err.Error(), `"coder"`) + assert.Contains(t, err.Error(), `"code"`) +} + +func TestBootstrap_AgentNameMatch(t *testing.T) { + stubDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(stubDir, "openshell"), []byte("#!/bin/sh\nexit 0\n"), 0o755)) + t.Setenv("PATH", stubDir) + + agentFile := filepath.Join(t.TempDir(), "agent.md") + require.NoError(t, os.WriteFile(agentFile, []byte("---\nname: code\n---\n# Code agent"), 0o644)) + + err := ClaudeRuntime{}.Bootstrap(bootstrapInput{ + sandboxName: "test-sandbox", + agentPath: agentFile, + agentName: "code", + }) + assert.NoError(t, err) +} + func TestBuildRunCommand_Basic(t *testing.T) { cmd := testRunCommand("hello-world", "", "/sandbox/workspace/repo", nil, "") assert.Contains(t, cmd, "cd /sandbox/workspace/repo") diff --git a/internal/runtime/pi_bootstrap.go b/internal/runtime/pi_bootstrap.go index 017767397..233da1f46 100644 --- a/internal/runtime/pi_bootstrap.go +++ b/internal/runtime/pi_bootstrap.go @@ -92,6 +92,12 @@ func (r PiRuntime) Bootstrap(input BootstrapInput) error { if err != nil { return err } + // Fail fast when the harness-configured agent name does not match the + // definition's frontmatter name: — the runtime would silently fall back + // to the default agent, producing an unconstrained run (#6764). + if err := validateAgentNameMatch(input.AgentName(), def.Name); err != nil { + return err + } agentName := input.AgentName() if agentName == "" { agentName = def.Name diff --git a/internal/runtime/pi_bootstrap_test.go b/internal/runtime/pi_bootstrap_test.go index b7192bd5b..31ab34a95 100644 --- a/internal/runtime/pi_bootstrap_test.go +++ b/internal/runtime/pi_bootstrap_test.go @@ -162,6 +162,39 @@ func TestPiRuntimeBootstrap_NoSecurityNoHooks(t *testing.T) { assert.True(t, os.IsNotExist(err), "no hook extension without security config") } +func TestPiRuntimeBootstrap_AgentNameMismatch(t *testing.T) { + work := t.TempDir() + logPath := filepath.Join(work, "openshell.log") + store := filepath.Join(work, "store") + fakeOpenshellPi(t, logPath, store, "/dev/null") + + agentFile := writeAgentFile(t, "---\nname: code\n---\n# Code agent") + err := PiRuntime{}.Bootstrap(bootstrapInput{ + sandboxName: "sb", + agentPath: agentFile, + agentName: "coder", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "agent name mismatch") + assert.Contains(t, err.Error(), `"coder"`) + assert.Contains(t, err.Error(), `"code"`) +} + +func TestPiRuntimeBootstrap_AgentNameMatch(t *testing.T) { + work := t.TempDir() + logPath := filepath.Join(work, "openshell.log") + store := filepath.Join(work, "store") + fakeOpenshellPi(t, logPath, store, "/dev/null") + + agentFile := writeAgentFile(t, "---\nname: triage\n---\n# Triage agent") + err := PiRuntime{}.Bootstrap(bootstrapInput{ + sandboxName: "sb", + agentPath: agentFile, + agentName: "triage", + }) + assert.NoError(t, err) +} + func TestPiRuntimeBootstrap_PreflightFailure(t *testing.T) { binDir := t.TempDir() script := "#!/bin/sh\nfor last; do :; done\ncase \"$last\" in \"pi --version\") echo 'sh: pi: not found' >&2; exit 127 ;; esac\nexit 0\n"