Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions backend/internal/adapters/agent/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

Expand Down Expand Up @@ -184,6 +185,9 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
cmd = append(cmd, "--append-system-prompt", systemPrompt)
}

appendMCPFlags(&cmd, cfg.Config.MCP)
appendPluginFlags(&cmd, cfg.Config.PluginDirs)

if cfg.Prompt != "" {
cmd = append(cmd, "--", cfg.Prompt)
}
Expand Down Expand Up @@ -228,6 +232,12 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
if err := ctx.Err(); err != nil {
return nil, false, err
}
// Defense-in-depth, symmetric with GetLaunchCommand: the config was
// validated on write, but re-check here so a config mutated later (by a
// bug or a different code path) is caught at restore too, not only launch.
if err := cfg.Config.Validate(); err != nil {
return nil, false, fmt.Errorf("claude-code: %w", err)
}

sessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
if sessionID == "" && cfg.Session.ID != "" {
Expand Down Expand Up @@ -256,6 +266,11 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
// re-appended or a restored orchestrator loses its role.
cmd = append(cmd, "--append-system-prompt", systemPrompt)
}
// MCP/plugin flags are also rebuilt from flags on resume (they are not part
// of the transcript), so re-apply them or a restored worker loses its scoped
// MCP set and plugins.
appendMCPFlags(&cmd, cfg.Config.MCP)
appendPluginFlags(&cmd, cfg.Config.PluginDirs)
cmd = append(cmd, "--resume", sessionID)
return cmd, true, nil
}
Expand Down Expand Up @@ -454,6 +469,49 @@ func appendToolFlags(cmd *[]string, allowed, disallowed []string) {
}
}

// appendMCPFlags emits claude-code's per-session MCP flags. Each MCPConfig
// entry is passed to the repeatable --mcp-config as-is (a JSON string or a path
// to a JSON file — both accepted by the CLI). Strict adds --strict-mcp-config
// so the session ignores every other MCP source, isolating the worker. A nil
// MCPConfig emits nothing, so an unset config inherits the global MCP set as
// before. Strict alone (empty Configs) is valid: it means "no MCP at all".
func appendMCPFlags(cmd *[]string, mcp *domain.MCPConfig) {
if mcp == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This pins the opposite of issue #2195's priority-one MCP behavior. The issue says workers should default to no MCP and opt in explicitly, but a nil config returns without --strict-mcp-config, so every globally configured MCP server remains available. The new ‘unset inherits global’ test makes that mismatch deliberate.

The issue also contains broader compatibility wording, so please resolve the acceptance semantics explicitly. If least privilege is still the requirement, workers need a strict-empty default plus an explicit way to request inheritance.

return
}
for _, c := range mcp.Configs {
if c = strings.TrimSpace(c); c != "" {
*cmd = append(*cmd, "--mcp-config", c)
}
}
if mcp.Strict {
*cmd = append(*cmd, "--strict-mcp-config")
}
}

// appendPluginFlags emits --plugin-dir / --plugin-url for each entry. An
// http(s):// entry maps to --plugin-url (a fetched zip); any other value is
// treated as a local path and mapped to --plugin-dir. Both flags are repeatable,
// so one is emitted per entry. Empty/whitespace entries are skipped.
func appendPluginFlags(cmd *[]string, dirs []string) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] These flags add plugins; they do not scope the set a worker can load. User/project/global plugins and standalone skills remain active, so a configured worker still carries the orchestrator's globally enabled capabilities. That leaves the central least-privilege requirement from #2195 unimplemented.

Anthropic documents --plugin-dir / --plugin-url as session plugin additions, independently of installed user/project/local scopes: https://code.claude.com/docs/en/plugins-reference

Please add an isolation mechanism for other plugin/skill sources, or narrow the feature and PR claims so they do not promise plugin scoping.

for _, d := range dirs {
if d = strings.TrimSpace(d); d == "" {
continue
}
if isPluginURL(d) {
*cmd = append(*cmd, "--plugin-url", d)
} else {
*cmd = append(*cmd, "--plugin-dir", d)
}
}
}

// isPluginURL reports whether s is an http(s) plugin URL rather than a local
// path, deciding --plugin-url vs --plugin-dir.
func isPluginURL(s string) bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL schemes are case-insensitive, but this only recognizes lowercase prefixes. A valid HTTPS://example.com/plugin.zip value is emitted as --plugin-dir instead of --plugin-url. Parse the value as a URL and compare the scheme case-insensitively, with a regression test.

return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}

// claudeBinarySpec locates the claude binary: PATH first, then the native
// installer's locations, npm global, Homebrew, and the claude-managed dir.
var claudeBinarySpec = binaryutil.BinarySpec{
Expand Down
111 changes: 111 additions & 0 deletions backend/internal/adapters/agent/claudecode/claudecode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/google/uuid"

"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)

Expand Down Expand Up @@ -147,6 +148,116 @@ func TestGetLaunchCommandInjectsSessionID(t *testing.T) {
}
}

