mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 04:08:53 +02:00
* 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>
1206 lines
40 KiB
Go
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))
|
|
}
|
|
}
|