mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* fix(runtimes): preserve custom profile base runtime identity Co-authored-by: multica-agent <[email protected]> * fix(runtimes): pass both url params to the profile identity test TestUpdateRuntimeProfile_RejectsIdentityChange nested two withURLParam calls. That helper installs a fresh chi route context each time and does not copy what is already there, so the outer call for profileId dropped id: the handler answered 400 "workspace_id is required" and never reached the immutability check the test exists to cover. Use withURLParams, which carries existing params forward and is what the rest of this file already uses. Co-authored-by: multica-agent <[email protected]> * fix(runtimes): name a runtime target the same way on every surface The base-runtime picker spelled Oh-My-Pi with an inline ternary, so it was the only place that used the product name. The catalog row, both detail headers, the base-runtime detail row and the locked chip in the form kept rendering the stored id under `capitalize` — a user picked "Oh-My-Pi" and the next screen, and the saved profile, said "Omp". Move the name into runtimeTypeLabel next to the target whitelist and read it from every surface. profileRuntimeType replaces the repeated runtime_type ?? protocol_family fallback. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> Co-authored-by: Bohan-J <[email protected]>
586 lines
23 KiB
Go
586 lines
23 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
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 newRuntimeCmd(exec.CommandContext(ctx, c.Path, c.Argv(args...)...))
|
|
}
|
|
|
|
// newRuntimeCmd applies the process-lifecycle defaults every runtime process in
|
|
// this package gets. It runs at construction because both of them have to be in
|
|
// place before the process exists.
|
|
//
|
|
// Both used to be opt-in, and opt-in is why GH #7522 happened. Of the 27 places
|
|
// this package starts a process, 8 asked for a process group; the rest left
|
|
// their CLI in the daemon's group, where a group-wide signal cannot reach it.
|
|
// os/exec's default Cancel is the same leak by another route: it kills the
|
|
// leader alone, so a cancelled task's tool subprocesses — MCP servers, shells,
|
|
// whatever the agent spawned — survive it. On Linux that was #5918. On Windows,
|
|
// where the leader is often a cmd.exe shim and the real CLI is already a
|
|
// grandchild, a cancelled agent kept working for 40 minutes.
|
|
//
|
|
// A backend that wants a graceful shutdown instead of an immediate kill assigns
|
|
// its own cmd.Cancel after construction and wins; claude, dsh and deveco do,
|
|
// because they drive SIGTERM → grace → SIGKILL themselves.
|
|
func newRuntimeCmd(cmd *exec.Cmd) *exec.Cmd {
|
|
configureProcessGroup(cmd)
|
|
cmd.Cancel = func() error {
|
|
signalProcessGroup(cmd, syscall.SIGKILL)
|
|
return nil
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
// runOwned is Run() over an owned process tree: start, wait, drop ownership.
|
|
//
|
|
// os/exec's Run/Output/CombinedOutput call Start themselves, so a probe written
|
|
// with them never reaches startOwnedProcessTree and owns nothing on Windows —
|
|
// where the direct child of a `--version` probe is typically the shim, not the
|
|
// CLI. That is the same escape GH #7522 was reported for, in a path nobody was
|
|
// looking at: detectCLIVersion's own comment already describes a broken CLI
|
|
// leaving grandchildren behind. These three helpers are how a synchronous probe
|
|
// gets the same ownership a task launch has.
|
|
func runOwned(cmd *exec.Cmd, logger *slog.Logger) error {
|
|
// Without a bound this waits for its own cleanup and never returns. A
|
|
// descendant that inherited the output pipes holds them open after the
|
|
// leader exits; cmd.Wait blocks until the copy goroutines see EOF; and the
|
|
// thing that would close those pipes is the release below, which runs
|
|
// after Wait. WaitDelay is defined for exactly this case — "a child
|
|
// process that exits but leaves its I/O pipes unclosed" — and cancellation
|
|
// is no help, because a probe like checkOpenclawVersion runs on the
|
|
// caller's context before any task timeout exists.
|
|
//
|
|
// A caller that set its own bound keeps it; detectCLIVersion has had one
|
|
// since MUL-3812 for this exact shape.
|
|
if cmd.WaitDelay == 0 {
|
|
cmd.WaitDelay = probeWaitDelay
|
|
}
|
|
if err := startOwnedProcessTree(cmd, logger); err != nil {
|
|
return err
|
|
}
|
|
err := cmd.Wait()
|
|
// The probe is over the moment its leader is: nothing it spawned should
|
|
// outlive the answer. Signalling before the release covers Unix, where
|
|
// releasing a process group is a no-op; on Windows closing the Job Object
|
|
// would take the tree down on its own.
|
|
signalProcessGroup(cmd, syscall.SIGKILL)
|
|
releaseProcessGroup(cmd)
|
|
return err
|
|
}
|
|
|
|
// probeWaitDelay bounds how long a finished probe waits on output pipes its
|
|
// descendants left open. detectCLIVersion sets the same bound on its own
|
|
// command. The timer only starts once the child has exited or the context is
|
|
// done, so a healthy probe never pays it.
|
|
// Package tests shorten it while preserving the delayed-descendant ordering;
|
|
// production never reassigns it.
|
|
var probeWaitDelay = 2 * time.Second
|
|
|
|
// outputOwned is cmd.Output() over an owned process tree. It matches the
|
|
// stdlib's contract — stdout returned, a failed run's stderr attached to the
|
|
// *exec.ExitError — except that the stderr sample is the last
|
|
// probeStderrSampleBytes rather than the stdlib's head-and-tail, which is where
|
|
// a CLI's actual failure line ends up.
|
|
func outputOwned(cmd *exec.Cmd, logger *slog.Logger) ([]byte, error) {
|
|
if cmd.Stdout != nil {
|
|
return nil, errors.New("exec: Stdout already set")
|
|
}
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
|
|
// A caller that set its own Stderr wants it; only fill in the sample when
|
|
// nothing else is watching, exactly as Output() does.
|
|
var stderr *tailBuffer
|
|
if cmd.Stderr == nil {
|
|
stderr = &tailBuffer{max: probeStderrSampleBytes}
|
|
cmd.Stderr = stderr
|
|
}
|
|
|
|
err := runOwned(cmd, logger)
|
|
var exitErr *exec.ExitError
|
|
if stderr != nil && errors.As(err, &exitErr) {
|
|
exitErr.Stderr = stderr.Bytes()
|
|
}
|
|
return stdout.Bytes(), err
|
|
}
|
|
|
|
// combinedOutputOwned is cmd.CombinedOutput() over an owned process tree.
|
|
// Stdout and Stderr are the same writer value, which is what makes os/exec give
|
|
// them one pipe and therefore one interleaving, as the stdlib does.
|
|
func combinedOutputOwned(cmd *exec.Cmd, logger *slog.Logger) ([]byte, error) {
|
|
if cmd.Stdout != nil {
|
|
return nil, errors.New("exec: Stdout already set")
|
|
}
|
|
if cmd.Stderr != nil {
|
|
return nil, errors.New("exec: Stderr already set")
|
|
}
|
|
var combined bytes.Buffer
|
|
cmd.Stdout = &combined
|
|
cmd.Stderr = &combined
|
|
err := runOwned(cmd, logger)
|
|
return combined.Bytes(), err
|
|
}
|
|
|
|
// probeStderrSampleBytes bounds the stderr kept for a failed probe's error.
|
|
// os/exec bounds the same sample at 32 KiB; a CLI stuck in a log loop should
|
|
// not be able to grow the daemon's heap through a `--version` call.
|
|
const probeStderrSampleBytes = 32 << 10
|
|
|
|
// tailBuffer keeps the last max bytes written to it and discards the rest. It
|
|
// always reports a full write, so a child is never blocked or shortened by the
|
|
// bound.
|
|
type tailBuffer struct {
|
|
buf []byte
|
|
max int
|
|
}
|
|
|
|
func (t *tailBuffer) Write(p []byte) (int, error) {
|
|
t.buf = append(t.buf, p...)
|
|
if len(t.buf) > t.max {
|
|
t.buf = t.buf[len(t.buf)-t.max:]
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (t *tailBuffer) Bytes() []byte { return t.buf }
|
|
|
|
// 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 newRuntimeCmd(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,
|
|
"codearts": codeartsBlockedArgs,
|
|
"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,
|
|
"zeroclaw": zeroclawBlockedArgs,
|
|
}
|
|
|
|
// 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 {
|
|
if family, ok := RuntimeProtocolFamily(agentType); ok {
|
|
agentType = family
|
|
}
|
|
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
|
|
}
|