// TestGetLaunchCommandMCPAndPluginFlags: a per-role MCP set and plugin list are
// emitted as claude-code flags. MCP configs go to --mcp-config (one per entry),
// Strict adds --strict-mcp-config, local plugin paths go to --plugin-dir, and
// http(s) URLs go to --plugin-url.
func TestGetLaunchCommandMCPAndPluginFlags(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Config: ports.AgentConfig{
MCP: &domain.MCPConfig{Configs: []string{"{\"a\":1}", "/p/m.json"}, Strict: true},
PluginDirs: []string{"/local/plugin", "https://example.com/p.zip"},
},
})
if err != nil {
t.Fatal(err)
}
if !containsSubsequence(cmd, []string{"--mcp-config", "{\"a\":1}"}) {
t.Fatalf("cmd %#v missing --mcp-config inline entry", cmd)
}
if !containsSubsequence(cmd, []string{"--mcp-config", "/p/m.json"}) {
t.Fatalf("cmd %#v missing --mcp-config path entry", cmd)
}
if !contains(cmd, "--strict-mcp-config") {
t.Fatalf("cmd %#v missing --strict-mcp-config", cmd)
}
if !containsSubsequence(cmd, []string{"--plugin-dir", "/local/plugin"}) {
t.Fatalf("cmd %#v missing --plugin-dir for local path", cmd)
}
if !containsSubsequence(cmd, []string{"--plugin-url", "https://example.com/p.zip"}) {
t.Fatalf("cmd %#v missing --plugin-url for URL", cmd)
}
}

// TestGetLaunchCommandNoMCPFlagsWhenUnset: a config with no MCP/plugin
// configuration emits nothing, so an unset role inherits the global set.
func TestGetLaunchCommandNoMCPFlagsWhenUnset(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
if err != nil {
t.Fatal(err)
}
for _, flag := range []string{"--mcp-config", "--strict-mcp-config", "--plugin-dir", "--plugin-url"} {
if contains(cmd, flag) {
t.Fatalf("cmd %#v unexpectedly contains %s", cmd, flag)
}
}
}

// TestGetLaunchCommandMCPStrictAlone: Strict with no configs is valid isolation
// and emits just --strict-mcp-config.
func TestGetLaunchCommandMCPStrictAlone(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Config: ports.AgentConfig{MCP: &domain.MCPConfig{Strict: true}},
})
if err != nil {
t.Fatal(err)
}
if !contains(cmd, "--strict-mcp-config") {
t.Fatalf("cmd %#v missing --strict-mcp-config", cmd)
}
if contains(cmd, "--mcp-config") {
t.Fatalf("cmd %#v unexpectedly contains --mcp-config", cmd)
}
}

// TestGetRestoreCommandReappliesMCPAndPluginFlags: like the system prompt, MCP
// and plugin flags are rebuilt from flags on resume, so a restored worker keeps
// its scoped MCP set and plugins.
func TestGetRestoreCommandReappliesMCPAndPluginFlags(t *testing.T) {
cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{
Config: ports.AgentConfig{
MCP: &domain.MCPConfig{Configs: []string{"{\"a\":1}"}, Strict: true},
PluginDirs: []string{"https://example.com/p.zip"},
},
Session: ports.SessionRef{
ID: "sess-r",
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "claude-native-1"},
},
})
if err != nil || !ok {
t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err)
}
if !containsSubsequence(cmd, []string{"--mcp-config", "{\"a\":1}"}) {
t.Fatalf("restore cmd %#v missing --mcp-config", cmd)
}
if !contains(cmd, "--strict-mcp-config") {
t.Fatalf("restore cmd %#v missing --strict-mcp-config", cmd)
}
if !containsSubsequence(cmd, []string{"--plugin-url", "https://example.com/p.zip"}) {
t.Fatalf("restore cmd %#v missing --plugin-url", cmd)
}
// Flags must precede --resume.
if !flagsBeforeResume(cmd, "--strict-mcp-config") {
t.Fatalf("restore cmd %#v: MCP flag not before --resume", cmd)
}
}

// flagsBeforeResume reports whether flag appears before --resume in cmd.
func flagsBeforeResume(cmd []string, flag string) bool {
for _, v := range cmd {
if v == "--resume" {
return false
}
if v == flag {
return true
}
}
return false
}

