Files
multica/server/pkg/agent/launch_test.go
T
e1be1625cb MUL-5959: feat(agent): add Dim (DimCode) ACP runtime (#6675)
* feat(agent): add Dim (DimCode) ACP runtime

Add Dim (dimcode, the `dim` CLI) as a first-party agent runtime, driven
over the ACP (Agent Client Protocol) transport via `dim acp`.

Key integration points:
- New dim backend (pkg/agent/dim.go): spawns `dim acp`, performs the
  ACP initialize/session/new handshake, and raises the runtime's
  hardcoded read-only permission preset to full-access (plus agent mode)
  via session/set_config_option before the first prompt — without it every
  file write is denied by a capability rule. Model override uses
  session/set_model; the model catalog is read from session/new.
- Session resume is intentionally skipped: Dim binds sessions to the
  creating process, so a later process's session/load is rejected with
  "held by another process" even after a clean session/close. The backend
  always starts a fresh session and reports ResumeRejected so the daemon
  classifies the run correctly. A best-effort session/close keeps Dim's
  own session list free of orphaned entries.
- Registration: SupportedTypes whitelist, New() factory, launch header,
  daemon probe (MULTICA_DIM_PATH / MULTICA_DIM_MODEL), default agent
  command name, protocol_family CHECK migration (255), model discovery,
  MCP config support, metrics label, and runtime display.
- Docs/UI: provider logo, landing i18n (en/zh/ja/ko), providers and
  install-agent-runtime docs, README, CLI_AND_DAEMON, SELF_HOSTING.
- Tests: dim backend unit tests (fresh session + resume behavior), shared
  ACP deliverable case, logo and MCP-support tests.

* feat(views): use official DimCode app icon for Dim provider logo

Replace the placeholder inline SVG with the official DimCode desktop
client app icon (from /opt/DimAgent/resources/build/icon.png, resized
to 128px), imported as a static asset the same way the Qwen Code mark
is handled.

* test(agent): add real dim ACP smoke tests (agentintegration)

Two gated tests behind MULTICA_RUN_REAL_AGENT_SMOKE=1:

- TestDimRealACPSmoke: full end-to-end against real `dim acp` —
  initialize, session/new, set_config_option (permission/mode), prompt,
  and assert completed output.
- TestDimRealResumeRejected: pass a fake ResumeSessionID, assert the
  backend starts a fresh session (no session/load), and reports
  ResumeRejected=true so the daemon classifies the run correctly.

Verified against dimcode 0.3.2 and 0.3.8.

* fix: renumber dim migration 265 → 271 (upstream added 265-270)

* fix: renumber dim migration to 272 (upstream added 271)

* fix(agent): dim cross-run resume via session/load + review fixes

Address review feedback on the Dim ACP runtime:

- #1 Deliver runtime brief to Dim: add dim to the AGENTS.md provider
  list (verified dim 0.3.8+ reads AGENTS.md from the session cwd).
- #2 Cross-run session continuity: dim 0.3.10+ releases its per-process
  session lock ~5s after the owning process exits, so resume now goes
  through the standard ACP session/load (same as traecli/kiro/grok)
  instead of always starting fresh. A loaded session retains its
  permission/mode, so set_config_option runs only on fresh sessions.
  Add a two-run regression (TestDimRealCrossRunResume) proving run B
  recalls context established only in run A.
- #3 Process lifecycle races: the deferred cleanup now cancels the run
  context before cmd.Wait (a child ignoring stdin EOF no longer hangs
  Result delivery) with a bounded force-kill fallback; the success path
  waits for the final prompt notification with a grace window instead
  of a non-blocking read that could miss the last message.
- #4 Isolate model discovery by executable path (discoveryCacheKey).
- #6 Real smoke test now writes a sentinel file to prove full-access is
  effective; a best-effort session/close is sent before teardown.
- #7 gofmt.

* test(agent): add dim process-lifecycle regressions (review #3)

- TestDimCleanupKillsHangingChild: a child that ignores stdin EOF and
  SIGTERM after an early failure is force-killed within
  dimProcessWaitTimeout, so Result still closes.
- TestDimPromptMissingNotificationStillCompletes: when session/prompt
  returns without stopReason (onPromptDone never fires), the bounded
  final-notification wait falls through and Result completes instead of
  hanging.

* fix(migrations): renumber dim runtime_profile migration 272 → 273 (upstream added 272)

* fix(agent): review round 2 — process tree, quiescence, fail-closed, version check

Address all remaining blockers from the second review:

- #1 Require dim >= 0.3.10 (version check at initialize); bounded retry
  on session/load when the lock is not yet released instead of silently
  starting fresh; integration test no longer sleeps before resume.
- #2 Process-tree cleanup: configureProcessGroup + signalProcessGroup +
  waitProcessGroupGone; cmd.Cancel returns nil so we own all signalling;
  regression asserts the descendant PID is gone.
- #3 Notification quiescence: onActivity + waitForACPNotificationQuiescence
  (same as hermes); Dim equivalent of the late-final-notification test.
- #4 Permission fail-closed: set_config_option runs on BOTH fresh and
  resumed sessions; on config failure, session/close is sent before
  returning so a partially configured session is not resumed; regression.
- #5 Migration renumbered 273 → 274 (upstream added 273).
- #6 AGENTS.md mapping regression, two-executable cache isolation test,
  session/close unit assertion, smoke test now executes a command.
- #7 README.md + CLI_AND_DAEMON.md updated with 22 CLIs including Dim.

* chore: remove temporary review actions doc (not for PR)

* fix(migrations): renumber dim migration 274 → 310 (upstream added 274-309)

* review: fix retry break scope, dedup isACPSessionNotFound comment, update stale notes

* fix(agent): pass *exec.Cmd to signalProcessGroup/waitProcessGroupGone (upstream signature change)

* fix(agent): close session on set_model failure + resume regression test

Audit-found gaps from self-review:
- set_model failure now sends session/close before returning (same as
  set_config_option failure) — reviewer #4 said 'permission, mode, or
  model'.
- Add TestDimConfigFailThenResumeReestablishes: run A config fails →
  session/close sent → run B resumes and re-applies set_config_option
  (fail-closed), completing successfully.
- Fix two stale comments that contradicted the code (config block now
  runs on both fresh and resumed sessions).

* fix(agent): add WaitDelay, use labeled break in retry loop

Self-audit improvements:
- Add cmd.WaitDelay=10s for consistency with claude.go (hard backstop
  if a process somehow survives SIGKILL).
- Use labeled break (break loadRetry) so runCtx cancellation during the
  retry delay exits the for loop directly, not just the select.
- Update PR description: migration 273→310, set_config_option now
  re-applied on both fresh and resumed sessions.

* fix(migrations): renumber dim migration 310 → 313 (upstream added 310-312)

* fix(migrations): remove stale 313 dim migration (replaced by 314 after upstream added dsh at 313)

* fix: DSH compatibility + thinking levels + reviewer round 3

Systematic fix of all 39 items from the compatibility gap analysis:

Rebase + migration:
- Rebase to latest upstream/main (resolves all DSH conflicts)
- Migration 314_runtime_profile_add_dim (whitelist includes both dsh + dim)
- Remove stale 313 dim migration

DSH + Dim coexistence (provider lists, counts, docs):
- SupportedTypes, config.go, metrics labels: both dsh + dim
- i18n (4 files): count 23, lists include DSH + Dim + Oh-My-Pi
- README.md/README.zh.md: count 23
- CLI_AND_DAEMON.md: 23-row table
- environment-variables.mdx (4 langs): MULTICA_DIM_PATH/MODEL callout
- display.ts, mcp-support.ts, types/agent.ts, provider-logo.tsx: both dsh+dim

Thinking levels (was MISSING — dim supports thought_level):
- dim.go: call applyACPEffortOption after set_model
- thinking.go: add dim to acpCatalogThinkingProviders
- dim.go: retain sessionResult for effort option

Reviewer round 3:
- Version check fail-closed: empty/malformed → reject (not allow)
- Cache test through ListModels call site with fake executables
- set_model failure regression test
- Test companions: sidecar_manifest_test, runtime_config_test add dim

Code quality:
- agent.go: fix unreachable duplicate return (rebase artifact)
- dim.go: labeled break in retry loop, WaitDelay, session/close on set_model fail

* fix: deliverable test fake version + provider-logo rebase artifact

* fix(migrations): renumber dim migration 314 → 315 (upstream added 314_workspace_mcp_config)

* fix(migrations): renumber dim migration 315 → 319 (upstream added 315-318)

* fix: Windows process tree, MinVersions, retry tests, migration 327

Address all 5 remaining blockers from review round 4:

1. Windows process-tree ownership: use startOwnedProcessTree instead of
   cmd.Start(), releaseProcessGroup in cleanup defer. This attaches the
   child to a Job Object on Windows (no-op on Unix), ensuring descendants
   are captured and terminated.

2. Migration renumbered 319 → 327 (upstream added 319-326).

3. Add dim: 0.3.10 to MinVersions in version.go so the daemon registers
   old Dim binaries as offline and refuses triggers (defense in depth on
   top of the ACP agentInfo.version check in dim.go).

4. Session-lock retry contract tests: success after bounded retries,
   exhaustion without falling through to session/new. Fake script
   supports DIM_LOAD_HELD_N (held for N calls then succeed) and
   DIM_LOAD_HELD_ALWAYS (always held).

5. README.md:206 20→23 runtimes. PR description updated.

* test: add missing reviewer-requested regressions (round 4)

- Windows Job Object regression: dim_windows_test.go proves the Dim
  backend captures and terminates descendants via startOwnedProcessTree
  (Windows-only build tag; companion to TestStartOwnedProcessTree).
- MinVersions registration: add dim test cases to TestCheckMinVersion
  (0.3.10 ok, 0.3.9 rejected, invalid rejected).
- Retry cancellation: TestDimSessionLoadRetryCancelled cancels the
  context during the retry delay, asserts no session/new fallback.

* fix: sync upstream Command signature changes + restore dim additions

Upstream changed ListModels/discoverXxxModels/detectCLIVersion to accept
Command (struct with Path+Prefix) instead of string. The previous rebase
kept our old-signature versions, causing widespread compile failures.

Restored all affected files from upstream, then re-applied dim-specific
additions:
- agent.go: dim in SupportedTypes, New(), launchHeaders
- hermes.go: isACPHeldByProcess + -32002 in isACPSessionNotFound
- thinking.go: dim in acpCatalogThinkingProviders
- models.go: dim case in ListModels + discoverDimModels
- version.go: dim in MinVersions
- All dim test files preserved (dim_test.go, dim_integration_test.go,
  dim_windows_test.go, models_test.go dim cache test)

* fix: rebase to latest main, migration 327→341, resolve DetectVersion conflict

* fix: remove unused os/exec import in dim_windows_test.go

* fix(migrations): renumber dim migration 341 → 342 (upstream added 341)

* fix: use Command.exec instead of exec.CommandContext (upstream GH #7046)

Upstream's TestOnlyLaunchGoSpawnsRuntimeProcesses requires all backends
to build processes through Command.exec (launch.go), so a custom runtime's
fixed_args are carried into the subprocess. Dim was the only backend still
using exec.CommandContext directly.

* fix: launch prefix policy + remove empty test files + deterministic retry tests

Address all 4 findings from review round 5:

1. Add "dim": dimBlockedArgs to launchPrefixBlockedArgs so protocol-breaking
   flags (--help/--auth-setup/--remote) are filtered from fixed_args.
2. Remove 77 zero-byte *_test.go files accidentally added at repo root.
3. Make retry tests deterministic: TestDimSessionLoadRetryCancelled waits
   for the first session/load request before cancelling (no fixed sleep);
   TestDimSessionLoadRetryExhausted asserts exactly 4 load attempts.
4. PR description will be updated separately.

* fix: rebase to latest main, migration 342→343 (upstream added mcode at 342)

* chore: trigger CI

* chore: refresh PR

* fix: restore mcode that was lost during merge conflict resolution

Merge took 'ours' side which predated mcode. Restore mcode in:
- SupportedTypes, New() factory, launchHeaders
- agent_supported_types_test.go want map
- metrics/labels.go
- config.go defaultAgentCommandNames
- migration 343 up/down whitelists

* fix: restore mcode probe in agents_probe.go + agent-cli-command-names.txt

* fix: restore all remaining mcode references lost during merge

Files fixed:
- README.md: mcode row in runtimes table
- mcp-support.test.ts: mcode assertion
- config.go: mcode in Agents comment + error message
- runtime_config.go: mcode in AGENTS.md case
- version.go: mcode in MinVersions
- version_test.go: mcode test cases

Verified: no file has fewer mcode references than upstream.

* fix: restore mcode in environment-variables docs + CLI_AND_DAEMON ACP list

- environment-variables.{mdx,ja,ko,zh}: restored 'MiniMax Code' in the
  QwenPaw model-variable sentence
- CLI_AND_DAEMON.md: added MiniMax Code to ACP-family list + loadSession
  fallback description

Verified: every changed file now has >= mcode references vs upstream.

* fix(migrations): renumber dim migration 343 → 344 (upstream added 343)

* fix(migrations): renumber dim migration 344 → 348 (upstream added 344-347 plugin migrations)

* fix(migrations): update down migration comment 344 → 348

* chore: retrigger CI (flaky TestOpenclawDiscoveryCacheConcurrentPreparations — upstream test, passes locally x5)

* fix(migrations): renumber dim migration 348 → 352 (upstream added 348-351)

* fix: Dim launch-prefix regression test + version.go comment repair (review #6)

- Add TestDimLaunchPrefixFiltersBlockedFlags: proves allowed prefix
  reaches command before acp, and --help/--auth-setup/--remote/-h are
  stripped from Dim's launch prefix.
- Add dim to TestLaunchPrefixReachesACPFamilies family list.
- Repair version.go: dim's cross-run session/load comment was appended
  to mcode entry during a rebase conflict; restore two independent
  accurate comments.

* fix(migrations): update down migration comment 348 → 352

* chore: retrigger CI (backend-tests stuck 50+ min)

* fix(migrations): renumber dim migration 352 → 362 (upstream added 352-361)

* fix: use logAgentCommand for dim argv logging (upstream redaction requirement)

Upstream's TestOnlyLaunchGoLogsAgentCommandArgs enforces that all
runtime argv logging goes through Config.logAgentCommand so sensitive
args are redacted. dim.go was still using Logger.Info directly.

* fix(migrations): renumber dim migration to 370

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 12:54:46 +08:00

796 lines
29 KiB
Go

package agent
import (
"bytes"
"context"
"go/ast"
"go/parser"
"go/token"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// prefixIndex reports where the first token of want starts inside argv, or -1.
func prefixIndex(argv, want []string) int {
for i := 0; i+len(want) <= len(argv); i++ {
if strings.Join(argv[i:i+len(want)], "\x00") == strings.Join(want, "\x00") {
return i
}
}
return -1
}
// TestLaunchPrefixPrecedesProtocolFlags is GH #7046 in one assertion. `ccms
// start q36` only reaches the real Claude binary after its `start` subcommand
// is selected, so a `-p` that arrives first is parsed by the wrapper, which
// rejects it outright ("unknown option '-p'"). The subcommand tokens have to
// come first.
func TestLaunchPrefixPrecedesProtocolFlags(t *testing.T) {
t.Parallel()
cfg := Config{ExecutablePath: "ccms", LaunchPrefix: []string{"start", "q36"}, Logger: slog.Default()}
argv := cfg.commandAt("ccms").Argv(buildClaudeArgs(ExecOptions{}, slog.Default())...)
subcommand := prefixIndex(argv, []string{"start", "q36"})
if subcommand != 0 {
t.Fatalf("fixed_args must be the argv prefix, found at %d: %v", subcommand, argv)
}
protocol := prefixIndex(argv, []string{"-p"})
if protocol < 0 {
t.Fatalf("protocol flags missing from argv: %v", argv)
}
if protocol < subcommand+2 {
t.Fatalf("-p at %d must come after the `start q36` prefix: %v", protocol, argv)
}
}
// TestLaunchPrefixPlacesFlagStyleWrappersFirst pins where a flag-style prefix
// lands. It asserts the argv Multica builds, not that any particular third-party
// parser accepts the new position — most treat the two orders as equivalent,
// but a CLI that separates global from subcommand flags may not, which is why
// the docs call the move out instead of promising it is invisible.
func TestLaunchPrefixPlacesFlagStyleWrappersFirst(t *testing.T) {
t.Parallel()
cfg := Config{ExecutablePath: "agent", LaunchPrefix: []string{"--model", "composer-2.5"}, Logger: slog.Default()}
argv := cfg.commandAt("agent").Argv(buildClaudeArgs(ExecOptions{}, slog.Default())...)
if idx := prefixIndex(argv, []string{"--model", "composer-2.5"}); idx != 0 {
t.Fatalf("flag-style fixed_args must still lead the argv, found at %d: %v", idx, argv)
}
}
// TestLaunchPrefixLosesToExplicitAgentModel pins the precedence flip this
// change makes deliberately. Before GH #7046 the prefix sat last and won every
// last-wins parse, so a runtime pinned to `--model composer-2.5` silently
// overrode the model the member picked in the UI. Prefix-first inverts that:
// the more specific per-agent choice is the later token and wins.
func TestLaunchPrefixLosesToExplicitAgentModel(t *testing.T) {
t.Parallel()
cfg := Config{ExecutablePath: "agent", LaunchPrefix: []string{"--model", "composer-2.5"}, Logger: slog.Default()}
argv := cfg.commandAt("agent").Argv(buildClaudeArgs(ExecOptions{Model: "claude-opus-4-7"}, slog.Default())...)
runtimeModel := prefixIndex(argv, []string{"--model", "composer-2.5"})
agentModel := prefixIndex(argv, []string{"--model", "claude-opus-4-7"})
if runtimeModel < 0 || agentModel < 0 {
t.Fatalf("expected both --model occurrences: %v", argv)
}
if agentModel <= runtimeModel {
t.Fatalf("the agent's explicit model must be the later --model (runtime at %d, agent at %d): %v",
runtimeModel, agentModel, argv)
}
}
// TestFilterLaunchPrefixKeepsSubcommandsDropsProtocolFlags covers the
// blocklist split: positional tokens are the command's identity and pass
// through, flag tokens compete with the daemon's own protocol arguments and do
// not. A blocked flag takes its value token with it.
func TestFilterLaunchPrefixKeepsSubcommandsDropsProtocolFlags(t *testing.T) {
t.Parallel()
got := filterLaunchPrefix(
[]string{"start", "q36", "-p", "--output-format", "text", "--model", "composer-2.5"},
"claude", slog.Default())
want := []string{"start", "q36", "--model", "composer-2.5"}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("filterLaunchPrefix = %v, want %v", got, want)
}
}
// TestNewFiltersLaunchPrefixOnce proves the filter runs at the single point
// that knows the protocol family, so no backend has to remember to call it.
func TestNewFiltersLaunchPrefixOnce(t *testing.T) {
t.Parallel()
backend, err := New("claude", Config{
ExecutablePath: "ccms",
LaunchPrefix: []string{"start", "q36", "--output-format", "text"},
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("New(claude): %v", err)
}
got := backend.(*claudeBackend).cfg.LaunchPrefix
want := []string{"start", "q36"}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("New did not filter the launch prefix: got %v, want %v", got, want)
}
}
// TestLaunchPrefixReachesACPFamilies is the regression guard for the half of
// the bug the report did not name: fixed_args used to ride on ExtraArgs, which
// the ACP backends never read, so on those families it was silently dropped
// rather than merely misordered. The prefix must land ahead of the hardcoded
// `acp` subcommand.
func TestLaunchPrefixReachesACPFamilies(t *testing.T) {
t.Parallel()
for _, family := range []string{"kimi", "hermes", "kiro", "reasonix", "qwenpaw", "dim"} {
t.Run(family, func(t *testing.T) {
t.Parallel()
cfg := Config{LaunchPrefix: []string{"start", "q36"}, Logger: slog.Default()}
argv := cfg.commandAt("wrapper").Argv("acp")
if idx := prefixIndex(argv, []string{"start", "q36", "acp"}); idx != 0 {
t.Fatalf("%s: prefix must precede the acp subcommand, got %v", family, argv)
}
})
}
}
// TestDiscoveryCacheKeySeparatesLaunchPrefixes: one binary behind two
// different prefixes is two different catalogs.
func TestDiscoveryCacheKeySeparatesLaunchPrefixes(t *testing.T) {
t.Parallel()
q36 := discoveryCacheKey("claude", Command{Path: "/usr/local/bin/ccms", Prefix: []string{"start", "q36"}})
opus := discoveryCacheKey("claude", Command{Path: "/usr/local/bin/ccms", Prefix: []string{"start", "opus"}})
bare := discoveryCacheKey("claude", Command{Path: "/usr/local/bin/ccms"})
if q36 == opus {
t.Fatalf("two prefixes on one binary share a cache key: %q", q36)
}
if q36 == bare || opus == bare {
t.Fatalf("a prefixed command must not share the bare binary's key: %q / %q / %q", q36, opus, bare)
}
}
// TestCommandArgvNeverAliasesItsInputs: Argv results get appended to by
// callers, so a shared backing array would let one invocation's arguments
// leak into the next.
func TestCommandArgvNeverAliasesItsInputs(t *testing.T) {
t.Parallel()
prefix := []string{"start", "q36"}
cmd := Command{Path: "ccms", Prefix: prefix}
first := cmd.Argv("-p")
first = append(first, "mutated")
second := cmd.Argv("--version")
if strings.Join(cmd.Prefix, "\x00") != "start\x00q36" {
t.Fatalf("Argv mutated the command's prefix: %v", cmd.Prefix)
}
if prefixIndex(second, []string{"mutated"}) >= 0 {
t.Fatalf("a later Argv saw an earlier call's append: %v", second)
}
}
func TestRedactAgentCommandArgsPreservesOnlySafeFlagNames(t *testing.T) {
t.Parallel()
overlongFlag := "--" + strings.Repeat("a", maxLoggedAgentCommandFlagLen)
args := []string{
"--api-key", "api-key-secret",
"--dash-prefixed-secret", "-sTk9xQZ-secretvalue",
"--token=token-secret",
"--header", "Authorization: Bearer header-secret",
"-c", `model_providers.example.api_key="config-secret"`,
"--future-secret", "future-value-secret",
"prompt-secret",
"--verbose",
"-not-a-short-flag",
overlongFlag,
}
want := []string{
"--api-key", redactedAgentCommandArg,
"--dash-prefixed-secret", redactedAgentCommandArg,
"--token",
"--header", redactedAgentCommandArg,
"-c", redactedAgentCommandArg,
"--future-secret", redactedAgentCommandArg,
redactedAgentCommandArg,
"--verbose",
redactedAgentCommandArg,
redactedAgentCommandArg,
}
got := redactAgentCommandArgs(args, nil)
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("redactAgentCommandArgs = %v, want %v", got, want)
}
}
func TestTrustedAgentCommandPositionalsFollowSourceIndexes(t *testing.T) {
t.Parallel()
invocationArgs := []string{"acp", "--api-key", "-sTk9xQZ-secretvalue", "acp"}
finalArgs := []string{
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "wrapper.ps1",
"start", "q36",
"acp", "--api-key", "-sTk9xQZ-secretvalue", "acp",
}
cfg := Config{LaunchPrefix: []string{"start", "q36"}}
trusted := cfg.trustedAgentCommandPositionals(finalArgs,
newAgentCommandLogArgs(invocationArgs, trustAgentCommandPositional(0, "acp")))
got := redactAgentCommandArgs(finalArgs, trusted)
want := []string{
redactedAgentCommandArg, redactedAgentCommandArg, redactedAgentCommandArg, redactedAgentCommandArg, redactedAgentCommandArg,
redactedAgentCommandArg, redactedAgentCommandArg,
"acp", "--api-key", redactedAgentCommandArg, redactedAgentCommandArg,
}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("source-aware redaction = %v, want %v", got, want)
}
}
func TestLogAgentCommandRedactsTextAndJSON(t *testing.T) {
t.Parallel()
args := []string{
"--api-key", "api-key-secret",
"--dash-prefixed-secret", "-sTk9xQZ-secretvalue",
"--token=token-secret",
"--header", "Authorization: Bearer header-secret",
"-c", `model_providers.example.api_key="config-secret"`,
"--future-secret", "future-value-secret",
"prompt-secret",
}
secrets := []string{
"api-key-secret",
"-sTk9xQZ-secretvalue",
"token-secret",
"Authorization: Bearer header-secret",
"config-secret",
"future-value-secret",
"prompt-secret",
}
for _, tc := range []struct {
name string
handler func(*bytes.Buffer) slog.Handler
}{
{"text", func(buf *bytes.Buffer) slog.Handler { return slog.NewTextHandler(buf, nil) }},
{"json", func(buf *bytes.Buffer) slog.Handler { return slog.NewJSONHandler(buf, nil) }},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
cfg := Config{Logger: slog.New(tc.handler(&buf)), provider: "codex"}
cmd := &exec.Cmd{Path: "/opt/multica/bin/codex", Args: append([]string{"codex"}, args...)}
cfg.logAgentCommandWithPrompt(cmd, newAgentCommandLogArgs(args), 123)
output := buf.String()
for _, secret := range secrets {
if strings.Contains(output, secret) {
t.Errorf("%s log exposed %q: %s", tc.name, secret, output)
}
}
for _, diagnostic := range []string{
"agent command", "provider", "codex", "/opt/multica/bin/codex",
"--api-key", "--token", "--header", "-c", "--future-secret",
redactedAgentCommandArg, "arg_count", "prompt_bytes",
} {
if !strings.Contains(output, diagnostic) {
t.Errorf("%s log omitted diagnostic %q: %s", tc.name, diagnostic, output)
}
}
})
}
}
func TestBackendFactoriesSetCommandLogProvider(t *testing.T) {
t.Parallel()
claude, err := New("claude", Config{Logger: slog.Default()})
if err != nil {
t.Fatalf("New(claude): %v", err)
}
if got := claude.(*claudeBackend).cfg.provider; got != "claude" {
t.Fatalf("claude log provider = %q, want claude", got)
}
omp, err := NewRuntime("omp", Config{Logger: slog.Default()})
if err != nil {
t.Fatalf("NewRuntime(omp): %v", err)
}
if got := omp.(*piBackend).cfg.provider; got != "omp" {
t.Fatalf("omp log provider = %q, want omp", got)
}
}
// TestOnlyLaunchGoSpawnsRuntimeProcesses is the structural half of this fix.
//
// Distributed opt-in is what let ExtraArgs rot: it was honoured by six of
// twenty-one backends, and MULTICA_QWENPAW_ARGS shipped plumbed-but-dropped
// because nothing failed when a backend forgot to read it. Re-establishing the
// same convention for the launch prefix would rot the same way, so the rule is
// mechanical instead: every runtime process in this package is constructed in
// launch.go, which is the one place that applies the prefix.
//
// A new backend that calls os/exec directly fails here rather than silently
// reintroducing GH #7046.
func TestOnlyLaunchGoSpawnsRuntimeProcesses(t *testing.T) {
t.Parallel()
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read package dir: %v", err)
}
fset := token.NewFileSet()
var offenders []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
// launch.go owns the boundary. The platform invocation rewrites resolve
// a PowerShell host with exec.LookPath but never spawn the runtime.
if name == "launch.go" {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
file, err := parser.ParseFile(fset, name, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
pkg, ok := sel.X.(*ast.Ident)
if !ok || pkg.Name != "exec" {
return true
}
if sel.Sel.Name != "Command" && sel.Sel.Name != "CommandContext" {
return true
}
offenders = append(offenders,
fset.Position(call.Pos()).String()+": exec."+sel.Sel.Name)
return true
})
}
if len(offenders) > 0 {
t.Fatalf("runtime processes must be built through Command.exec / Command.execVia in launch.go, "+
"otherwise a custom runtime's fixed_args are dropped (GH #7046). Offending sites:\n%s",
strings.Join(offenders, "\n"))
}
}
func containsRuntimeArgReference(expr ast.Expr) bool {
found := false
ast.Inspect(expr, func(n ast.Node) bool {
switch node := n.(type) {
case *ast.SelectorExpr:
if node.Sel.Name == "Args" {
found = true
return false
}
case *ast.Ident:
switch strings.ToLower(node.Name) {
case "args", "cmdargs", "argv":
found = true
return false
}
}
return !found
})
return found
}
// TestOnlyLaunchGoLogsAgentCommandArgs keeps raw argv out of adapter-local log
// calls. Every runtime process log must flow through Config.logAgentCommand in
// launch.go, where values are redacted consistently for text and JSON handlers.
// The guard checks both common field labels and the expressions themselves, so
// renaming an "args" field to "argv" cannot bypass it while still passing
// cmd.Args, args, cmdArgs, or argv to a logger.
func TestOnlyLaunchGoLogsAgentCommandArgs(t *testing.T) {
t.Parallel()
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read package dir: %v", err)
}
fset := token.NewFileSet()
var offenders []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || name == "launch.go" {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
file, err := parser.ParseFile(fset, name, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
switch sel.Sel.Name {
case "Debug", "Info", "Warn", "Error", "Log", "LogAttrs":
default:
return true
}
for _, arg := range call.Args {
literal, ok := arg.(*ast.BasicLit)
if ok && literal.Kind == token.STRING &&
(literal.Value == `"args"` || literal.Value == `"argv"` || literal.Value == `"agent command"`) {
offenders = append(offenders, fset.Position(call.Pos()).String())
break
}
if containsRuntimeArgReference(arg) {
offenders = append(offenders, fset.Position(call.Pos()).String())
break
}
}
return true
})
}
if len(offenders) > 0 {
t.Fatalf("runtime argv logs must use Config.logAgentCommand in launch.go so values are redacted. Offending sites:\n%s",
strings.Join(offenders, "\n"))
}
}
// TestClaudeBackendLaunchesWrapperSubcommandFirst is the end-to-end proof:
// a real subprocess, spawned through the real backend, records the argv it was
// handed. The fake wrapper stands in for `ccms` and never touches a
// user-installed CLI.
func TestClaudeBackendLaunchesWrapperSubcommandFirst(t *testing.T) {
t.Parallel()
dir := t.TempDir()
argsFile := filepath.Join(dir, "argv")
wrapper := filepath.Join(dir, "ccms")
writeTestExecutable(t, wrapper, []byte("#!/bin/sh\n"+
"for arg in \"$@\"; do printf '%s\\n' \"$arg\" >> \""+argsFile+"\"; done\n"+
"exit 0\n"))
backend, err := New("claude", Config{
ExecutablePath: wrapper,
LaunchPrefix: []string{"start", "q36"},
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("New(claude): %v", err)
}
session, err := backend.Execute(context.Background(), "hello", ExecOptions{Cwd: dir})
if err != nil {
t.Fatalf("Execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
<-session.Result
raw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("wrapper recorded no argv: %v", err)
}
argv := strings.Fields(string(bytes.TrimSpace(raw)))
if idx := prefixIndex(argv, []string{"start", "q36"}); idx != 0 {
t.Fatalf("wrapper was launched as %v; `start q36` must lead", argv)
}
if idx := prefixIndex(argv, []string{"-p"}); idx < 2 {
t.Fatalf("-p reached the wrapper before its subcommand: %v", argv)
}
}
// TestLaunchPrefixOutranksExtraArgs locks the full argv contract that replaced
// "fixed_args are prepended to ExtraArgs" (#4408). The old arrangement put the
// prefix inside ExtraArgs, which lands *after* the protocol flags — and which
// fifteen of the twenty-one backends never read at all. The prefix now has its
// own slot ahead of everything.
func TestLaunchPrefixOutranksExtraArgs(t *testing.T) {
t.Parallel()
cfg := Config{LaunchPrefix: []string{"start", "q36"}, Logger: slog.Default()}
argv := cfg.commandAt("ccms").Argv(buildClaudeArgs(ExecOptions{
ExtraArgs: []string{"--extra-arg"},
CustomArgs: []string{"--custom-arg"},
}, slog.Default())...)
prefix := prefixIndex(argv, []string{"start", "q36"})
protocol := prefixIndex(argv, []string{"-p"})
extra := prefixIndex(argv, []string{"--extra-arg"})
custom := prefixIndex(argv, []string{"--custom-arg"})
if prefix != 0 {
t.Fatalf("launch prefix must lead: %v", argv)
}
if !(prefix < protocol && protocol < extra && extra < custom) {
t.Fatalf("want prefix < protocol < extra < custom, got %d/%d/%d/%d: %v",
prefix, protocol, extra, custom, argv)
}
}
// TestBuiltinRuntimeIdentitiesFilterLaunchPrefix: NewRuntime composes New, so
// a runtime identity (omp) inherits the same prefix policy as its family.
func TestBuiltinRuntimeIdentitiesFilterLaunchPrefix(t *testing.T) {
t.Parallel()
backend, err := ResolveBackend("omp", Config{
ExecutablePath: "wrapper",
LaunchPrefix: []string{"start", "q36", "-p"},
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("ResolveBackend(omp): %v", err)
}
got := backend.(*piBackend).cfg.LaunchPrefix
if strings.Join(got, "\x00") != "start\x00q36" {
t.Fatalf("runtime identity did not inherit prefix filtering: %v", got)
}
}
// TestCodexLaunchPrefixCannotShadowManagedConfig: prefix-first wins ordinary
// flag conflicts on purpose, but Codex `-c key=value` beats the daemon-written
// config.toml from any argv position. A fixed_args entry aimed at a managed
// namespace has to be removed, not merely outranked.
func TestCodexLaunchPrefixCannotShadowManagedConfig(t *testing.T) {
t.Parallel()
cmd := Command{Prefix: []string{
"proxy", "run",
"-c", "mcp_servers.evil.command=/bin/sh",
"-c", "shell_environment_policy.inherit=all",
"--model", "gpt-5",
}}
filtered := cmd.
withFilteredPrefix(func(p []string) []string { return filterCodexShellEnvConfigOverrides(p, slog.Default()) }).
withFilteredPrefix(func(p []string) []string { return filterCodexCustomConfigOverrides(p, slog.Default()) })
want := []string{"proxy", "run", "--model", "gpt-5"}
if strings.Join(filtered.Prefix, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("prefix = %v, want the managed-namespace overrides dropped and %v kept",
filtered.Prefix, want)
}
}
// TestFilterLaunchPrefixKeepsPositionalCollidingWithSubcommand is Elon's
// must-fix 2. The blocklists are shared with custom_args, and several carry
// positional tokens — hermes/qwenpaw block `acp`, traecli blocks `acp` and
// `serve`, grok blocks `agent`, `stdio` and `serve`. Those entries exist to
// stop someone re-issuing the backend's own subcommand through custom_args.
// A launch prefix is the opposite case: the token is the command's identity,
// and dropping it rewrites the operator's command into a different one.
func TestFilterLaunchPrefixKeepsPositionalCollidingWithSubcommand(t *testing.T) {
t.Parallel()
cases := []struct {
family string
prefix []string
want []string
}{
{"hermes", []string{"acp", "tenant"}, []string{"acp", "tenant"}},
{"qwenpaw", []string{"acp", "tenant"}, []string{"acp", "tenant"}},
{"traecli", []string{"serve", "acp"}, []string{"serve", "acp"}},
{"grok", []string{"agent", "stdio"}, []string{"agent", "stdio"}},
{"grok", []string{"leader", "headless"}, []string{"leader", "headless"}},
// Flags in the same prefix are still filtered; grok blocks -p.
{"grok", []string{"agent", "-p", "stdio"}, []string{"agent", "stdio"}},
}
for _, tc := range cases {
t.Run(tc.family+"/"+strings.Join(tc.prefix, "_"), func(t *testing.T) {
t.Parallel()
got := filterLaunchPrefix(tc.prefix, tc.family, slog.Default())
if strings.Join(got, "\x00") != strings.Join(tc.want, "\x00") {
t.Fatalf("filterLaunchPrefix(%v, %s) = %v, want %v", tc.prefix, tc.family, got, tc.want)
}
})
}
}
// TestFilterLaunchPrefixConsumesBlockedFlagValue: dropping a blocked flag must
// take its value with it, or the leftover value token would be read as a
// subcommand — precisely the kind of token this filter otherwise preserves.
func TestFilterLaunchPrefixConsumesBlockedFlagValue(t *testing.T) {
t.Parallel()
got := filterLaunchPrefix(
[]string{"start", "--permission-mode", "ask", "q36", "--output-format=text"},
"claude", slog.Default())
want := []string{"start", "q36"}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("filterLaunchPrefix = %v, want %v", got, want)
}
}
// TestDimLaunchPrefixFiltersBlockedFlags proves the Dim launch-prefix safety
// policy: allowed positional tokens reach the command ahead of the hardcoded
// `acp` subcommand, while protocol-breaking flags (--help, --auth-setup,
// --remote, -h, acp) are stripped. Without this the fixed_args regression
// this round fixed could return silently.
func TestDimLaunchPrefixFiltersBlockedFlags(t *testing.T) {
t.Parallel()
// Allowed positional prefix tokens survive and precede `acp`.
cfg := Config{LaunchPrefix: []string{"start", "q36"}, Logger: slog.Default()}
argv := cfg.commandAt("wrapper").Argv("acp")
if idx := prefixIndex(argv, []string{"start", "q36", "acp"}); idx != 0 {
t.Fatalf("dim: allowed prefix must precede the acp subcommand, got %v", argv)
}
// Protocol-breaking flags are removed from the prefix.
got := filterLaunchPrefix(
[]string{"start", "--help", "--auth-setup", "--remote", "-h", "q36"},
"dim", slog.Default())
want := []string{"start", "q36"}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("dim: blocked flags must be stripped, got %v, want %v", got, want)
}
}
// TestHermesLaunchArgvMatchesBackendAssembly is the root of the second-round
// Hermes finding: the daemon must resolve the profile from the argv the backend
// actually builds, not from a concatenation that leaves out `acp`.
//
// With fixed_args `--model` and custom_args `-p research`, the two disagree.
// `--model` is a value-taking flag, so the approximation `--model -p research`
// consumes `-p` as its value and finds no selection at all, while the real
// `--model acp -p research` skips `--model acp` and selects `research`. Seeding
// the overlay from the first answer while the process runs the second is a
// silent config mismatch.
func TestHermesLaunchArgvMatchesBackendAssembly(t *testing.T) {
t.Parallel()
prefix := []string{"--model"}
custom := []string{"-p", "research"}
argv := HermesLaunchArgv(prefix, custom, slog.Default())
if want := []string{"--model", "acp", "-p", "research"}; strings.Join(argv, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("HermesLaunchArgv = %v, want %v", argv, want)
}
// It must equal what the backend hands the launch boundary.
backend := Command{Prefix: prefix}.Argv(hermesCLIArgs(custom, slog.Default())...)
if strings.Join(argv, "\x00") != strings.Join(backend, "\x00") {
t.Fatalf("resolver argv %v diverges from backend argv %v", argv, backend)
}
sel := ParseHermesProfileArgs(argv)
if !sel.Found || sel.Name != "research" {
t.Fatalf("selection = %+v, want research — the profile the process really reads", sel)
}
// The approximation this replaced is what got it wrong.
if naive := ParseHermesProfileArgs(append(append([]string{}, prefix...), custom...)); naive.Found {
t.Fatalf("expected the prefix++custom approximation to disagree, got %+v", naive)
}
}
// TestStripHermesProfileSelectorsSpansRegionBoundary: a launch prefix ending in
// a bare `-p` captures the backend's own `acp` token as its profile value.
// Neither region holds a complete selection, so stripping them separately
// leaves the selector live and the task walks out of the overlay.
func TestStripHermesProfileSelectorsSpansRegionBoundary(t *testing.T) {
t.Parallel()
prefix, custom := StripHermesProfileSelectors([]string{"-p"}, []string{"research", "--yolo"}, slog.Default())
if sel := ParseHermesProfileArgs(Command{Prefix: prefix}.Argv(hermesCLIArgsFrom(custom)...)); sel.Found {
t.Fatalf("launched argv still selects %q: prefix=%v custom=%v", sel.Name, prefix, custom)
}
if len(prefix) != 0 {
t.Fatalf("the straddling `-p` must be removed from the prefix, got %v", prefix)
}
if strings.Join(custom, "\x00") != strings.Join([]string{"research", "--yolo"}, "\x00") {
t.Fatalf("custom args must survive intact, got %v", custom)
}
}
// TestStripHermesProfileSelectorsRemovesEveryOccurrence: hermes honours the
// first selection and ignores the rest, so removing one promotes the next.
// With prefix and custom args configured separately, several selections is
// ordinary configuration rather than a user mistake.
func TestStripHermesProfileSelectorsRemovesEveryOccurrence(t *testing.T) {
t.Parallel()
prefix, custom := StripHermesProfileSelectors(
[]string{"wrapper-sub", "-p", "research"},
[]string{"--profile=ops", "--model", "x", "-p", "third"},
slog.Default())
argv := Command{Prefix: prefix}.Argv(hermesCLIArgsFrom(custom)...)
if sel := ParseHermesProfileArgs(argv); sel.Found {
t.Fatalf("a selection survived: %+v in %v", sel, argv)
}
if strings.Join(prefix, "\x00") != "wrapper-sub" {
t.Fatalf("prefix = %v, want the command identity kept and the selector gone", prefix)
}
if strings.Join(custom, "\x00") != strings.Join([]string{"--model", "x"}, "\x00") {
t.Fatalf("custom = %v, want unrelated args kept", custom)
}
}
// TestStripHermesProfileSelectorsLeavesInvalidSelectionAlone: a value hermes
// itself discards redirects nothing, so removing it would only mangle the
// user's argv.
func TestStripHermesProfileSelectorsLeavesInvalidSelectionAlone(t *testing.T) {
t.Parallel()
prefix, custom := StripHermesProfileSelectors(nil, []string{"-p", "NOT_A_PROFILE"}, slog.Default())
if len(prefix) != 0 {
t.Fatalf("prefix = %v, want empty", prefix)
}
if strings.Join(custom, "\x00") != strings.Join([]string{"-p", "NOT_A_PROFILE"}, "\x00") {
t.Fatalf("custom = %v, want the discarded selection left untouched", custom)
}
}
// TestCodexLaunchPrefixCannotOverrideExplicitFastMode is Elon's must-fix 3.
// `--disable fast_mode` beats `--enable fast_mode` regardless of argv order and
// `-c features.fast_mode=false` beats config.toml from any position, so being
// first does not make the prefix lose. Both spellings have to be removed.
func TestCodexLaunchPrefixCannotOverrideExplicitFastMode(t *testing.T) {
t.Parallel()
for _, prefix := range [][]string{
{"proxy", "run", "--disable", "fast_mode"},
{"proxy", "run", "--disable=fast_mode"},
{"proxy", "run", "-c", "features.fast_mode=false"},
} {
t.Run(strings.Join(prefix, "_"), func(t *testing.T) {
t.Parallel()
got := stripCodexFastModeConflicts(prefix, slog.Default())
want := []string{"proxy", "run"}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("stripCodexFastModeConflicts(%v) = %v, want %v", prefix, got, want)
}
// The managed enable is appended once, by buildCodexArgs, not here.
if prefixIndex(got, []string{"--enable"}) >= 0 {
t.Fatalf("the strip helper must not append the managed enable: %v", got)
}
})
}
}
// TestEnforceCodexFastModeStillAppendsEnableOnce guards the split: pulling the
// removal out of enforceCodexFastMode must not change what it produces.
func TestEnforceCodexFastModeStillAppendsEnableOnce(t *testing.T) {
t.Parallel()
got := enforceCodexFastMode([]string{"--disable", "fast_mode", "--model", "gpt-5"}, slog.Default())
want := []string{"--model", "gpt-5", "--enable", "fast_mode"}
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("enforceCodexFastMode = %v, want %v", got, want)
}
}