mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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]>
439 lines
17 KiB
Go
439 lines
17 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
const redactedAgentCommandArg = "<redacted>"
|
|
|
|
const maxLoggedAgentCommandFlagLen = 64
|
|
|
|
// agentCommandLogArgs describes the adapter-assembled argv handed to the
|
|
// launch boundary. trustedPositionals contains indexes into invocationArgs for
|
|
// literal subcommands owned by the adapter; every other positional remains
|
|
// redacted. Keeping index-and-literal assertions instead of a global string
|
|
// allowlist makes trust follow the argument's source, so an identical token
|
|
// supplied through custom_args is still treated as sensitive.
|
|
type agentCommandLogArgs struct {
|
|
invocationArgs []string
|
|
trustedPositionals []trustedAgentCommandPositional
|
|
}
|
|
|
|
type trustedAgentCommandPositional struct {
|
|
index int
|
|
value string
|
|
}
|
|
|
|
func trustAgentCommandPositional(index int, value string) trustedAgentCommandPositional {
|
|
return trustedAgentCommandPositional{index: index, value: value}
|
|
}
|
|
|
|
func newAgentCommandLogArgs(invocationArgs []string, trustedPositionals ...trustedAgentCommandPositional) agentCommandLogArgs {
|
|
return agentCommandLogArgs{
|
|
invocationArgs: invocationArgs,
|
|
trustedPositionals: append([]trustedAgentCommandPositional(nil), trustedPositionals...),
|
|
}
|
|
}
|
|
|
|
// Command is the identity of a runtime CLI: the executable Multica spawns plus
|
|
// the argv prefix that belongs to the command itself rather than to any single
|
|
// invocation.
|
|
//
|
|
// A custom runtime profile (MUL-3284) is configured as `command_name` plus
|
|
// `fixed_args`, and for a wrapper like `ccms start q36` those two fixed_args
|
|
// tokens are part of *what the program is*, not options passed to it. The
|
|
// wrapper only reaches the real Claude binary after its `start` subcommand has
|
|
// been selected, so `-p` and the rest of the stream-json protocol flags are
|
|
// meaningless — and rejected outright by a Commander-style parser — until the
|
|
// prefix has been consumed (GH #7046).
|
|
//
|
|
// So the prefix goes directly after the executable and before everything a
|
|
// backend builds:
|
|
//
|
|
// <Path> <Prefix...> <protocol args...> <ExtraArgs...> <CustomArgs...>
|
|
//
|
|
// Subcommand-style prefixes work only in this position. Flag-style prefixes
|
|
// generally parse the same either way — `agent --model composer-2.5 -p …` and
|
|
// `agent -p … --model composer-2.5` are equivalent to most parsers — so
|
|
// prefix-first is the broader of the two orders, not a universally safe one: a
|
|
// CLI that separates global flags from subcommand flags can still care where a
|
|
// flag lands. That residual risk is why the position change is called out in
|
|
// the runtime docs rather than treated as invisible.
|
|
//
|
|
// The zero Command is a bare executable with no prefix, which is what every
|
|
// built-in runtime uses.
|
|
type Command struct {
|
|
// Path is the executable to spawn — a PATH lookup name or an absolute path.
|
|
Path string
|
|
// Prefix is the argv prefix that belongs to the command, already filtered
|
|
// by FilterLaunchPrefix. Never mutate a Command's Prefix in place; Argv
|
|
// copies it precisely so a caller cannot alias it.
|
|
Prefix []string
|
|
// logger reports prefix/argument conflicts at the moment a process is
|
|
// built. Optional: a zero Command logs nothing.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewCommand builds a Command from a resolved executable path and a launch
|
|
// prefix. Callers inside this package normally go through Config.commandAt
|
|
// instead, which carries the prefix the daemon configured for the runtime.
|
|
//
|
|
// The prefix should already have been through FilterLaunchPrefix; pass a raw
|
|
// fixed_args list only where no protocol channel exists to protect, such as a
|
|
// one-shot `--version` probe.
|
|
func NewCommand(path string, prefix []string) Command {
|
|
return Command{Path: path, Prefix: append([]string(nil), prefix...)}
|
|
}
|
|
|
|
// Argv returns the full argument vector for one invocation: the command's own
|
|
// prefix followed by args. The result never aliases either input, so callers
|
|
// may append to it freely.
|
|
func (c Command) Argv(args ...string) []string {
|
|
argv := make([]string, 0, len(c.Prefix)+len(args))
|
|
argv = append(argv, c.Prefix...)
|
|
argv = append(argv, args...)
|
|
return argv
|
|
}
|
|
|
|
// exec builds the *exec.Cmd that runs this command with args.
|
|
//
|
|
// This is the single place in the package where a runtime CLI process is
|
|
// constructed. Everything the daemon launches or probes — task execution,
|
|
// `--version` detection, model discovery — routes through here or through
|
|
// execVia, so a new backend cannot forget the launch prefix and silently
|
|
// reintroduce GH #7046. TestOnlyLaunchGoSpawnsRuntimeProcesses enforces it.
|
|
func (c Command) exec(ctx context.Context, args ...string) *exec.Cmd {
|
|
warnLaunchPrefixOverlap(c.Prefix, args, c.logger)
|
|
return exec.CommandContext(ctx, c.Path, c.Argv(args...)...)
|
|
}
|
|
|
|
// invocationChooser is the shape of the per-tool platform launch rewrites
|
|
// (chooseCursorInvocation, chooseCopilotInvocation, choosePiInvocation). On
|
|
// Windows they replace argv[0] with a PowerShell host or a bundled native
|
|
// binary and wrap the CLI's own argv; on other platforms they pass through.
|
|
type invocationChooser func(execName, lookedUp string, args []string, logger *slog.Logger) (string, []string)
|
|
|
|
// execVia builds the *exec.Cmd for a runtime CLI whose launch goes through a
|
|
// platform invocation chooser.
|
|
//
|
|
// The prefix is applied to the CLI's own argv *before* the chooser runs, never
|
|
// to the final argv[0]: on Windows the chooser may prepend its own tokens
|
|
// (`-NoProfile -ExecutionPolicy Bypass -File cursor-agent.ps1`) that must stay
|
|
// in front. `ccms start q36` has to land after the PowerShell preamble, on the
|
|
// wrapped CLI, not ahead of PowerShell's own flags.
|
|
func (c Command) execVia(ctx context.Context, choose invocationChooser, lookedUp string, args []string, logger *slog.Logger) (*exec.Cmd, string, []string) {
|
|
warnLaunchPrefixOverlap(c.Prefix, args, logger)
|
|
argv0, cmdArgs := choose(c.Path, lookedUp, c.Argv(args...), logger)
|
|
return exec.CommandContext(ctx, argv0, cmdArgs...), argv0, cmdArgs
|
|
}
|
|
|
|
// withFilteredPrefix returns a copy of the command whose prefix has been
|
|
// through fn.
|
|
//
|
|
// Moving the prefix to the front makes it lose an ordinary last-wins flag
|
|
// conflict, which is the point. But a few settings are not decided by argv
|
|
// order at all: Codex's `-c key=value` beats the daemon-written config.toml
|
|
// from any position, so a `-c mcp_servers.…` in fixed_args shadows managed MCP
|
|
// whether it sits first or last. Those need the same removal that ExtraArgs
|
|
// and CustomArgs already get, and only the backend knows which they are.
|
|
func (c Command) withFilteredPrefix(fn func([]string) []string) Command {
|
|
c.Prefix = fn(c.Prefix)
|
|
return c
|
|
}
|
|
|
|
// cacheKey identifies the command for the model-discovery memo. The prefix is
|
|
// part of it because two profiles can share one binary and still enumerate
|
|
// different catalogs — `ccms start q36` and `ccms start opus` are the same
|
|
// executable with different models behind it, and keying on the path alone
|
|
// would serve the first one's catalog to the second (see discoveryCacheKey).
|
|
func (c Command) cacheKey() string {
|
|
if len(c.Prefix) == 0 {
|
|
return c.Path
|
|
}
|
|
return c.Path + "\x00" + strings.Join(c.Prefix, "\x00")
|
|
}
|
|
|
|
// String renders the command the way a user wrote it in the Runtimes UI, for
|
|
// logs and error messages.
|
|
func (c Command) String() string {
|
|
if len(c.Prefix) == 0 {
|
|
return c.Path
|
|
}
|
|
return c.Path + " " + strings.Join(c.Prefix, " ")
|
|
}
|
|
|
|
// commandAt pairs a resolved executable path with this runtime's launch
|
|
// prefix. Backends call it after their own PATH resolution and default-binary
|
|
// fallback, which is why the path is a parameter rather than read from
|
|
// Config.ExecutablePath.
|
|
func (c Config) commandAt(path string) Command {
|
|
return Command{Path: path, Prefix: c.LaunchPrefix, logger: c.Logger}
|
|
}
|
|
|
|
// logAgentCommand is the only boundary allowed to record runtime process
|
|
// arguments. It works from the final exec.Cmd so launch prefixes and
|
|
// platform-specific rewrites are represented, but never records argument
|
|
// values: flag names and adapter-owned literal subcommands remain useful for
|
|
// diagnostics, inline values are removed, and every other positional/value
|
|
// token is replaced with a marker.
|
|
func (c Config) logAgentCommand(cmd *exec.Cmd, source agentCommandLogArgs) {
|
|
c.logAgentCommandFields(cmd, source, 0, false)
|
|
}
|
|
|
|
// logAgentCommandWithPrompt adds only a typed prompt byte count to the safe
|
|
// command log. Keeping it separate from logAgentCommand avoids an open-ended
|
|
// optional field channel that could accidentally carry prompt content.
|
|
func (c Config) logAgentCommandWithPrompt(cmd *exec.Cmd, source agentCommandLogArgs, promptBytes int) {
|
|
c.logAgentCommandFields(cmd, source, promptBytes, true)
|
|
}
|
|
|
|
func (c Config) logAgentCommandFields(cmd *exec.Cmd, source agentCommandLogArgs, promptBytes int, includePromptBytes bool) {
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
logger := c.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
|
|
args := []string{}
|
|
if len(cmd.Args) > 1 {
|
|
args = cmd.Args[1:]
|
|
}
|
|
trustedPositionals := c.trustedAgentCommandPositionals(args, source)
|
|
fields := []any{
|
|
"provider", c.provider,
|
|
"exec", cmd.Path,
|
|
"args", redactAgentCommandArgs(args, trustedPositionals),
|
|
"arg_count", len(args),
|
|
}
|
|
if includePromptBytes {
|
|
fields = append(fields, "prompt_bytes", promptBytes)
|
|
}
|
|
logger.Info("agent command", fields...)
|
|
}
|
|
|
|
// trustedAgentCommandPositionals maps source indexes onto the final exec.Cmd
|
|
// argv. Platform launch rewrites may prepend a PowerShell host and wrapper
|
|
// arguments, so the original launch prefix plus adapter argv must match the
|
|
// final suffix before any positional is trusted. A mismatch fails closed.
|
|
func (c Config) trustedAgentCommandPositionals(finalArgs []string, source agentCommandLogArgs) map[int]struct{} {
|
|
originalLen := len(c.LaunchPrefix) + len(source.invocationArgs)
|
|
start := len(finalArgs) - originalLen
|
|
if start < 0 {
|
|
return nil
|
|
}
|
|
for i, arg := range c.LaunchPrefix {
|
|
if finalArgs[start+i] != arg {
|
|
return nil
|
|
}
|
|
}
|
|
invocationStart := start + len(c.LaunchPrefix)
|
|
for i, arg := range source.invocationArgs {
|
|
if finalArgs[invocationStart+i] != arg {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
trusted := make(map[int]struct{}, len(source.trustedPositionals))
|
|
for _, positional := range source.trustedPositionals {
|
|
if positional.index < 0 || positional.index >= len(source.invocationArgs) ||
|
|
source.invocationArgs[positional.index] != positional.value {
|
|
continue
|
|
}
|
|
trusted[invocationStart+positional.index] = struct{}{}
|
|
}
|
|
return trusted
|
|
}
|
|
|
|
// redactAgentCommandArgs preserves only syntactically plausible flag names and
|
|
// source-proven adapter subcommands while removing every value. Single-dash
|
|
// flags must be one ASCII letter; this keeps a token such as
|
|
// "-sTk9xQZ-secretvalue" from being mistaken for a flag. Long flags are
|
|
// length-bounded and lose inline values. All other tokens become <redacted>.
|
|
func redactAgentCommandArgs(args []string, trustedPositionals map[int]struct{}) []string {
|
|
redacted := make([]string, len(args))
|
|
for i, arg := range args {
|
|
if _, ok := trustedPositionals[i]; ok {
|
|
redacted[i] = arg
|
|
continue
|
|
}
|
|
if flag, ok := safeAgentCommandFlagName(arg); ok {
|
|
redacted[i] = flag
|
|
continue
|
|
}
|
|
redacted[i] = redactedAgentCommandArg
|
|
}
|
|
return redacted
|
|
}
|
|
|
|
func safeAgentCommandFlagName(arg string) (string, bool) {
|
|
flag := arg
|
|
if equals := strings.IndexByte(flag, '='); equals > 0 {
|
|
flag = flag[:equals]
|
|
}
|
|
if len(flag) == 2 && flag[0] == '-' && isASCIIAlpha(flag[1]) {
|
|
return flag, true
|
|
}
|
|
if len(flag) < 3 || len(flag) > maxLoggedAgentCommandFlagLen || !strings.HasPrefix(flag, "--") || !isASCIIAlpha(flag[2]) {
|
|
return "", false
|
|
}
|
|
for i := 3; i < len(flag); i++ {
|
|
ch := flag[i]
|
|
if !isASCIIAlpha(ch) && (ch < '0' || ch > '9') && ch != '.' && ch != '_' && ch != '-' {
|
|
return "", false
|
|
}
|
|
}
|
|
return flag, true
|
|
}
|
|
|
|
func isASCIIAlpha(ch byte) bool {
|
|
return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z'
|
|
}
|
|
|
|
// launchPrefixBlockedArgs maps a protocol family to the flags a launch prefix
|
|
// may not set. It exists so New can filter the prefix once, at the single
|
|
// point that knows the family, instead of asking each backend to remember.
|
|
//
|
|
// Only families that reject flags at all appear here; a family whose backend
|
|
// has no blocked-arg policy accepts any prefix.
|
|
var launchPrefixBlockedArgs = map[string]map[string]blockedArgMode{
|
|
"antigravity": antigravityBlockedArgs,
|
|
"claude": claudeBlockedArgs,
|
|
"codebuddy": codebuddyBlockedArgs,
|
|
"codex": codexBlockedArgs,
|
|
"copilot": copilotBlockedArgs,
|
|
"cursor": cursorBlockedArgs,
|
|
"deveco": devecoBlockedArgs,
|
|
"grok": grokBlockedArgs,
|
|
"hermes": hermesBlockedArgs,
|
|
"kimi": kimiBlockedArgs,
|
|
"kiro": kiroBlockedArgs,
|
|
"opencode": opencodeBlockedArgs,
|
|
"openclaw": openclawBlockedArgs,
|
|
"pi": piBlockedArgs,
|
|
"qoder": qoderBlockedArgs,
|
|
"qoderclicn": qoderBlockedArgs,
|
|
"qwen": qwenBlockedArgs,
|
|
"qwenpaw": qwenpawBlockedArgs,
|
|
"reasonix": reasonixBlockedArgs,
|
|
"traecli": traecliBlockedArgs,
|
|
"dim": dimBlockedArgs,
|
|
}
|
|
|
|
// FilterLaunchPrefix is the exported form for callers outside this package —
|
|
// the daemon, which builds Commands for CLI probes that never reach New.
|
|
// Without it a `--version` probe and a task launch would disagree about what
|
|
// the runtime's prefix is.
|
|
func FilterLaunchPrefix(agentType string, prefix []string, logger *slog.Logger) []string {
|
|
return filterLaunchPrefix(prefix, agentType, logger)
|
|
}
|
|
|
|
// filterLaunchPrefix drops protocol-critical flags from a launch prefix while
|
|
// letting positional tokens through.
|
|
//
|
|
// The two token kinds answer different questions. A positional token is the
|
|
// command's identity — `start`, `q36` — and the daemon has no opinion about
|
|
// it. A flag token competes with the protocol arguments the daemon owns, and
|
|
// the prefix position would let it win: putting `--output-format text` in
|
|
// fixed_args would break the daemon↔CLI stream-json channel exactly like
|
|
// putting it in custom_args, which is already refused.
|
|
//
|
|
// This deliberately does NOT delegate to filterCustomArgs, even though the two
|
|
// share blocklists. Those maps also carry positional tokens — Hermes and
|
|
// QwenPaw block `acp`, TRAE blocks `acp` and `serve`, Grok blocks `agent`,
|
|
// `stdio` and `serve` — because in custom_args a bare `acp` really is someone
|
|
// re-issuing the backend's own subcommand. In a launch prefix the same token
|
|
// is part of the command's identity: `hermes-wrapper acp tenant` names a
|
|
// program, and dropping `acp` from it would silently rewrite the command into
|
|
// one the operator never configured. Only a leading `-` makes a token eligible
|
|
// for the blocklist here.
|
|
//
|
|
// A blocked flag still consumes its separate value token, positional or not,
|
|
// so `--permission-mode ask` cannot leave a stray `ask` behind to be read as a
|
|
// subcommand.
|
|
func filterLaunchPrefix(prefix []string, agentType string, logger *slog.Logger) []string {
|
|
if len(prefix) == 0 {
|
|
return nil
|
|
}
|
|
blocked, ok := launchPrefixBlockedArgs[agentType]
|
|
if !ok {
|
|
return append([]string(nil), prefix...)
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
filtered := make([]string, 0, len(prefix))
|
|
for i := 0; i < len(prefix); i++ {
|
|
arg := unshellQuoteArg(prefix[i])
|
|
flag, isFlag := launchPrefixFlagName(arg)
|
|
if !isFlag {
|
|
// The command's own identity. Never filtered, even when it
|
|
// collides with a backend subcommand name.
|
|
filtered = append(filtered, arg)
|
|
continue
|
|
}
|
|
mode, isBlocked := blocked[flag]
|
|
if !isBlocked {
|
|
filtered = append(filtered, arg)
|
|
continue
|
|
}
|
|
logger.Warn("custom runtime fixed_args: blocked protocol-critical flag, skipping", "flag", flag)
|
|
hasInlineValue := strings.Contains(arg, "=")
|
|
if mode == blockedWithValue && !hasInlineValue {
|
|
i++
|
|
} else if mode == blockedOptionalValue && !hasInlineValue && i+1 < len(prefix) &&
|
|
!strings.HasPrefix(unshellQuoteArg(prefix[i+1]), "-") {
|
|
i++
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// warnLaunchPrefixOverlap logs the flags a launch prefix shares with the
|
|
// arguments the daemon builds for this run.
|
|
//
|
|
// Overlap is not an error: the prefix comes first, so the daemon's own value
|
|
// is the later one and wins under the last-wins parsing every supported CLI
|
|
// uses. That is the intended precedence — an explicitly chosen per-agent model
|
|
// should beat a runtime-wide default — but before GH #7046 the prefix sat last
|
|
// and quietly won instead, so a runtime pinned to `--model composer-2.5` used
|
|
// to override the model the member picked in the UI. Anyone who was relying on
|
|
// that gets a line in the log naming the flag rather than a silent change.
|
|
func warnLaunchPrefixOverlap(prefix, args []string, logger *slog.Logger) {
|
|
if len(prefix) == 0 || len(args) == 0 || logger == nil {
|
|
return
|
|
}
|
|
later := make(map[string]bool, len(args))
|
|
for _, a := range args {
|
|
if flag, ok := launchPrefixFlagName(a); ok {
|
|
later[flag] = true
|
|
}
|
|
}
|
|
for _, p := range prefix {
|
|
flag, ok := launchPrefixFlagName(p)
|
|
if !ok || !later[flag] {
|
|
continue
|
|
}
|
|
logger.Warn("custom runtime fixed_args set a flag the runtime also sets; the runtime's value wins",
|
|
"flag", flag)
|
|
}
|
|
}
|
|
|
|
// launchPrefixFlagName extracts the flag name from an argv token, reporting
|
|
// false for positional tokens. `--model=o3` and `--model o3` both yield
|
|
// "--model" so the two spellings compare equal.
|
|
func launchPrefixFlagName(arg string) (string, bool) {
|
|
arg = unshellQuoteArg(arg)
|
|
if !strings.HasPrefix(arg, "-") || arg == "-" || arg == "--" {
|
|
return "", false
|
|
}
|
|
if idx := strings.Index(arg, "="); idx > 0 {
|
|
return arg[:idx], true
|
|
}
|
|
return arg, true
|
|
}
|