Compare commits

..

1 Commits

Author SHA1 Message Date
J
6b15d6f6cf feat(cli): add --mcp-config flags to agent create/update
Agents already support an mcp_config field (consumed by the daemon →
provider at task time) and the agent-settings UI exposes an MCP tab, but
the CLI had no way to set it. This adds the missing CLI surface, mirroring
the existing custom-env pattern:

- `agent create` and `agent update` gain --mcp-config / --mcp-config-stdin
  / --mcp-config-file. The stdin/file channels keep MCP server tokens out
  of shell history and 'ps'; the three channels are mutually exclusive.
- The value is validated as a JSON object (or the literal `null` to clear,
  on update), matching the agent-settings MCP tab. Empty stdin/file input
  errors instead of silently clearing a secret-bearing field.
- Unlike custom_env, mcp_config IS settable via `agent update` — it is
  persisted through the generic UpdateAgent endpoint (no dedicated audited
  endpoint), so both create and update expose the flags.

Adds parser/resolver unit tests (incl. secret-leak sanitization) and
updates the multica-creating-agents built-in skill + source map.

MUL-3070

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 12:47:37 +08:00
5 changed files with 84 additions and 96 deletions

View File

@@ -160,6 +160,12 @@ func init() {
agentCreateCmd.Flags().String("description", "", "Agent description")
agentCreateCmd.Flags().String("instructions", "", "Agent instructions")
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
// --from-template seeds the new agent from a curated template: imports the
// template's skills into the workspace (find-or-create by name) and applies
// the template's instructions. When set, --description/--instructions/
// --custom-args/--custom-env/--runtime-config are ignored (the template
// provides all the agent shape); --name and --runtime-id are still required.
agentCreateCmd.Flags().String("from-template", "", "Template slug to seed the agent from (e.g. code-reviewer). Lists are available via GET /api/agent-templates.")
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
agentCreateCmd.Flags().String("model", "", "Model identifier (e.g. claude-sonnet-4-6, openai/gpt-4o). Prefer this over passing --model in --custom-args.")
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
@@ -426,6 +432,14 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("--runtime-id is required")
}
// --from-template short-circuits to the dedicated endpoint, which
// fetches the template's skill URLs in parallel and creates the agent
// + skill rows atomically. Skip the manual-create body building and
// post the small template payload instead.
if templateSlug, _ := cmd.Flags().GetString("from-template"); templateSlug != "" {
return runAgentCreateFromTemplate(cmd, client, name, runtimeID, templateSlug)
}
body := map[string]any{
"name": name,
"runtime_id": runtimeID,
@@ -492,6 +506,55 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
return nil
}
// runAgentCreateFromTemplate posts to POST /api/agents/from-template. The
// server fetches every referenced skill in parallel and writes everything in
// a single transaction; a 422 here means at least one upstream URL was
// unreachable, in which case the body carries the failing URLs so we can
// surface them verbatim to the operator instead of a generic error.
func runAgentCreateFromTemplate(cmd *cobra.Command, client *cli.APIClient, name, runtimeID, slug string) error {
body := map[string]any{
"template_slug": slug,
"name": name,
"runtime_id": runtimeID,
}
if cmd.Flags().Changed("model") {
v, _ := cmd.Flags().GetString("model")
body["model"] = v
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
body["visibility"] = v
}
if cmd.Flags().Changed("max-concurrent-tasks") {
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
body["max_concurrent_tasks"] = v
}
// 60s ceiling: templates fan out N HTTP fetches to GitHub, each ~200-500ms.
// Matches the timeout used by `multica skill import` (cmd_skill.go).
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var result map[string]any
if err := client.PostJSON(ctx, "/api/agents/from-template", body, &result); err != nil {
return fmt.Errorf("create agent from template: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
agent, _ := result["agent"].(map[string]any)
imported, _ := result["imported_skill_ids"].([]any)
reused, _ := result["reused_skill_ids"].([]any)
fmt.Printf("Agent created from template %q: %s (%s)\n", slug, strVal(agent, "name"), strVal(agent, "id"))
if len(imported) > 0 || len(reused) > 0 {
fmt.Printf(" Skills: %d imported, %d reused\n", len(imported), len(reused))
}
return nil
}
func runAgentUpdate(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {

View File

@@ -238,18 +238,6 @@ func TestAgentUpdateDoesNotExposeCustomEnvFlags(t *testing.T) {
}
}
// TestAgentCreateDoesNotExposeFromTemplate guards against re-adding the
// `--from-template` flag. It was an untaught, immature CLI surface that
// short-circuited before body assembly — silently dropping sibling create
// flags like --mcp-config / --custom-env — and was removed. The agent-template
// backend API still exists but has no CLI surface; manual `agent create` is the
// only supported CLI creation path.
func TestAgentCreateDoesNotExposeFromTemplate(t *testing.T) {
if agentCreateCmd.Flag("from-template") != nil {
t.Error("agent create must NOT expose --from-template; it was removed as an untaught CLI surface that silently dropped sibling flags")
}
}
// TestParseCustomEnvErrorSanitization guards against future changes
// re-introducing %w wrapping of json.Unmarshal errors. Those errors
// can surface short fragments of the input, which — for a flag that

View File

@@ -19,30 +19,23 @@ go test ./internal/service -run TestBuiltinSkillsConformToTemplate
| Contract | Line | Behavior | Safe check |
|---|---|---|---|
| Create flags: `name`, `description`, `instructions`, `runtime-id` | 159162 | Registered create flags; `name`/`runtime-id` enforced in `runAgentCreate` | `multica agent create --help` |
| `runtime-config`, `model`, `custom-args` flags | 163165 | `model` help: "Prefer this over passing --model in --custom-args"; `custom-args` help names codex/openclaw rejecting `--model` (CLI help only, not server-enforced) | `multica agent create --help` |
| Secret-safe env input: `custom-env`, `custom-env-stdin`, `custom-env-file` | 166168 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
| Secret-safe MCP input: `mcp-config`, `mcp-config-stdin`, `mcp-config-file` (create) | 169171 | Same three-channel pattern as `custom-env`; `--mcp-config` warns about shell history / `ps`; value must be a JSON object or `null` | `multica agent create --help` |
| MCP flags on `agent update` | 192194 | Same three channels on update; `--mcp-config null` clears. Unlike `custom_env`, `mcp_config` IS settable via update | `multica agent update --help` |
| `runAgentCreate` builds body + `POST /api/agents` | 414 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 482) | read 414489 |
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/mcp-config/model | 437478 | `resolveCustomEnv` (455) and `resolveMcpConfig` (460) gate their secret channels; omitted flags are not sent | read 437478 |
| `runAgentUpdate` sends `mcp_config` | 550 | `resolveMcpConfig` adds `mcp_config` to the `PUT /api/agents/{id}` body (564); `custom_env` is intentionally not a flag here | read 495565 |
| `parseMcpConfig` / `resolveMcpConfig` helpers | 1066, 1094 | Validator (object-or-`null`, content-free errors) + three-channel resolver, mirroring `parseCustomEnv`/`resolveCustomEnv` | read 10661150 |
| `agent skills set` = replace-all | 772 | `PUT /api/agents/{id}/skills` (790); `--skill-ids ''` clears all (779) | `multica agent skills set --help` |
| `agent skills add` = additive | 797 | `POST /api/agents/{id}/skills/add` (818); requires ≥1 id (804, 808) | `multica agent skills add --help` |
| `agent skills list` | 740 | reads bindings, no side effect | `multica agent skills list --help` |
| `agent env get` | 874 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
| `agent env set` | 909 | `PUT /api/agents/{id}/env` with full `custom_env` map (923, 929) | `multica agent env set --help` |
| `runtime-config`, `model`, `custom-args` flags | 169171 | `model` help: "Prefer this over passing --model in --custom-args"; `custom-args` help names codex/openclaw rejecting `--model` (CLI help only, not server-enforced) | `multica agent create --help` |
| Secret-safe env input: `custom-env`, `custom-env-stdin`, `custom-env-file` | 172174 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
| Secret-safe MCP input: `mcp-config`, `mcp-config-stdin`, `mcp-config-file` (create) | 175177 | Same three-channel pattern as `custom-env`; `--mcp-config` warns about shell history / `ps`; value must be a JSON object or `null` | `multica agent create --help` |
| MCP flags on `agent update` | 198200 | Same three channels on update; `--mcp-config null` clears. Unlike `custom_env`, `mcp_config` IS settable via update | `multica agent update --help` |
| `runAgentCreate` builds body + `POST /api/agents` | 420 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 496) | read 420501 |
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/mcp-config/model | 443487 | `resolveCustomEnv` (469) and `resolveMcpConfig` (474) gate their secret channels; omitted flags are not sent | read 443487 |
| `runAgentUpdate` sends `mcp_config` | 613 | `resolveMcpConfig` adds `mcp_config` to the `PUT /api/agents/{id}` body (627); `custom_env` is intentionally not a flag here | read 558633 |
| `parseMcpConfig` / `resolveMcpConfig` helpers | 1129, 1157 | Validator (object-or-`null`, content-free errors) + three-channel resolver, mirroring `parseCustomEnv`/`resolveCustomEnv` | read 11291215 |
| `agent skills set` = replace-all | 835 | `PUT /api/agents/{id}/skills` (853); `--skill-ids ''` clears all (842) | `multica agent skills set --help` |
| `agent skills add` = additive | 860 | `POST /api/agents/{id}/skills/add` (881); requires ≥1 id (867, 871) | `multica agent skills add --help` |
| `agent skills list` | 803 | reads bindings, no side effect | `multica agent skills list --help` |
| `agent env get` | 937 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
| `agent env set` | 972 | `PUT /api/agents/{id}/env` with full `custom_env` map (986, 992) | `multica agent env set --help` |
Note: the CLI no longer exposes `--from-template`. The agent-template backend
still exists (registry `server/internal/agenttmpl/`, handler `agent_template.go`,
routes `GET /api/agent-templates` and `POST /api/agents/from-template`, plus the
`packages/core` client/query wrappers) but is currently orphaned plumbing with no
live caller: the removed CLI flag was its only non-test consumer, and onboarding
does NOT use it — `packages/views/onboarding/steps/step-agent.tsx` builds four
hardcoded local presets (i18n-resolved) and creates via plain `POST /api/agents`
(`createAgent`), never `POST /api/agents/from-template`. Do not treat the template
API as a supported agent-creation path. This skill teaches manual `agent create`
only.
Note: `--from-template` exists at line 168 and short-circuits to
`runAgentCreateFromTemplate` (line 514). It is intentionally NOT taught — the
template path is immature and out of scope for this skill.
## Create handler — `server/internal/handler/agent.go`

View File

@@ -590,36 +590,10 @@ func mergeEnv(base []string, extra map[string]string) []string {
return env
}
// isFilteredChildEnvKey reports whether an inherited env var is an internal
// Claude Code runtime/session marker that must NOT leak into the spawned child
// (otherwise the child mistakes itself for a nested or resumed session, or
// inherits the parent's exec path / transport).
//
// It must NOT strip the user-facing CLAUDE_CODE_* configuration namespace
// (CLAUDE_CODE_GIT_BASH_PATH, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX,
// CLAUDE_CODE_MAX_OUTPUT_TOKENS, CLAUDE_CODE_TMPDIR, ...): users set those
// deliberately and the child needs them. Blanket-stripping the whole prefix is
// what broke Windows — CLAUDE_CODE_GIT_BASH_PATH was silently removed, so Claude
// Code could not find bash.exe and exited immediately. Strip internal markers by
// exact name and let every other CLAUDE_CODE_* var through.
//
// The denylist holds only undocumented, per-process runtime markers. Anything in
// the public env-vars reference (https://code.claude.com/docs/en/env-vars) is
// user config and stays out of this list — including CLAUDE_CODE_TMPDIR, a
// documented temp-dir override under which Claude Code creates its own
// per-session subdir, so inheriting it is harmless.
func isFilteredChildEnvKey(key string) bool {
switch key {
case "CLAUDECODE", // "1" when running inside Claude Code
"CLAUDE_CODE_ENTRYPOINT", // entrypoint marker (cli/sdk-cli/...)
"CLAUDE_CODE_EXECPATH", // path to the running CLI binary
"CLAUDE_CODE_SESSION_ID", // per-session identifier
"CLAUDE_CODE_SSE_PORT": // IDE-extension transport port
return true
}
// CLAUDECODE_* (no underscore between CLAUDE and CODE) is wholly internal;
// keep stripping it. The user-facing config namespace is CLAUDE_CODE_*.
return strings.HasPrefix(key, "CLAUDECODE_")
return key == "CLAUDECODE" ||
strings.HasPrefix(key, "CLAUDECODE_") ||
strings.HasPrefix(key, "CLAUDE_CODE_")
}
// blockedArgMode specifies whether a blocked arg takes a value or is standalone.

View File

@@ -417,29 +417,12 @@ func TestMergeEnvFiltersClaudeCodeVars(t *testing.T) {
"PATH=/usr/bin",
"CLAUDECODE=1",
"CLAUDE_CODE_ENTRYPOINT=cli",
"CLAUDE_CODE_EXECPATH=/opt/claude",
"CLAUDE_CODE_SESSION_ID=abc123",
"CLAUDE_CODE_SSE_PORT=9999",
"CLAUDECODEX=keep-me",
"CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe",
"CLAUDE_CODE_USE_BEDROCK=1",
"CLAUDE_CODE_TMPDIR=/custom/tmp",
}, map[string]string{"FOO": "bar"})
// Internal runtime/session markers must be stripped so the child does not
// inherit the parent's identity or transport.
filteredOut := []string{
"CLAUDECODE=1",
"CLAUDE_CODE_ENTRYPOINT=cli",
"CLAUDE_CODE_EXECPATH=/opt/claude",
"CLAUDE_CODE_SESSION_ID=abc123",
"CLAUDE_CODE_SSE_PORT=9999",
}
for _, entry := range env {
for _, banned := range filteredOut {
if entry == banned {
t.Fatalf("expected internal Claude Code marker %q to be filtered, got %v", banned, env)
}
if entry == "CLAUDECODE=1" || entry == "CLAUDE_CODE_ENTRYPOINT=cli" {
t.Fatalf("expected CLAUDECODE vars to be filtered, got %v", env)
}
}
@@ -454,19 +437,6 @@ func TestMergeEnvFiltersClaudeCodeVars(t *testing.T) {
if !found["CLAUDECODEX=keep-me"] {
t.Fatalf("expected unrelated env vars to be preserved, got %v", env)
}
// User-facing CLAUDE_CODE_* config must reach the child — stripping
// CLAUDE_CODE_GIT_BASH_PATH is what broke Claude Code on Windows (#3671).
if !found["CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe"] {
t.Fatalf("expected CLAUDE_CODE_GIT_BASH_PATH to be preserved, got %v", env)
}
if !found["CLAUDE_CODE_USE_BEDROCK=1"] {
t.Fatalf("expected CLAUDE_CODE_USE_BEDROCK to be preserved, got %v", env)
}
// CLAUDE_CODE_TMPDIR is a documented user-configurable temp-dir override, not
// an internal per-session marker, so it must reach the child.
if !found["CLAUDE_CODE_TMPDIR=/custom/tmp"] {
t.Fatalf("expected CLAUDE_CODE_TMPDIR to be preserved, got %v", env)
}
if !found["FOO=bar"] {
t.Fatalf("expected extra env var to be appended, got %v", env)
}