Files
multica/server/cmd/multica/cmd_agent_test.go
Bohan Jiang 62925b97f1 chore(cli): remove the --from-template flag from agent create (#3805)
* chore(cli): remove the --from-template flag from agent create

The `--from-template` CLI flag was an untaught, immature surface (the
built-in skill's source-map explicitly marked the template path "out of
scope"). It also silently ignored sibling create flags (--custom-env,
--mcp-config, etc.) by short-circuiting before body assembly. Remove the
flag and its runAgentCreateFromTemplate handler from the CLI.

Scope is CLI-only. The agent-template product feature stays intact:
- registry server/internal/agenttmpl/ (embedded curated templates)
- handler server/internal/handler/agent_template.go
- routes GET /api/agent-templates, GET /api/agent-templates/{slug},
  POST /api/agents/from-template
- the onboarding "create from template" flow (packages/views/onboarding)

The onboarding flow calls the API directly and does not depend on the
CLI flag, so removing the flag does not affect it.

Updates the multica-creating-agents source map accordingly.

MUL-3070

Co-authored-by: multica-agent <github@multica.ai>

* fix: correct source-map note on agent-template usage + guard --from-template

Review of #3805 (MUL-3070) flagged a factual error in the source-map note:
it claimed onboarding uses the agent-template backend. It does not.
`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`. The whole
agent-template stack (registry, handler, routes, `packages/core` client +
query wrappers) is orphaned — the removed CLI flag was its only non-test
caller. Rewrite the note to say so.

Also add a regression test asserting `agent create` exposes no
`--from-template` flag, so it can't be silently re-added.

MUL-3070

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 14:21:11 +08:00

1206 lines
40 KiB
Go

package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
// freshAgentEnvSetCmd returns a standalone cobra.Command with the three
// --custom-env* flags registered identically to agentEnvSetCmd, so
// resolveCustomEnv-shaped tests can mutate flag state without leaking
// across subtests. After MUL-2600 the same three flags are registered
// on `agent create` and `agent env set` (NOT on `agent update`), but
// the parser they drive is shared, so a single fresh-command helper
// covers both call sites.
func freshAgentEnvSetCmd() *cobra.Command {
c := &cobra.Command{Use: "set"}
c.Flags().String("custom-env", "", "")
c.Flags().Bool("custom-env-stdin", false, "")
c.Flags().String("custom-env-file", "", "")
return c
}
// TestResolveWorkspaceID_AgentContextSkipsConfig is a regression test for
// the cross-workspace contamination bug (#1235). Inside a daemon-spawned
// agent task (MULTICA_AGENT_ID / MULTICA_TASK_ID set), the CLI must NOT
// silently read the user-global ~/.multica/config.json to recover a missing
// workspace — that fallback is how agent operations leaked into an
// unrelated workspace when the daemon failed to inject the right value.
//
// Outside agent context, the three-level fallback (flag → env → config) is
// unchanged.
func TestResolveWorkspaceID_AgentContextSkipsConfig(t *testing.T) {
t.Setenv("HOME", t.TempDir())
// Seed the global CLI config with a workspace_id that must NOT be
// picked up while running inside an agent task.
if err := cli.SaveCLIConfig(cli.CLIConfig{WorkspaceID: "config-file-ws"}); err != nil {
t.Fatalf("seed config: %v", err)
}
t.Run("outside agent context falls back to config", func(t *testing.T) {
t.Setenv("MULTICA_AGENT_ID", "")
t.Setenv("MULTICA_TASK_ID", "")
t.Setenv("MULTICA_WORKSPACE_ID", "")
got := resolveWorkspaceID(testCmd())
if got != "config-file-ws" {
t.Fatalf("resolveWorkspaceID() = %q, want %q (config fallback)", got, "config-file-ws")
}
})
t.Run("agent context with explicit env uses env", func(t *testing.T) {
t.Setenv("MULTICA_AGENT_ID", "agent-123")
t.Setenv("MULTICA_TASK_ID", "task-456")
t.Setenv("MULTICA_WORKSPACE_ID", "env-ws")
got := resolveWorkspaceID(testCmd())
if got != "env-ws" {
t.Fatalf("resolveWorkspaceID() = %q, want %q (env)", got, "env-ws")
}
})
t.Run("agent context without env returns empty, never config", func(t *testing.T) {
t.Setenv("MULTICA_AGENT_ID", "agent-123")
t.Setenv("MULTICA_TASK_ID", "task-456")
t.Setenv("MULTICA_WORKSPACE_ID", "")
got := resolveWorkspaceID(testCmd())
if got != "" {
t.Fatalf("resolveWorkspaceID() = %q, want empty (no silent config fallback in agent context)", got)
}
})
t.Run("task marker alone also counts as agent context", func(t *testing.T) {
t.Setenv("MULTICA_AGENT_ID", "")
t.Setenv("MULTICA_TASK_ID", "task-456")
t.Setenv("MULTICA_WORKSPACE_ID", "")
if got := resolveWorkspaceID(testCmd()); got != "" {
t.Fatalf("resolveWorkspaceID() = %q, want empty", got)
}
})
t.Run("requireWorkspaceID surfaces agent-context error", func(t *testing.T) {
t.Setenv("MULTICA_AGENT_ID", "agent-123")
t.Setenv("MULTICA_TASK_ID", "task-456")
t.Setenv("MULTICA_WORKSPACE_ID", "")
_, err := requireWorkspaceID(testCmd())
if err == nil {
t.Fatal("requireWorkspaceID(): expected error inside agent context with empty env, got nil")
}
if !strings.Contains(err.Error(), "agent execution context") {
t.Fatalf("requireWorkspaceID() error = %q, want it to mention agent execution context", err.Error())
}
})
}
// TestParseCustomEnv covers the --custom-env flag parser used by
// `agent create` and `agent env set`. The flag accepts a JSON object
// of string keys and values; the only clear signal is the explicit
// "{}" (server treats a non-nil empty map as a clear). Empty or
// whitespace-only input must error — that path nearly always means an
// upstream failure rather than a deliberate clear, especially via the
// stdin/file channels.
func TestParseCustomEnv(t *testing.T) {
cases := []struct {
name string
raw string
want map[string]string
wantErr bool
}{
{
name: "single pair",
raw: `{"SECOND_BRAIN_TOKEN":"abc123"}`,
want: map[string]string{"SECOND_BRAIN_TOKEN": "abc123"},
},
{
name: "multiple pairs",
raw: `{"A":"1","B":"2"}`,
want: map[string]string{"A": "1", "B": "2"},
},
{
name: "explicit empty object clears",
raw: `{}`,
want: map[string]string{},
},
{
name: "empty string errors",
raw: ``,
wantErr: true,
},
{
name: "whitespace only errors",
raw: ` `,
wantErr: true,
},
{
name: "not JSON",
raw: `KEY=value`,
wantErr: true,
},
{
name: "JSON array not object",
raw: `["A","B"]`,
wantErr: true,
},
{
name: "non-string value",
raw: `{"A":1}`,
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseCustomEnv(tc.raw)
if tc.wantErr {
if err == nil {
t.Fatalf("parseCustomEnv(%q): expected error, got nil (result=%v)", tc.raw, got)
}
if !strings.Contains(err.Error(), "--custom-env") {
t.Fatalf("parseCustomEnv(%q): error should mention --custom-env, got %v", tc.raw, err)
}
return
}
if err != nil {
t.Fatalf("parseCustomEnv(%q): unexpected error: %v", tc.raw, err)
}
if got == nil {
t.Fatalf("parseCustomEnv(%q): result must be non-nil (empty map, not nil) so the server treats it as clear", tc.raw)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("parseCustomEnv(%q) = %v, want %v", tc.raw, got, tc.want)
}
})
}
}
// TestAgentUpdateNoFieldsErrorPointsAtEnvCommand invokes runAgentUpdate
// with no flags set and asserts the resulting "no fields" error
// directs the user toward the new env subcommand. After MUL-2600 the
// --custom-env* flags are gone from `agent update`; the hint must
// surface their replacement so users discover the new audited path.
func TestAgentUpdateNoFieldsErrorPointsAtEnvCommand(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "test-ws")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_AGENT_ID", "")
t.Setenv("MULTICA_TASK_ID", "")
cmd := &cobra.Command{Use: "update"}
cmd.Flags().String("name", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("instructions", "", "")
cmd.Flags().String("runtime-id", "", "")
cmd.Flags().String("runtime-config", "", "")
cmd.Flags().String("model", "", "")
cmd.Flags().String("custom-args", "", "")
cmd.Flags().String("visibility", "", "")
cmd.Flags().String("status", "", "")
cmd.Flags().Int32("max-concurrent-tasks", 0, "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
err := runAgentUpdate(cmd, []string{"agent-id-placeholder"})
if err == nil {
t.Fatal("runAgentUpdate with no flags: expected 'no fields' error, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "multica agent env set") {
t.Fatalf("no-fields error must direct users to `multica agent env set`; got: %q", msg)
}
}
// TestAgentUpdateDoesNotExposeCustomEnvFlags is the inverse guarantee
// for the above test: if someone re-adds the --custom-env* flags to
// `agent update`, this fails loudly. The /env path is the only
// audited surface and we don't want a silent regression.
func TestAgentUpdateDoesNotExposeCustomEnvFlags(t *testing.T) {
for _, flag := range []string{"custom-env", "custom-env-stdin", "custom-env-file"} {
if agentUpdateCmd.Flag(flag) != nil {
t.Errorf("agent update must NOT expose --%s after MUL-2600; use `multica agent env set` instead", flag)
}
}
}
// 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
// carries secret material — must not appear in user-visible error
// messages.
func TestParseCustomEnvErrorSanitization(t *testing.T) {
// Pick a string that, if echoed, would be obvious. The key is
// that the error must not contain any substring of the raw input.
secretish := `{"SECRET_TOKEN":verySensitiveValue}` // invalid JSON, unquoted value
_, err := parseCustomEnv(secretish)
if err == nil {
t.Fatal("expected parse error for invalid JSON")
}
msg := err.Error()
for _, leak := range []string{"SECRET_TOKEN", "verySensitiveValue"} {
if strings.Contains(msg, leak) {
t.Fatalf("parseCustomEnv error leaked input fragment %q: %q", leak, msg)
}
}
}
// TestParseCustomArgsErrorSanitization mirrors the parseCustomEnv check
// for --custom-args. custom_args is not a dedicated secret channel, but
// callers regularly stuff sensitive values (e.g. "--api-key=…") into the
// list, so json.Unmarshal errors must never echo input fragments here
// either.
func TestParseCustomArgsErrorSanitization(t *testing.T) {
secretish := `["--api-key=verySensitiveValue", oops]` // invalid JSON, bare oops
_, err := parseCustomArgs(secretish)
if err == nil {
t.Fatal("expected parse error for invalid JSON")
}
msg := err.Error()
for _, leak := range []string{"--api-key", "verySensitiveValue", "oops"} {
if strings.Contains(msg, leak) {
t.Fatalf("parseCustomArgs error leaked input fragment %q: %q", leak, msg)
}
}
}
// TestAgentCreateAndEnvSetExposeSecretSafeFlags guarantees the
// --custom-env-stdin and --custom-env-file alternatives stay wired
// up on both commands that accept env input (`agent create` and the
// new `agent env set`). They exist specifically so callers can keep
// secret material out of shell history / 'ps'; regressing either
// surface reopens the foot-gun.
func TestAgentCreateAndEnvSetExposeSecretSafeFlags(t *testing.T) {
for _, flag := range []string{"custom-env-stdin", "custom-env-file"} {
if agentCreateCmd.Flag(flag) == nil {
t.Fatalf("agent create must expose --%s", flag)
}
if agentEnvSetCmd.Flag(flag) == nil {
t.Fatalf("agent env set must expose --%s", flag)
}
}
// The --custom-env help text must warn users that argv is visible
// to shell history / 'ps' — "never logged" alone is misleading.
for _, c := range []struct {
name string
usage string
}{
{"agent create", agentCreateCmd.Flag("custom-env").Usage},
{"agent env set", agentEnvSetCmd.Flag("custom-env").Usage},
} {
low := strings.ToLower(c.usage)
if !strings.Contains(low, "shell history") || !strings.Contains(low, "'ps'") {
t.Fatalf("%s --custom-env usage must warn about shell history and 'ps' exposure; got: %q", c.name, c.usage)
}
}
}
// TestResolveCustomEnv exercises the input-channel resolver: inline
// flag, stdin, file, mutual exclusion, and the "not supplied" path.
func TestResolveCustomEnv(t *testing.T) {
t.Run("not supplied", func(t *testing.T) {
cmd := freshAgentEnvSetCmd()
got, ok, err := resolveCustomEnv(cmd)
if err != nil || ok || got != nil {
t.Fatalf("unset flags: got=%v ok=%v err=%v", got, ok, err)
}
})
t.Run("inline flag", func(t *testing.T) {
cmd := freshAgentEnvSetCmd()
if err := cmd.Flags().Set("custom-env", `{"A":"1"}`); err != nil {
t.Fatal(err)
}
got, ok, err := resolveCustomEnv(cmd)
if err != nil || !ok {
t.Fatalf("inline: ok=%v err=%v", ok, err)
}
if !reflect.DeepEqual(got, map[string]string{"A": "1"}) {
t.Fatalf("inline: got %v", got)
}
})
t.Run("stdin", func(t *testing.T) {
cmd := freshAgentEnvSetCmd()
if err := cmd.Flags().Set("custom-env-stdin", "true"); err != nil {
t.Fatal(err)
}
cmd.SetIn(bytes.NewBufferString(`{"B":"2"}`))
got, ok, err := resolveCustomEnv(cmd)
if err != nil || !ok {
t.Fatalf("stdin: ok=%v err=%v", ok, err)
}
if !reflect.DeepEqual(got, map[string]string{"B": "2"}) {
t.Fatalf("stdin: got %v", got)
}
})
t.Run("file", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "env.json")
if err := os.WriteFile(path, []byte(`{"C":"3"}`), 0o600); err != nil {
t.Fatal(err)
}
cmd := freshAgentEnvSetCmd()
if err := cmd.Flags().Set("custom-env-file", path); err != nil {
t.Fatal(err)
}
got, ok, err := resolveCustomEnv(cmd)
if err != nil || !ok {
t.Fatalf("file: ok=%v err=%v", ok, err)
}
if !reflect.DeepEqual(got, map[string]string{"C": "3"}) {
t.Fatalf("file: got %v", got)
}
})
t.Run("mutually exclusive: inline + stdin", func(t *testing.T) {
cmd := freshAgentEnvSetCmd()
_ = cmd.Flags().Set("custom-env", `{"A":"1"}`)
_ = cmd.Flags().Set("custom-env-stdin", "true")
_, _, err := resolveCustomEnv(cmd)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual-exclusion error, got %v", err)
}
})
t.Run("mutually exclusive: inline + file", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "env.json")
if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
cmd := freshAgentEnvSetCmd()
_ = cmd.Flags().Set("custom-env", `{}`)
_ = cmd.Flags().Set("custom-env-file", path)
_, _, err := resolveCustomEnv(cmd)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual-exclusion error, got %v", err)
}
})
t.Run("mutually exclusive: stdin + file", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "env.json")
if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
cmd := freshAgentEnvSetCmd()
_ = cmd.Flags().Set("custom-env-stdin", "true")
_ = cmd.Flags().Set("custom-env-file", path)
_, _, err := resolveCustomEnv(cmd)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual-exclusion error, got %v", err)
}
})
t.Run("file: missing path surfaces filesystem error", func(t *testing.T) {
cmd := freshAgentEnvSetCmd()
_ = cmd.Flags().Set("custom-env-file", filepath.Join(t.TempDir(), "does-not-exist.json"))
_, _, err := resolveCustomEnv(cmd)
if err == nil || !strings.Contains(err.Error(), "--custom-env-file") {
t.Fatalf("expected --custom-env-file error, got %v", err)
}
})
// Empty input on stdin/file almost always means an upstream failure
// (missing file, set -o pipefail off, etc.), not a deliberate clear.
// The resolver must reject it with a channel-specific error so the
// secret map is never silently wiped.
t.Run("stdin: empty input errors", func(t *testing.T) {
cmd := freshAgentEnvSetCmd()
_ = cmd.Flags().Set("custom-env-stdin", "true")
cmd.SetIn(bytes.NewBufferString(""))
_, _, err := resolveCustomEnv(cmd)
if err == nil || !strings.Contains(err.Error(), "--custom-env-stdin") || !strings.Contains(err.Error(), "{}") {
t.Fatalf("expected --custom-env-stdin empty-input error mentioning '{}', got %v", err)
}
})
t.Run("stdin: whitespace-only input errors", func(t *testing.T) {
cmd := freshAgentEnvSetCmd()
_ = cmd.Flags().Set("custom-env-stdin", "true")
cmd.SetIn(bytes.NewBufferString(" \n\t "))
_, _, err := resolveCustomEnv(cmd)
if err == nil || !strings.Contains(err.Error(), "--custom-env-stdin") {
t.Fatalf("expected --custom-env-stdin empty-input error, got %v", err)
}
})
t.Run("stdin: explicit {} still clears", func(t *testing.T) {
cmd := freshAgentEnvSetCmd()
_ = cmd.Flags().Set("custom-env-stdin", "true")
cmd.SetIn(bytes.NewBufferString("{}"))
got, ok, err := resolveCustomEnv(cmd)
if err != nil || !ok {
t.Fatalf("stdin {}: ok=%v err=%v", ok, err)
}
if !reflect.DeepEqual(got, map[string]string{}) {
t.Fatalf("stdin {}: got %v, want empty map", got)
}
})
t.Run("file: empty contents errors", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.json")
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
cmd := freshAgentEnvSetCmd()
_ = cmd.Flags().Set("custom-env-file", path)
_, _, err := resolveCustomEnv(cmd)
if err == nil || !strings.Contains(err.Error(), "--custom-env-file") || !strings.Contains(err.Error(), "{}") {
t.Fatalf("expected --custom-env-file empty-contents error mentioning '{}', got %v", err)
}
})
t.Run("file: empty path errors instead of being silently swallowed", func(t *testing.T) {
cmd := freshAgentEnvSetCmd()
// Mark the flag as Changed with an empty value — previously this
// was swallowed by the && filePath != "" guard.
_ = cmd.Flags().Set("custom-env-file", "")
if !cmd.Flags().Changed("custom-env-file") {
t.Fatal("setup: expected custom-env-file flag to be marked Changed")
}
_, _, err := resolveCustomEnv(cmd)
if err == nil || !strings.Contains(err.Error(), "--custom-env-file") {
t.Fatalf("expected --custom-env-file empty-path error, got %v", err)
}
})
t.Run("file: explicit {} still clears", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "clear.json")
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
cmd := freshAgentEnvSetCmd()
_ = cmd.Flags().Set("custom-env-file", path)
got, ok, err := resolveCustomEnv(cmd)
if err != nil || !ok {
t.Fatalf("file {}: ok=%v err=%v", ok, err)
}
if !reflect.DeepEqual(got, map[string]string{}) {
t.Fatalf("file {}: got %v, want empty map", got)
}
})
}
// 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
var gotBody map[string][]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("decode request body: %v", err)
}
json.NewEncoder(w).Encode([]map[string]any{
{"id": "skill-a", "name": "Skill A", "description": ""},
{"id": "skill-b", "name": "Skill B", "description": ""},
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "add"}
cmd.Flags().StringSlice("skill-ids", nil, "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("skill-ids", "skill-a,skill-b"); err != nil {
t.Fatal(err)
}
if err := runAgentSkillsAdd(cmd, []string{"agent-123"}); err != nil {
t.Fatalf("runAgentSkillsAdd: %v", err)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %s, want POST", gotMethod)
}
if gotPath != "/api/agents/agent-123/skills/add" {
t.Fatalf("path = %q, want additive endpoint", gotPath)
}
if !reflect.DeepEqual(gotBody["skill_ids"], []string{"skill-a", "skill-b"}) {
t.Fatalf("skill_ids body = %v", gotBody["skill_ids"])
}
}
func TestAgentSkillsAddRequiresSkillIDs(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "add"}
cmd.Flags().StringSlice("skill-ids", nil, "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
err := runAgentSkillsAdd(cmd, []string{"agent-123"})
if err == nil || !strings.Contains(err.Error(), "--skill-ids is required") {
t.Fatalf("expected required --skill-ids error, got %v", err)
}
}
// TestAgentAvatarHappyPath verifies the full flow: agent pre-check, file upload,
// and avatar update all succeed.
func TestAgentAvatarHappyPath(t *testing.T) {
dir := t.TempDir()
pngPath := filepath.Join(dir, "avatar.png")
if err := os.WriteFile(pngPath, []byte("fake-png-data"), 0o644); err != nil {
t.Fatalf("write test png: %v", err)
}
var gotPaths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPaths = append(gotPaths, r.URL.Path)
switch r.URL.Path {
case "/api/agents/agent-123":
if r.Method == http.MethodGet {
json.NewEncoder(w).Encode(map[string]any{
"id": "agent-123",
"name": "TestAgent",
})
} else if r.Method == http.MethodPut {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
if body["avatar_url"] != "https://cdn.example.com/avatars/agent-123.png" {
t.Errorf("unexpected avatar_url: %v", body["avatar_url"])
}
json.NewEncoder(w).Encode(map[string]any{
"id": "agent-123",
"name": "TestAgent",
"avatar_url": "https://cdn.example.com/avatars/agent-123.png",
})
} else {
t.Errorf("unexpected method: %s", r.Method)
}
case "/api/upload-file":
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
json.NewEncoder(w).Encode(map[string]any{
"id": "att-456",
"url": "https://cdn.example.com/avatars/agent-123.png",
})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", pngPath); err != nil {
t.Fatal(err)
}
if err := runAgentAvatar(cmd, []string{"agent-123"}); err != nil {
t.Fatalf("runAgentAvatar: %v", err)
}
if len(gotPaths) != 3 {
t.Fatalf("expected 3 API calls, got %d: %v", len(gotPaths), gotPaths)
}
}
// TestAgentAvatarUnsupportedFormat rejects files with unsupported extensions.
func TestAgentAvatarUnsupportedFormat(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
dir := t.TempDir()
txtPath := filepath.Join(dir, "avatar.txt")
if err := os.WriteFile(txtPath, []byte("not an image"), 0o644); err != nil {
t.Fatalf("write test txt: %v", err)
}
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", txtPath); err != nil {
t.Fatal(err)
}
err := runAgentAvatar(cmd, []string{"agent-123"})
if err == nil {
t.Fatal("expected error for unsupported format, got nil")
}
if !strings.Contains(err.Error(), "unsupported file format") {
t.Fatalf("expected 'unsupported file format' error, got: %v", err)
}
}
// TestAgentAvatarOversizedFile rejects files larger than 5MB.
func TestAgentAvatarOversizedFile(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
dir := t.TempDir()
bigPath := filepath.Join(dir, "big.png")
// Write slightly more than 5MB.
if err := os.WriteFile(bigPath, make([]byte, 5<<20+1), 0o644); err != nil {
t.Fatalf("write test file: %v", err)
}
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", bigPath); err != nil {
t.Fatal(err)
}
err := runAgentAvatar(cmd, []string{"agent-123"})
if err == nil {
t.Fatal("expected error for oversized file, got nil")
}
if !strings.Contains(err.Error(), "file too large") {
t.Fatalf("expected 'file too large' error, got: %v", err)
}
}
// TestAgentAvatarMissingAgent returns 404 when the agent does not exist.
func TestAgentAvatarMissingAgent(t *testing.T) {
dir := t.TempDir()
pngPath := filepath.Join(dir, "avatar.png")
if err := os.WriteFile(pngPath, []byte("fake-png-data"), 0o644); err != nil {
t.Fatalf("write test png: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/agents/missing-agent" {
w.WriteHeader(http.StatusNotFound)
io.WriteString(w, "agent not found")
return
}
http.NotFound(w, r)
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", pngPath); err != nil {
t.Fatal(err)
}
err := runAgentAvatar(cmd, []string{"missing-agent"})
if err == nil {
t.Fatal("expected error for missing agent, got nil")
}
if !strings.Contains(err.Error(), "get agent") {
t.Fatalf("expected 'get agent' error, got: %v", err)
}
}
// TestAgentAvatarUploadFailure handles upload endpoint returning an error.
func TestAgentAvatarUploadFailure(t *testing.T) {
dir := t.TempDir()
pngPath := filepath.Join(dir, "avatar.png")
if err := os.WriteFile(pngPath, []byte("fake-png-data"), 0o644); err != nil {
t.Fatalf("write test png: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/agents/agent-123":
json.NewEncoder(w).Encode(map[string]any{"id": "agent-123", "name": "TestAgent"})
case "/api/upload-file":
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, "upload failed")
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", pngPath); err != nil {
t.Fatal(err)
}
err := runAgentAvatar(cmd, []string{"agent-123"})
if err == nil {
t.Fatal("expected error for upload failure, got nil")
}
if !strings.Contains(err.Error(), "upload avatar") {
t.Fatalf("expected 'upload avatar' error, got: %v", err)
}
}
// TestAgentAvatarUpdateFailure handles the PUT update endpoint returning an error.
func TestAgentAvatarUpdateFailure(t *testing.T) {
dir := t.TempDir()
pngPath := filepath.Join(dir, "avatar.png")
if err := os.WriteFile(pngPath, []byte("fake-png-data"), 0o644); err != nil {
t.Fatalf("write test png: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/agents/agent-123":
if r.Method == http.MethodPut {
w.WriteHeader(http.StatusForbidden)
io.WriteString(w, "forbidden")
return
}
json.NewEncoder(w).Encode(map[string]any{"id": "agent-123", "name": "TestAgent"})
case "/api/upload-file":
json.NewEncoder(w).Encode(map[string]any{
"id": "att-456",
"url": "https://cdn.example.com/avatars/agent-123.png",
})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", pngPath); err != nil {
t.Fatal(err)
}
err := runAgentAvatar(cmd, []string{"agent-123"})
if err == nil {
t.Fatal("expected error for update failure, got nil")
}
if !strings.Contains(err.Error(), "update agent avatar") {
t.Fatalf("expected 'update agent avatar' error, got: %v", err)
}
}
// TestAgentAvatarMissingFileFlag rejects when --file is not provided.
func TestAgentAvatarMissingFileFlag(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
err := runAgentAvatar(cmd, []string{"agent-123"})
if err == nil {
t.Fatal("expected error when --file is missing, got nil")
}
if !strings.Contains(err.Error(), "--file is required") {
t.Fatalf("expected '--file is required' error, got: %v", err)
}
}
// TestAgentAvatarNonexistentFile rejects when the file path does not exist.
func TestAgentAvatarNonexistentFile(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", filepath.Join(t.TempDir(), "does-not-exist.png")); err != nil {
t.Fatal(err)
}
err := runAgentAvatar(cmd, []string{"agent-123"})
if err == nil {
t.Fatal("expected error for non-existent file, got nil")
}
if !strings.Contains(err.Error(), "file not found") {
t.Fatalf("expected 'file not found' error, got: %v", err)
}
}
// TestAgentAvatarSizeBoundary verifies that exactly 5MB passes and 5MB+1 fails.
func TestAgentAvatarSizeBoundary(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Run("exactly 5MB passes", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ok.png")
if err := os.WriteFile(path, make([]byte, 5<<20), 0o644); err != nil {
t.Fatalf("write test file: %v", err)
}
// The command will fail later because no server is running, but
// the size validation itself should pass.
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", path); err != nil {
t.Fatal(err)
}
err := runAgentAvatar(cmd, []string{"agent-123"})
// We expect an error from the network call, not from size validation.
if err != nil && strings.Contains(err.Error(), "file too large") {
t.Fatalf("size validation should pass for exactly-5MB file, got: %v", err)
}
})
t.Run("5MB plus one byte is rejected", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "big.png")
if err := os.WriteFile(path, make([]byte, 5<<20+1), 0o644); err != nil {
t.Fatalf("write test file: %v", err)
}
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", path); err != nil {
t.Fatal(err)
}
err := runAgentAvatar(cmd, []string{"agent-123"})
if err == nil {
t.Fatal("expected error for 5MB+1 file, got nil")
}
if !strings.Contains(err.Error(), "file too large") {
t.Fatalf("expected 'file too large' error, got: %v", err)
}
})
}
// TestAgentAvatarCaseInsensitiveExtension verifies uppercase extensions are accepted.
func TestAgentAvatarCaseInsensitiveExtension(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
for _, ext := range []string{"avatar.PNG", "avatar.JPG", "avatar.JPEG", "avatar.GIF", "avatar.WEBP"} {
t.Run(ext, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ext)
if err := os.WriteFile(path, []byte("fake"), 0o644); err != nil {
t.Fatalf("write test file: %v", err)
}
cmd := &cobra.Command{Use: "avatar"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("output", "json", "")
cmd.Flags().String("profile", "", "")
if err := cmd.Flags().Set("file", path); err != nil {
t.Fatal(err)
}
err := runAgentAvatar(cmd, []string{"agent-123"})
// We expect an error from the network call, not from extension validation.
if err != nil && strings.Contains(err.Error(), "unsupported file format") {
t.Fatalf("extension validation should pass for %s, got: %v", ext, err)
}
})
}
}
// TestAgentGetTableIncludesAvatarURL verifies the table output includes AVATAR_URL.
func TestAgentGetTableIncludesAvatarURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/agents/agent-123" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
json.NewEncoder(w).Encode(map[string]any{
"id": "agent-123",
"name": "TestAgent",
"status": "active",
"runtime_mode": "cloud",
"visibility": "workspace",
"avatar_url": "https://cdn.example.com/avatar.png",
"description": "A test agent",
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("output", "table", "")
cmd.Flags().String("profile", "", "")
// Capture stdout.
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := runAgentGet(cmd, []string{"agent-123"})
w.Close()
os.Stdout = old
out, _ := io.ReadAll(r)
if err != nil {
t.Fatalf("runAgentGet: %v", err)
}
if !strings.Contains(string(out), "AVATAR_URL") {
t.Fatalf("table output missing AVATAR_URL header: %s", string(out))
}
if !strings.Contains(string(out), "https://cdn.example.com/avatar.png") {
t.Fatalf("table output missing avatar_url value: %s", string(out))
}
}