mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 09:30:00 +02:00
Compare commits
1 Commits
agent/lamb
...
feat/cli-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b15d6f6cf |
@@ -172,6 +172,9 @@ func init() {
|
||||
agentCreateCmd.Flags().String("custom-env", "", "Custom environment variables as JSON object, e.g. '{\"KEY\":\"value\"}'. Treated as secret material — never logged by the CLI, but values passed on the command line are visible to shell history and 'ps'; prefer --custom-env-stdin or --custom-env-file for real secrets. Pass '{}' to set an empty map.")
|
||||
agentCreateCmd.Flags().Bool("custom-env-stdin", false, "Read the --custom-env JSON object from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --custom-env and --custom-env-file.")
|
||||
agentCreateCmd.Flags().String("custom-env-file", "", "Read the --custom-env JSON object from a file path (suggested mode: 0600). Mutually exclusive with --custom-env and --custom-env-stdin.")
|
||||
agentCreateCmd.Flags().String("mcp-config", "", "MCP server configuration as a JSON object, e.g. '{\"mcpServers\":{\"shortcut\":{...}}}'. Treated as secret material (MCP entries often carry API tokens) — never logged by the CLI, but values passed on the command line are visible to shell history and 'ps'; prefer --mcp-config-stdin or --mcp-config-file for real secrets.")
|
||||
agentCreateCmd.Flags().Bool("mcp-config-stdin", false, "Read the --mcp-config JSON object from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --mcp-config and --mcp-config-file.")
|
||||
agentCreateCmd.Flags().String("mcp-config-file", "", "Read the --mcp-config JSON object from a file path (suggested mode: 0600). Mutually exclusive with --mcp-config and --mcp-config-stdin.")
|
||||
agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace")
|
||||
agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks")
|
||||
agentCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
@@ -187,6 +190,14 @@ func init() {
|
||||
// custom_env is intentionally NOT part of `agent update`. Use
|
||||
// `multica agent env set <id>` — that path is owner/admin-only,
|
||||
// denies agent actors, and writes a persisted audit trail.
|
||||
//
|
||||
// mcp_config, unlike custom_env, IS updatable here: it is persisted
|
||||
// through the generic UpdateAgent endpoint (there is no dedicated
|
||||
// audited endpoint for it). The same three secret-safe input channels
|
||||
// as `agent create` are offered. Pass `--mcp-config null` to clear.
|
||||
agentUpdateCmd.Flags().String("mcp-config", "", "New MCP server configuration as a JSON object, e.g. '{\"mcpServers\":{...}}'. Pass 'null' to clear. Treated as secret material — never logged by the CLI, but values passed on the command line are visible to shell history and 'ps'; prefer --mcp-config-stdin or --mcp-config-file for real secrets.")
|
||||
agentUpdateCmd.Flags().Bool("mcp-config-stdin", false, "Read the --mcp-config JSON from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --mcp-config and --mcp-config-file.")
|
||||
agentUpdateCmd.Flags().String("mcp-config-file", "", "Read the --mcp-config JSON from a file path (suggested mode: 0600). Mutually exclusive with --mcp-config and --mcp-config-stdin.")
|
||||
agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace")
|
||||
agentUpdateCmd.Flags().String("status", "", "New status")
|
||||
agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks")
|
||||
@@ -460,6 +471,11 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
|
||||
} else if ok {
|
||||
body["custom_env"] = ce
|
||||
}
|
||||
if mc, ok, err := resolveMcpConfig(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
body["mcp_config"] = mc
|
||||
}
|
||||
if cmd.Flags().Changed("model") {
|
||||
v, _ := cmd.Flags().GetString("model")
|
||||
body["model"] = v
|
||||
@@ -594,9 +610,14 @@ func runAgentUpdate(cmd *cobra.Command, args []string) error {
|
||||
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
|
||||
body["max_concurrent_tasks"] = v
|
||||
}
|
||||
if mc, ok, err := resolveMcpConfig(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
body["mcp_config"] = mc
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --model, --custom-args, --visibility, --status, or --max-concurrent-tasks (env vars now live behind `multica agent env set <id>`)")
|
||||
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --model, --custom-args, --mcp-config, --visibility, --status, or --max-concurrent-tasks (env vars now live behind `multica agent env set <id>`)")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
@@ -1092,6 +1113,106 @@ func resolveCustomEnv(cmd *cobra.Command) (map[string]string, bool, error) {
|
||||
return ce, true, nil
|
||||
}
|
||||
|
||||
// parseMcpConfig validates the --mcp-config value and returns the raw JSON to
|
||||
// send. It accepts a JSON object (the MCP config, e.g. {"mcpServers": {…}}) or
|
||||
// the literal `null` to clear the agent's config. A top-level array or
|
||||
// primitive is rejected because it can never be a valid MCP config — this
|
||||
// mirrors the agent-settings UI (mcp-config-tab.tsx). Empty/whitespace input
|
||||
// is rejected rather than treated as a clear: for the stdin/file channels it
|
||||
// almost always signals an upstream failure (missing file, unset pipe) rather
|
||||
// than a deliberate clear, and silently wiping a secret-bearing field is the
|
||||
// wrong default — pass an explicit `null` to clear.
|
||||
//
|
||||
// The payload is treated as secret material (MCP entries routinely carry API
|
||||
// tokens), so parse errors never wrap the underlying json error, which can
|
||||
// echo short fragments of malformed input.
|
||||
func parseMcpConfig(raw string) (json.RawMessage, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("--mcp-config: empty input; pass 'null' to clear or a JSON object to set")
|
||||
}
|
||||
var probe any
|
||||
if err := json.Unmarshal([]byte(trimmed), &probe); err != nil {
|
||||
return nil, fmt.Errorf("--mcp-config must be a valid JSON object, or 'null' to clear")
|
||||
}
|
||||
// null → clear (NULL column server-side; on create it is a no-op).
|
||||
if probe == nil {
|
||||
return json.RawMessage("null"), nil
|
||||
}
|
||||
if _, ok := probe.(map[string]any); !ok {
|
||||
return nil, fmt.Errorf("--mcp-config must be a JSON object, or 'null' to clear")
|
||||
}
|
||||
return json.RawMessage(trimmed), nil
|
||||
}
|
||||
|
||||
// resolveMcpConfig collects the --mcp-config, --mcp-config-stdin, and
|
||||
// --mcp-config-file flags and returns the raw JSON value to send, a bool
|
||||
// indicating whether the caller supplied any of them, and any error. The
|
||||
// three input channels are mutually exclusive so callers can't accidentally
|
||||
// provide a secret twice. Stdin and file inputs exist to keep mcp_config —
|
||||
// which routinely embeds API tokens — out of shell history and 'ps'. Mirrors
|
||||
// resolveCustomEnv; the only behavioural difference is the clear sentinel
|
||||
// (`null` here vs `{}` for custom_env), because mcp_config distinguishes an
|
||||
// explicit empty object from an absent config server-side.
|
||||
func resolveMcpConfig(cmd *cobra.Command) (json.RawMessage, bool, error) {
|
||||
inline := cmd.Flags().Changed("mcp-config")
|
||||
fromStdin, _ := cmd.Flags().GetBool("mcp-config-stdin")
|
||||
filePath, _ := cmd.Flags().GetString("mcp-config-file")
|
||||
fromFile := cmd.Flags().Changed("mcp-config-file")
|
||||
|
||||
count := 0
|
||||
if inline {
|
||||
count++
|
||||
}
|
||||
if fromStdin {
|
||||
count++
|
||||
}
|
||||
if fromFile {
|
||||
count++
|
||||
}
|
||||
switch {
|
||||
case count == 0:
|
||||
return nil, false, nil
|
||||
case count > 1:
|
||||
return nil, false, fmt.Errorf("--mcp-config, --mcp-config-stdin, and --mcp-config-file are mutually exclusive; pick one")
|
||||
}
|
||||
|
||||
var raw string
|
||||
switch {
|
||||
case inline:
|
||||
raw, _ = cmd.Flags().GetString("mcp-config")
|
||||
case fromStdin:
|
||||
buf, err := io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("read --mcp-config-stdin: %w", err)
|
||||
}
|
||||
raw = string(buf)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, false, fmt.Errorf("--mcp-config-stdin: empty input; pass 'null' to clear")
|
||||
}
|
||||
case fromFile:
|
||||
if filePath == "" {
|
||||
return nil, false, fmt.Errorf("--mcp-config-file: path must not be empty")
|
||||
}
|
||||
buf, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
// Filesystem errors may include the path but not the contents —
|
||||
// safe to surface via %w.
|
||||
return nil, false, fmt.Errorf("read --mcp-config-file: %w", err)
|
||||
}
|
||||
raw = string(buf)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, false, fmt.Errorf("--mcp-config-file %q: empty contents; pass 'null' to clear", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
mc, err := parseMcpConfig(raw)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return mc, true, nil
|
||||
}
|
||||
|
||||
func strVal(m map[string]any, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
|
||||
@@ -500,6 +500,205 @@ func TestResolveCustomEnv(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// freshMcpConfigCmd returns a standalone cobra.Command with the three
|
||||
// --mcp-config* flags registered identically to `agent create` / `agent
|
||||
// update`, so resolveMcpConfig-shaped tests can mutate flag state without
|
||||
// leaking across subtests.
|
||||
func freshMcpConfigCmd() *cobra.Command {
|
||||
c := &cobra.Command{Use: "x"}
|
||||
c.Flags().String("mcp-config", "", "")
|
||||
c.Flags().Bool("mcp-config-stdin", false, "")
|
||||
c.Flags().String("mcp-config-file", "", "")
|
||||
return c
|
||||
}
|
||||
|
||||
func TestParseMcpConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string // expected raw JSON; ignored when wantErr
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "object with servers", raw: `{"mcpServers":{"shortcut":{"command":"npx"}}}`, want: `{"mcpServers":{"shortcut":{"command":"npx"}}}`},
|
||||
{name: "explicit empty object is a valid empty set", raw: `{}`, want: `{}`},
|
||||
{name: "null clears", raw: `null`, want: `null`},
|
||||
{name: "null with surrounding whitespace clears", raw: " null\n", want: `null`},
|
||||
{name: "empty string errors", raw: ``, wantErr: true},
|
||||
{name: "whitespace only errors", raw: ` `, wantErr: true},
|
||||
{name: "not JSON", raw: `command=npx`, wantErr: true},
|
||||
{name: "top-level array rejected", raw: `[{"a":1}]`, wantErr: true},
|
||||
{name: "top-level string rejected", raw: `"oops"`, wantErr: true},
|
||||
{name: "top-level number rejected", raw: `42`, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseMcpConfig(tc.raw)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parseMcpConfig(%q): expected error, got nil (result=%s)", tc.raw, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--mcp-config") {
|
||||
t.Fatalf("parseMcpConfig(%q): error should mention --mcp-config, got %v", tc.raw, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseMcpConfig(%q): unexpected error: %v", tc.raw, err)
|
||||
}
|
||||
if string(got) != tc.want {
|
||||
t.Fatalf("parseMcpConfig(%q) = %s, want %s", tc.raw, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseMcpConfigErrorSanitization mirrors the parseCustomEnv check:
|
||||
// mcp_config carries secret material (MCP entries embed API tokens), so a
|
||||
// json.Unmarshal failure must never echo fragments of the input.
|
||||
func TestParseMcpConfigErrorSanitization(t *testing.T) {
|
||||
secretish := `{"mcpServers":{"x":{"env":{"TOKEN":verySensitiveValue}}}}` // invalid JSON, unquoted value
|
||||
_, err := parseMcpConfig(secretish)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error for invalid JSON")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leak := range []string{"TOKEN", "verySensitiveValue", "mcpServers"} {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Fatalf("parseMcpConfig error leaked input fragment %q: %q", leak, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveMcpConfig exercises the input-channel resolver: inline flag,
|
||||
// stdin, file, the `null` clear sentinel, mutual exclusion, and the
|
||||
// "not supplied" path.
|
||||
func TestResolveMcpConfig(t *testing.T) {
|
||||
t.Run("not supplied", func(t *testing.T) {
|
||||
cmd := freshMcpConfigCmd()
|
||||
got, ok, err := resolveMcpConfig(cmd)
|
||||
if err != nil || ok || got != nil {
|
||||
t.Fatalf("unset flags: got=%s ok=%v err=%v", got, ok, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline object", func(t *testing.T) {
|
||||
cmd := freshMcpConfigCmd()
|
||||
if err := cmd.Flags().Set("mcp-config", `{"mcpServers":{}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, err := resolveMcpConfig(cmd)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("inline: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(got) != `{"mcpServers":{}}` {
|
||||
t.Fatalf("inline: got %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline null clears", func(t *testing.T) {
|
||||
cmd := freshMcpConfigCmd()
|
||||
_ = cmd.Flags().Set("mcp-config", `null`)
|
||||
got, ok, err := resolveMcpConfig(cmd)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("null: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(got) != `null` {
|
||||
t.Fatalf("null: got %s, want null", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin", func(t *testing.T) {
|
||||
cmd := freshMcpConfigCmd()
|
||||
_ = cmd.Flags().Set("mcp-config-stdin", "true")
|
||||
cmd.SetIn(bytes.NewBufferString(`{"mcpServers":{"a":{}}}`))
|
||||
got, ok, err := resolveMcpConfig(cmd)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("stdin: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(got) != `{"mcpServers":{"a":{}}}` {
|
||||
t.Fatalf("stdin: got %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mcp.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcpServers":{"b":{}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := freshMcpConfigCmd()
|
||||
_ = cmd.Flags().Set("mcp-config-file", path)
|
||||
got, ok, err := resolveMcpConfig(cmd)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("file: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if string(got) != `{"mcpServers":{"b":{}}}` {
|
||||
t.Fatalf("file: got %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mutually exclusive: inline + stdin", func(t *testing.T) {
|
||||
cmd := freshMcpConfigCmd()
|
||||
_ = cmd.Flags().Set("mcp-config", `{}`)
|
||||
_ = cmd.Flags().Set("mcp-config-stdin", "true")
|
||||
_, _, err := resolveMcpConfig(cmd)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual-exclusion error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Empty stdin almost always means an upstream failure, not a deliberate
|
||||
// clear — it must error rather than silently wipe a secret-bearing field.
|
||||
t.Run("stdin: empty input errors", func(t *testing.T) {
|
||||
cmd := freshMcpConfigCmd()
|
||||
_ = cmd.Flags().Set("mcp-config-stdin", "true")
|
||||
cmd.SetIn(bytes.NewBufferString(""))
|
||||
_, _, err := resolveMcpConfig(cmd)
|
||||
if err == nil || !strings.Contains(err.Error(), "--mcp-config-stdin") || !strings.Contains(err.Error(), "null") {
|
||||
t.Fatalf("expected --mcp-config-stdin empty-input error mentioning 'null', got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file: missing path surfaces filesystem error", func(t *testing.T) {
|
||||
cmd := freshMcpConfigCmd()
|
||||
_ = cmd.Flags().Set("mcp-config-file", filepath.Join(t.TempDir(), "nope.json"))
|
||||
_, _, err := resolveMcpConfig(cmd)
|
||||
if err == nil || !strings.Contains(err.Error(), "--mcp-config-file") {
|
||||
t.Fatalf("expected --mcp-config-file error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentCreateAndUpdateExposeMcpConfigFlags guarantees the secret-safe
|
||||
// --mcp-config-stdin / --mcp-config-file alternatives stay wired up on both
|
||||
// commands that accept MCP input. Unlike custom_env, mcp_config IS updatable
|
||||
// via `agent update` (it has no dedicated audited endpoint), so both surfaces
|
||||
// must expose all three channels.
|
||||
func TestAgentCreateAndUpdateExposeMcpConfigFlags(t *testing.T) {
|
||||
for _, flag := range []string{"mcp-config", "mcp-config-stdin", "mcp-config-file"} {
|
||||
if agentCreateCmd.Flag(flag) == nil {
|
||||
t.Fatalf("agent create must expose --%s", flag)
|
||||
}
|
||||
if agentUpdateCmd.Flag(flag) == nil {
|
||||
t.Fatalf("agent update must expose --%s", flag)
|
||||
}
|
||||
}
|
||||
// The --mcp-config help text must warn that argv is visible to shell
|
||||
// history / 'ps' — the same foot-gun the custom-env flags warn about.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
usage string
|
||||
}{
|
||||
{"agent create", agentCreateCmd.Flag("mcp-config").Usage},
|
||||
{"agent update", agentUpdateCmd.Flag("mcp-config").Usage},
|
||||
} {
|
||||
low := strings.ToLower(c.usage)
|
||||
if !strings.Contains(low, "shell history") || !strings.Contains(low, "'ps'") {
|
||||
t.Fatalf("%s --mcp-config usage must warn about shell history and 'ps' exposure; got: %q", c.name, c.usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSkillsAddCallsAdditiveEndpoint(t *testing.T) {
|
||||
var gotMethod string
|
||||
var gotPath string
|
||||
|
||||
@@ -78,7 +78,7 @@ The HTTP body (`CreateAgentRequest`) accepts: `name`, `description`,
|
||||
| `custom_args` | `agent.custom_args` (JSON array) | JSON shape checked CLI-side; server stores as-is | daemon (extra CLI switches); defaults to `[]` |
|
||||
| `runtime_config` | `agent.runtime_config` (JSON) | JSON shape checked CLI-side; server stores as-is | runtime-specific config; defaults to `{}` |
|
||||
| `custom_env` | `agent.custom_env` (JSON object) | — | daemon (process env); see Env & secrets |
|
||||
| `mcp_config` | `agent.mcp_config` (raw JSON) | literal `null` is dropped at create | daemon → provider (MCP servers) — **runtime-consumed**; redacted on read |
|
||||
| `mcp_config` | `agent.mcp_config` (raw JSON) | CLI checks it is a JSON object or `null`; server stores as-is. At create, literal `null` is dropped (no-op); at update, `null` clears the column | daemon → provider (MCP servers) — **runtime-consumed**; redacted on read |
|
||||
| `visibility` | `agent.visibility` | — | access control; defaults to `private`; gates who can read/route a private agent (e.g. a private squad leader) — NOT the runtime prompt |
|
||||
| `max_concurrent_tasks` | `agent.max_concurrent_tasks` | — | scheduler task cap; defaults to `6` |
|
||||
|
||||
@@ -131,6 +131,35 @@ Read-side facts (these are the wrong assumptions to avoid):
|
||||
`PUT /api/agents/{id}/env` (`multica agent env set`), which is owner/admin-only
|
||||
and writes an audit row.
|
||||
|
||||
### mcp_config
|
||||
|
||||
`mcp_config` is the agent's MCP server configuration (a JSON object such as
|
||||
`{"mcpServers": {…}}`). It is also secret material — MCP entries routinely embed
|
||||
API tokens — and offers the same three input channels as `custom_env`, on BOTH
|
||||
`agent create` and `agent update`:
|
||||
|
||||
```bash
|
||||
multica agent create --name <name> --runtime-id <runtime-id> --mcp-config-file <0600-json> --output json
|
||||
multica agent update <agent-id> --mcp-config-stdin --output json
|
||||
multica agent update <agent-id> --mcp-config 'null' # clears the config
|
||||
```
|
||||
|
||||
`--mcp-config-stdin` / `--mcp-config-file` keep the value out of shell history
|
||||
and `ps`; the inline `--mcp-config <json>` does not. The CLI requires a JSON
|
||||
**object** or the literal `null`; a top-level array or primitive is rejected
|
||||
client-side, and empty stdin/file input errors rather than silently clearing.
|
||||
|
||||
Two ways `mcp_config` differs from `custom_env`:
|
||||
|
||||
- **It IS settable through `agent update`.** Unlike `custom_env`, `mcp_config`
|
||||
has no dedicated audited endpoint — the generic `PUT /api/agents/{id}` accepts
|
||||
it. Tri-state per the raw request body: field omitted → no change; `null` →
|
||||
clear; object → replace.
|
||||
- **It is serialized on read, but redacted.** `agent get`/`list` return
|
||||
`mcp_config` only to callers allowed to view agent secrets; otherwise the
|
||||
field is `null` and `mcp_config_redacted` is `true`. Agent actors never see
|
||||
it, and a workspace may force redaction for everyone.
|
||||
|
||||
## Skill binding
|
||||
|
||||
Creating an agent does NOT bind any workspace skill — binding is a separate
|
||||
@@ -173,6 +202,9 @@ State-changing (require an explicit instruction — do not run speculatively):
|
||||
- "Create binds the agent's skills." It does not; bind explicitly afterward.
|
||||
- "`agent update` can rotate env." It cannot — it 400s on `custom_env`; use the
|
||||
env endpoint.
|
||||
- "`mcp_config` behaves like `custom_env` on update." It does not — `mcp_config`
|
||||
IS settable via `agent update` (`--mcp-config`), with `--mcp-config null` to
|
||||
clear; only `custom_env` is gated behind the dedicated env endpoint.
|
||||
- "`agent get` shows env values." It shows only `has_custom_env` and
|
||||
`custom_env_key_count`.
|
||||
- "An invalid `thinking_level`/`model` combo is caught at create." Only an
|
||||
|
||||
@@ -21,16 +21,20 @@ go test ./internal/service -run TestBuiltinSkillsConformToTemplate
|
||||
| Create flags: `name`, `description`, `instructions`, `runtime-id` | 159–162 | Registered create flags; `name`/`runtime-id` enforced in `runAgentCreate` | `multica agent create --help` |
|
||||
| `runtime-config`, `model`, `custom-args` flags | 169–171 | `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` | 172–174 | `--custom-env` warns about shell history / `ps`; stdin and file modes keep secrets off the command line; mutually exclusive | `multica agent create --help` |
|
||||
| `runAgentCreate` builds body + `POST /api/agents` | 409 | Only sets a body key when the flag `Changed`; posts to `/api/agents` (line 480) | read 409–491 |
|
||||
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/model | 432–474 | `resolveCustomEnv` (458) gates the three env channels; omitted flags are not sent | read 432–474 |
|
||||
| `agent skills set` = replace-all | 814 | `PUT /api/agents/{id}/skills` (832); `--skill-ids ''` clears all (821) | `multica agent skills set --help` |
|
||||
| `agent skills add` = additive | 839 | `POST /api/agents/{id}/skills/add` (860); requires ≥1 id (849) | `multica agent skills add --help` |
|
||||
| `agent skills list` | 782 | reads bindings, no side effect | `multica agent skills list --help` |
|
||||
| `agent env get` | 916 | `GET /api/agents/{id}/env` | `multica agent env get --help` |
|
||||
| `agent env set` | 951 | `PUT /api/agents/{id}/env` with full `custom_env` map (965, 971) | `multica agent env set --help` |
|
||||
| Secret-safe MCP input: `mcp-config`, `mcp-config-stdin`, `mcp-config-file` (create) | 175–177 | 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` | 198–200 | 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 420–501 |
|
||||
| Body assembly: description/instructions/runtime-config/custom-args/custom-env/mcp-config/model | 443–487 | `resolveCustomEnv` (469) and `resolveMcpConfig` (474) gate their secret channels; omitted flags are not sent | read 443–487 |
|
||||
| `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 558–633 |
|
||||
| `parseMcpConfig` / `resolveMcpConfig` helpers | 1129, 1157 | Validator (object-or-`null`, content-free errors) + three-channel resolver, mirroring `parseCustomEnv`/`resolveCustomEnv` | read 1129–1215 |
|
||||
| `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: `--from-template` exists at line 168 and short-circuits to
|
||||
`runAgentCreateFromTemplate` (line 498). It is intentionally NOT taught — the
|
||||
`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`
|
||||
@@ -52,6 +56,7 @@ template path is immature and out of scope for this skill.
|
||||
| `mcp_config` redacted on read | 54, 848–851 | `redactMcpConfig` sets `McpConfigRedacted=true`; a private agent read by a member also redacts (494, 509) |
|
||||
| `CreateAgent` insert params | 708–722 | persists runtime_config, instructions, custom_env, custom_args, model, thinking_level, mcp_config, visibility, max_concurrent_tasks |
|
||||
| `UpdateAgent` rejects `custom_env` | 910–913 | if `custom_env` present in body → 400 "use PUT /api/agents/{id}/env (or `multica agent env set`)" |
|
||||
| `UpdateAgent` persists / clears `mcp_config` | 944–948, 1060–1061 | Tri-state from the raw body: key omitted → no change; literal `null` → `ClearAgentMcpConfig`; object → replace. No 400 like `custom_env` — `mcp_config` IS updatable here |
|
||||
| `description` ≤ 255 on update too | 921–924 | same cap re-checked on update |
|
||||
|
||||
## Env endpoint — `server/internal/handler/agent_env.go`
|
||||
|
||||
Reference in New Issue
Block a user