func TestClaudeSessionUUIDDeterministicAndUnique(t *testing.T) {
a1 := claudeSessionUUID("alpha")
a2 := claudeSessionUUID("alpha")
Expand Down
26 changes: 24 additions & 2 deletions backend/internal/cli/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,18 @@ type workspaceRepoDetails struct {

// agentConfig mirrors the daemon's typed domain.AgentConfig for the CLI client.
type agentConfig struct {
Model string `json:"model,omitempty"`
Permissions string `json:"permissions,omitempty"`
Model string `json:"model,omitempty"`
Permissions string `json:"permissions,omitempty"`
SystemPrompt string `json:"systemPrompt,omitempty"`
Env map[string]string `json:"env,omitempty"`
MCP *mcpConfig `json:"mcp,omitempty"`
PluginDirs []string `json:"pluginDirs,omitempty"`
}

// mcpConfig mirrors domain.MCPConfig.
type mcpConfig struct {
Configs []string `json:"configs,omitempty"`
Strict bool `json:"strict,omitempty"`
}

// roleOverride mirrors domain.RoleOverride.
Expand Down Expand Up @@ -124,6 +134,9 @@ type projectSetConfigOptions struct {
permission string
workerAgent string
orchestratorAgent string
workerMCPConfig []string
workerStrictMCP bool
workerPluginDir []string
agentRules string
agentRulesFile string
orchestratorRules string
Expand Down Expand Up @@ -315,6 +328,9 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command {
f.StringVar(&opts.permission, "permission", "", "Permission mode: default, accept-edits, auto, bypass-permissions")
f.StringVar(&opts.workerAgent, "worker-agent", "", "Harness override for worker sessions")
f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Harness override for orchestrator sessions")
f.StringArrayVar(&opts.workerMCPConfig, "worker-mcp-config", nil, "MCP config (JSON string or path to JSON file) passed to claude-code workers via --mcp-config (repeatable)")
f.BoolVar(&opts.workerStrictMCP, "worker-strict-mcp", false, "Isolate claude-code workers from every other MCP source (--strict-mcp-config)")
f.StringArrayVar(&opts.workerPluginDir, "worker-plugin-dir", nil, "Plugin path or http(s):// URL loaded by claude-code workers only (repeatable)")
f.StringVar(&opts.agentRules, "agent-rules", "", "Project-specific standing instructions for worker sessions")
f.StringVar(&opts.agentRulesFile, "agent-rules-file", "", "Repo-relative file containing worker standing instructions")
f.StringVar(&opts.orchestratorRules, "orchestrator-rules", "", "Project-specific standing instructions for orchestrator sessions")
Expand Down Expand Up @@ -369,6 +385,12 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) {
Assignee: opts.trackerAssignee,
},
}
if len(opts.workerMCPConfig) > 0 || opts.workerStrictMCP {
cfg.Worker.AgentConfig.MCP = &mcpConfig{Configs: opts.workerMCPConfig, Strict: opts.workerStrictMCP}
}
if len(opts.workerPluginDir) > 0 {
cfg.Worker.AgentConfig.PluginDirs = opts.workerPluginDir
}
if reflect.DeepEqual(cfg, projectConfig{}) {
return projectConfig{}, usageError{errors.New("usage: provide at least one config flag, --config-json, or --clear")}
}
Expand Down
35 changes: 35 additions & 0 deletions backend/internal/cli/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,41 @@ func TestBuildProjectConfigTrackerIntakeFlags(t *testing.T) {
}
}

// TestBuildProjectConfigWorkerProfileFlags: the worker MCP/plugin flags are
// assembled into the per-role AgentConfig that the daemon validates and
// claude-code maps to --mcp-config / --strict-mcp-config / --plugin-dir[-url].
func TestBuildProjectConfigWorkerProfileFlags(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bypasses Cobra and only tests the options-to-struct helper. The repository requires command tests for CLI changes; please add an executeCLI/HTTP-capture case that parses the three new flags and verifies the emitted JSON wire shape. That would catch flag registration, repeatability, validation/usage exit behavior, and client/daemon DTO drift.

got, err := buildProjectConfig(projectSetConfigOptions{
workerMCPConfig: []string{`{"a":1}`, "/p/m.json"},
workerStrictMCP: true,
workerPluginDir: []string{"/local/plugin", "https://example.com/p.zip"},
})
if err != nil {
t.Fatal(err)
}
mcp := got.Worker.AgentConfig.MCP
if mcp == nil || !mcp.Strict || len(mcp.Configs) != 2 || mcp.Configs[0] != `{"a":1}` || mcp.Configs[1] != "/p/m.json" {
t.Fatalf("worker MCP = %#v, want strict with 2 configs", mcp)
}
if len(got.Worker.AgentConfig.PluginDirs) != 2 || got.Worker.AgentConfig.PluginDirs[1] != "https://example.com/p.zip" {
t.Fatalf("worker pluginDirs = %#v", got.Worker.AgentConfig.PluginDirs)
}
}

// TestBuildProjectConfigWorkerStrictMCPAlone: --worker-strict-mcp without any
// --worker-mcp-config is a valid isolation request (empty MCP set), so it must
// still build a present MCPConfig with Strict=true rather than dropping it.
func TestBuildProjectConfigWorkerStrictMCPAlone(t *testing.T) {
got, err := buildProjectConfig(projectSetConfigOptions{workerStrictMCP: true})
if err != nil {
t.Fatal(err)
}
mcp := got.Worker.AgentConfig.MCP
if mcp == nil || !mcp.Strict || len(mcp.Configs) != 0 {
t.Fatalf("worker MCP = %#v, want strict isolation with empty configs", mcp)
}
}

func TestProjectList_Success(t *testing.T) {
cfg := setConfigEnv(t)
srv, capture := projectServer(t, http.StatusOK, `{"projects":[{"id":"zeta","name":"Zeta","sessionPrefix":"zeta"},{"id":"alpha","name":"Alpha","sessionPrefix":"alpha","resolveError":"config missing"}]}`)
Expand Down
Loading
Loading