Files
multica/server/cmd/multica/cmd_agent_test.go
Magnus Handeland 9b55b2a9ce feat(cli): add --custom-env flag to agent create/update (#1518)
* feat(cli): add --custom-env to agent create/update

Adds a JSON-object flag on `multica agent create` and `multica agent
update` that writes the agent's `custom_env` map via the existing
handler API. Needed so runtime bearer tokens (e.g. SECOND_BRAIN_TOKEN)
can be provisioned from the CLI without falling back to curl or
admin-only UI access.

- `--custom-env '{"KEY":"value"}'` → sets the map.
- `--custom-env '{}'` or `--custom-env ''` → clears the map on update
  (server treats a non-nil empty map as "clear all entries").
- Omitted flag → no change.
- Help text flags the value as secret material and never logged.
- Table-driven tests cover the parser (valid, clear, invalid JSON,
  wrong shape) plus flag discoverability on both commands.

* feat(cli): add --custom-env-{stdin,file}; sanitize parse errors

Security review of the --custom-env flag (PR #1518) surfaced two issues:

1. Secrets on the command line leak via shell history and /proc/<pid>/cmdline
   regardless of CLI logging. Add --custom-env-stdin and --custom-env-file
   as mutually-exclusive alternatives, and update the --custom-env help
   text to warn about shell history / 'ps' exposure so the "never logged"
   claim is no longer misleading.

2. parseCustomEnv wrapped json.Unmarshal errors with %w; SyntaxError /
   UnmarshalTypeError can surface fragments of the (secret) input. Return
   a fixed, content-free message instead.

Refactor the body-assembly blocks in both agentCreateCmd and
agentUpdateCmd to go through a single resolveCustomEnv helper so the
three input channels behave identically. Tests cover every channel,
mutual exclusion, error sanitization, and help-text wording.

* fix(cli): require explicit '{}' to clear custom_env; sanitize --custom-args errors

Address PR #1518 review feedback from @Bohan-J:

1. parseCustomEnv now errors on empty/whitespace input. The clear signal
   is the explicit '{}' object only. The previous behavior silently wiped
   the secret map when an upstream pipe was empty (cat missing.json |
   ... --custom-env-stdin without set -o pipefail) or when --custom-env-file
   pointed at an empty file. resolveCustomEnv emits channel-specific error
   messages (e.g. "--custom-env-stdin: empty input; pass '{}' to clear").

2. Drop the '&& filePath != ""' guard so an explicit --custom-env-file ""
   surfaces an error instead of being silently ignored.

3. Rewrite TestAgentUpdateNoFieldsMentionsCustomEnv into
   TestAgentUpdateNoFieldsErrorMentionsAllCustomEnvFlags — the body now
   actually runs runAgentUpdate with no flags and asserts the resulting
   "no fields" error names all three --custom-env channels.

4. Extract parseCustomArgs helper. Replace the '%w'-wrapped json error
   with a content-free message, mirroring parseCustomEnv. Although
   custom_args is not a dedicated secret channel, callers regularly stuff
   sensitive values like "--api-key=..." into it, so json.Unmarshal must
   never echo input fragments. Adds TestParseCustomArgsErrorSanitization.

Also adds resolveCustomEnv subtests for stdin/file empty-input, empty
file contents, empty file path, and explicit '{}' positive cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Implementer (Multica Agent) <implementer@multica-agent.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:32:55 +08:00

493 lines
17 KiB
Go

package main
import (
"bytes"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
// freshAgentUpdateCmd returns a standalone cobra.Command with the three
// --custom-env* flags registered identically to agentUpdateCmd, so tests
// can mutate flag state without leaking across subtests (the package-level
// agentUpdateCmd has no Reset).
func freshAgentUpdateCmd() *cobra.Command {
c := &cobra.Command{Use: "update"}
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 both
// `agent create` and `agent update`. 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 on update 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)
}
})
}
}
// TestAgentUpdateNoFieldsErrorMentionsAllCustomEnvFlags actually invokes
// runAgentUpdate with no flags set and asserts the resulting "no fields"
// error mentions all three --custom-env channels by name. This guards
// against the discoverability regression we'd see if a future edit
// dropped one of the flag names from the hint.
func TestAgentUpdateNoFieldsErrorMentionsAllCustomEnvFlags(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", "")
// Build a fresh command with the same flag surface as agentUpdateCmd
// but without the package-level state, so cmd.Flags().Changed(...)
// returns false for every field and runAgentUpdate falls into the
// "no fields to update" branch.
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("custom-env", "", "")
cmd.Flags().Bool("custom-env-stdin", false, "")
cmd.Flags().String("custom-env-file", "", "")
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()
// "--custom-env (" matches the bare flag specifically, not its -stdin /
// -file siblings, so we can prove all three names are present.
for _, want := range []string{"--custom-env (", "--custom-env-stdin", "--custom-env-file"} {
if !strings.Contains(msg, want) {
t.Fatalf("no-fields error must mention %q; got: %q", want, msg)
}
}
}
// 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)
}
}
}
// TestAgentCreateAndUpdateExposeSecretSafeFlags guarantees the
// --custom-env-stdin and --custom-env-file alternatives stay wired
// up on both commands. They exist specifically so callers can keep
// secret material out of shell history / 'ps'; regressing either
// surface reopens the foot-gun.
func TestAgentCreateAndUpdateExposeSecretSafeFlags(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 agentUpdateCmd.Flag(flag) == nil {
t.Fatalf("agent update 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 update", agentUpdateCmd.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 := freshAgentUpdateCmd()
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 := freshAgentUpdateCmd()
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 := freshAgentUpdateCmd()
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 := freshAgentUpdateCmd()
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 := freshAgentUpdateCmd()
_ = 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 := freshAgentUpdateCmd()
_ = 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 := freshAgentUpdateCmd()
_ = 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 := freshAgentUpdateCmd()
_ = 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 := freshAgentUpdateCmd()
_ = 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 := freshAgentUpdateCmd()
_ = 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 := freshAgentUpdateCmd()
_ = 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 := freshAgentUpdateCmd()
_ = 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 := freshAgentUpdateCmd()
// 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 := freshAgentUpdateCmd()
_ = 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)
}
})
}