mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* fix(daemon): treat custom runtime fixed_args as the argv prefix (MUL-6260) A custom runtime profile configured as `ccms start q36` failed every task with `unknown option '-p'`. The profile's fixed_args were merged into ExecOptions.ExtraArgs, which each backend appends *after* the protocol flags it hardcodes, so the wrapper saw `-p` before its own `start` subcommand had been selected and rejected it (GH #7046). Ordering was only half the defect. ExtraArgs is opt-in — six of the twenty-one backends read it — so on the other fifteen families, including every ACP one, fixed_args were silently discarded rather than misordered. Model discovery and `--version` probing carried the profile's path but not its arguments, so even a fixed launch would have left the model picker and the reported version describing the wrapper instead of the CLI behind it. fixed_args now travel as agent.Config.LaunchPrefix and are spliced in directly after the executable, ahead of everything a backend builds. The prefix is applied by a new Command boundary in launch.go through which every runtime process in the package is constructed — launches, version probes and discovery alike — so a backend cannot forget it. A guard test fails the build if any file outside launch.go calls os/exec directly; distributed opt-in is what let ExtraArgs rot, and repeating it would rot the same way. Flag-style commands (`agent --model composer-2.5`) are unaffected: a CLI takes its own flags in any position, which is why prefix-first can be the single order rather than a per-runtime setting. One deliberate behaviour change. The prefix used to sit last and win every last-wins parse, so a profile pinning `--model` silently overrode the model chosen on the agent — the UI showed one model and ran another. Prefix-first inverts that: the more specific per-agent choice is the later token and wins. Overlaps are logged. Protocol-critical flags are filtered out of the prefix once in New(), which knows the family; positional tokens always pass through. Codex additionally strips `-c` overrides aimed at daemon-managed config namespaces, since those beat config.toml from any argv position. The order was never documented, and every shipped fixed_args example is flag-style, so this fills in a contract rather than breaking one. Co-authored-by: multica-agent <[email protected]> * fix(daemon): close the launch-prefix gaps found in review (MUL-6260) Four defects from the first round of review on the fixed_args-as-argv-prefix change. Hermes could escape its own task overlay. The profile selection was resolved from custom_args alone and stripped from custom_args alone, so a profile configured as `hermes -p research` kept that flag in the launch prefix and re-pointed HERMES_HOME past the overlay the daemon had just built. Resolution now parses the argv hermes actually sees — launch prefix then custom args, in process order — and the strip covers both regions. It also removes every selection rather than the first: taking out the first promotes the second, and two separately-configured argv regions make "two selections" an ordinary configuration rather than a user mistake. Positional tokens were not passing through as documented. filterLaunchPrefix delegated to filterCustomArgs, whose blocklists deliberately carry positional tokens — hermes and qwenpaw block `acp`, traecli blocks `acp`/`serve`, grok blocks `agent`/`stdio`/`serve` — because in custom_args those are someone re-issuing the backend's own subcommand. In a launch prefix the same token is the command's identity, so `hermes-wrapper acp tenant` was being rewritten to `tenant`. Only a leading `-` now makes a token eligible for the blocklist; a blocked flag still consumes its separate value so no stray value survives to be read as a subcommand. Codex fast mode lost to the prefix. `--disable fast_mode` beats `--enable fast_mode` regardless of argv order and `-c features.fast_mode=false` beats config.toml from any position, so prefix-first does not protect an explicitly selected priority tier. The removal half of enforceCodexFastMode is now a separate helper applied to the prefix too; the managed enable is still appended once, on the final args. Windows CI could not compile the daemon tests: the Command migration left an unused `path := runtimeCmd.Path` in a hook whose body never referenced it. Verified with GOOS=windows and GOOS=linux vet, which is what should have caught it the first time. Also softens the docs and the Command doc comment: flag-style prefixes usually parse the same in either position, but a CLI that separates global from subcommand flags can care, so the runtime docs now name the position change and tell operators with existing fixed_args to re-run one task after upgrading instead of promising the move is invisible. Co-authored-by: multica-agent <[email protected]> * fix(daemon): resolve and strip the Hermes profile from the real launch argv (MUL-6260) Second-round review found the Hermes profile chain still reasoning about a hand-assembled approximation of the command line instead of the command line itself. The backend launches `<prefix> acp <filtered custom args>`, but the daemon assembled `<prefix> <custom args>` — no `acp`, no filtering — and that token participates in Hermes' own argv scan. With fixed_args `--model` and custom_args `-p research`, the approximation reads `-p` as `--model`'s value and finds no selection, so the overlay is seeded from the default home; the real `--model acp -p research` skips `--model acp` and selects `research`. The selector was then stripped, so the task stayed inside the overlay — but an overlay built from the wrong profile, silently. Stripping had the matching defect, and this one was still an escape rather than a mismatch. A launch prefix ending in a bare `-p` captures the backend's `acp` as its profile value, so neither region holds a complete selection and per-region stripping removes nothing: `-p acp research --yolo` reaches Hermes intact and re-points HERMES_HOME straight out of the overlay. Both halves now go through one assembly. agent.HermesLaunchArgv builds exactly what hermesBackend.Execute builds — the backend calls the same helper, so they cannot drift — and the daemon resolves from it. StripHermesProfileSelectors strips against that same argv and hands surviving tokens back to the region they came from, iterating to a fixed point because removing one selection promotes the next. The `acp` token is never removed; the backend re-adds it, and dropping the flag that captured it is what breaks the selection. StripAllHermesProfileArgs, hermesLaunchArgs and hermesEffectiveArgs are gone: each was only ever correct on a complete argv, and keeping them would leave the same footgun for the next caller. Tests cover the case the review named (prefix ending in a value-taking flag, custom args opening with a selector), the straddling-selection escape, multiple occurrences across both regions, and an invalid value left untouched because Hermes discards it anyway. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
283 lines
10 KiB
Go
283 lines
10 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/agent"
|
|
)
|
|
|
|
// modelListFixture stands up a Daemon whose model-list report is captured and
|
|
// whose agent.ListModels call is stubbed, so a test can assert exactly which
|
|
// command model discovery enumerated — path and launch prefix both — without
|
|
// shelling out to a CLI.
|
|
type modelListFixture struct {
|
|
daemon *Daemon
|
|
|
|
mu sync.Mutex
|
|
listedProvider string
|
|
listedPath string
|
|
listedPrefix []string
|
|
listCalls int
|
|
report map[string]any
|
|
}
|
|
|
|
func newModelListFixture(t *testing.T) *modelListFixture {
|
|
t.Helper()
|
|
withFastLocalSkillReportBackoffs(t)
|
|
|
|
fx := &modelListFixture{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
fx.mu.Lock()
|
|
fx.report = body
|
|
fx.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
d := freshDaemon(srv.URL)
|
|
d.profileLaunchSpecs = make(map[string]profileLaunchSpec)
|
|
fx.daemon = d
|
|
|
|
orig := listModels
|
|
listModels = func(_ context.Context, provider string, runtimeCmd agent.Command) (agent.Catalog, error) {
|
|
fx.mu.Lock()
|
|
fx.listedProvider = provider
|
|
fx.listedPath = runtimeCmd.Path
|
|
fx.listedPrefix = append([]string(nil), runtimeCmd.Prefix...)
|
|
fx.listCalls++
|
|
fx.mu.Unlock()
|
|
return agent.Catalog{Models: []agent.Model{{ID: "m-1", Label: "M 1"}}}, nil
|
|
}
|
|
t.Cleanup(func() { listModels = orig })
|
|
|
|
return fx
|
|
}
|
|
|
|
func (fx *modelListFixture) snapshot() (provider, path string, calls int, report map[string]any) {
|
|
fx.mu.Lock()
|
|
defer fx.mu.Unlock()
|
|
return fx.listedProvider, fx.listedPath, fx.listCalls, fx.report
|
|
}
|
|
|
|
// fakeExecutable writes an executable file the daemon's exec.LookPath-based
|
|
// presence check accepts. Tests must never resolve a user-installed agent CLI,
|
|
// so every built-in AgentEntry.Path in this file points at one of these.
|
|
func fakeExecutable(t *testing.T, name string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), name)
|
|
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake executable: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// TestHandleModelList_CustomProfileEnumeratesProfileBinary pins the core of
|
|
// MUL-5789: a profile-backed runtime must have its models discovered from the
|
|
// binary the profile pinned, not from the built-in CLI of the same protocol
|
|
// family that happens to also be installed. Enumerating the built-in advertises
|
|
// a catalog the launched binary never agreed to.
|
|
func TestHandleModelList_CustomProfileEnumeratesProfileBinary(t *testing.T) {
|
|
fx := newModelListFixture(t)
|
|
d := fx.daemon
|
|
|
|
builtinPath := fakeExecutable(t, "hermes")
|
|
d.cfg.Agents = map[string]AgentEntry{"hermes": {Path: builtinPath}}
|
|
d.runtimeIndex["rt-custom"] = Runtime{ID: "rt-custom", Provider: "hermes", ProfileID: "prof-1"}
|
|
d.profileLaunchSpecs["prof-1"] = profileLaunchSpec{path: "/usr/local/bin/jcode", version: "1.2.3"}
|
|
|
|
d.handleModelList(context.Background(), d.runtimeIndex["rt-custom"], "req-1")
|
|
|
|
provider, path, calls, report := fx.snapshot()
|
|
if calls != 1 {
|
|
t.Fatalf("expected exactly 1 discovery call, got %d", calls)
|
|
}
|
|
if provider != "hermes" {
|
|
t.Fatalf("discovery provider = %q, want the protocol family %q", provider, "hermes")
|
|
}
|
|
if path != "/usr/local/bin/jcode" {
|
|
t.Fatalf("discovery path = %q, want the profile's command path", path)
|
|
}
|
|
if got := report["status"]; got != "completed" {
|
|
t.Fatalf("report status = %v, want completed (report: %v)", got, report)
|
|
}
|
|
}
|
|
|
|
// TestHandleModelList_CustomProfileWithoutBuiltinAgent is the reported failure
|
|
// (GitHub #6466): a host that has only the custom command installed — no
|
|
// built-in CLI of the same protocol family — used to answer the model list with
|
|
// `no agent configured for provider`, leaving the picker empty even though the
|
|
// same runtime executes tasks fine.
|
|
func TestHandleModelList_CustomProfileWithoutBuiltinAgent(t *testing.T) {
|
|
fx := newModelListFixture(t)
|
|
d := fx.daemon
|
|
|
|
// No built-in agents at all on this host.
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
d.runtimeIndex["rt-custom"] = Runtime{ID: "rt-custom", Provider: "hermes", ProfileID: "prof-1"}
|
|
d.profileLaunchSpecs["prof-1"] = profileLaunchSpec{path: "/usr/local/bin/jcode"}
|
|
|
|
d.handleModelList(context.Background(), d.runtimeIndex["rt-custom"], "req-1")
|
|
|
|
_, path, calls, report := fx.snapshot()
|
|
if calls != 1 {
|
|
t.Fatalf("expected discovery to run, got %d calls (report: %v)", calls, report)
|
|
}
|
|
if path != "/usr/local/bin/jcode" {
|
|
t.Fatalf("discovery path = %q, want the profile's command path", path)
|
|
}
|
|
if got := report["status"]; got != "completed" {
|
|
t.Fatalf("report status = %v, want completed (report: %v)", got, report)
|
|
}
|
|
if _, hasErr := report["error"]; hasErr {
|
|
t.Fatalf("report carried an error: %v", report)
|
|
}
|
|
}
|
|
|
|
// TestHandleModelList_BuiltinRuntimeUnaffected pins that the built-in path is
|
|
// untouched, including when unrelated profile launch specs exist on the same
|
|
// daemon. A built-in runtime has no ProfileID, so it must keep resolving
|
|
// through the provider's agent entry.
|
|
func TestHandleModelList_BuiltinRuntimeUnaffected(t *testing.T) {
|
|
fx := newModelListFixture(t)
|
|
d := fx.daemon
|
|
|
|
builtinPath := fakeExecutable(t, "codex")
|
|
d.cfg.Agents = map[string]AgentEntry{"codex": {Path: builtinPath}}
|
|
d.runtimeIndex["rt-builtin"] = Runtime{ID: "rt-builtin", Provider: "codex"}
|
|
// A custom profile for a different runtime must not leak into this lookup.
|
|
d.runtimeIndex["rt-custom"] = Runtime{ID: "rt-custom", Provider: "codex", ProfileID: "prof-1"}
|
|
d.profileLaunchSpecs["prof-1"] = profileLaunchSpec{path: "/opt/bin/company-codex"}
|
|
|
|
d.handleModelList(context.Background(), d.runtimeIndex["rt-builtin"], "req-1")
|
|
|
|
_, path, calls, report := fx.snapshot()
|
|
if calls != 1 {
|
|
t.Fatalf("expected exactly 1 discovery call, got %d", calls)
|
|
}
|
|
if path != builtinPath {
|
|
t.Fatalf("discovery path = %q, want the built-in entry path %q", path, builtinPath)
|
|
}
|
|
if got := report["status"]; got != "completed" {
|
|
t.Fatalf("report status = %v, want completed (report: %v)", got, report)
|
|
}
|
|
}
|
|
|
|
// TestHandleModelList_NoProfileAndNoBuiltinStillFails pins that the failure
|
|
// message survives for the case it was actually written for: a built-in runtime
|
|
// whose provider has no agent entry on this host.
|
|
func TestHandleModelList_NoProfileAndNoBuiltinStillFails(t *testing.T) {
|
|
fx := newModelListFixture(t)
|
|
d := fx.daemon
|
|
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
d.runtimeIndex["rt-builtin"] = Runtime{ID: "rt-builtin", Provider: "codex"}
|
|
|
|
d.handleModelList(context.Background(), d.runtimeIndex["rt-builtin"], "req-1")
|
|
|
|
_, _, calls, report := fx.snapshot()
|
|
if calls != 0 {
|
|
t.Fatalf("expected no discovery call, got %d", calls)
|
|
}
|
|
if got := report["status"]; got != "failed" {
|
|
t.Fatalf("report status = %v, want failed (report: %v)", got, report)
|
|
}
|
|
if got, want := report["error"], `no agent configured for provider "codex"`; got != want {
|
|
t.Fatalf("report error = %v, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestHandleModelList_UnresolvedProfileFallsBackToBuiltin covers the profile
|
|
// whose command never resolved on this host (registration skipped it, so there
|
|
// is no launch spec). Discovery must not invent a path — it falls through to
|
|
// the built-in entry, matching customProfileLaunchForRuntime's contract.
|
|
func TestHandleModelList_UnresolvedProfileFallsBackToBuiltin(t *testing.T) {
|
|
fx := newModelListFixture(t)
|
|
d := fx.daemon
|
|
|
|
builtinPath := fakeExecutable(t, "hermes")
|
|
d.cfg.Agents = map[string]AgentEntry{"hermes": {Path: builtinPath}}
|
|
d.runtimeIndex["rt-custom"] = Runtime{ID: "rt-custom", Provider: "hermes", ProfileID: "prof-missing"}
|
|
|
|
d.handleModelList(context.Background(), d.runtimeIndex["rt-custom"], "req-1")
|
|
|
|
_, path, calls, report := fx.snapshot()
|
|
if calls != 1 {
|
|
t.Fatalf("expected exactly 1 discovery call, got %d", calls)
|
|
}
|
|
if path != builtinPath {
|
|
t.Fatalf("discovery path = %q, want the built-in fallback %q", path, builtinPath)
|
|
}
|
|
if got := report["status"]; got != "completed" {
|
|
t.Fatalf("report status = %v, want completed (report: %v)", got, report)
|
|
}
|
|
}
|
|
|
|
// TestHandleModelList_CustomProfileCarriesFixedArgs is the discovery half of
|
|
// GH #7046. #6488 aligned model discovery to the profile's own binary but
|
|
// carried only the path, so a wrapper whose CLI is reachable only through a
|
|
// subcommand was enumerated as `ccms models` — the wrapper's own catalog, or
|
|
// more often an error — instead of `ccms start q36 models`. The launch prefix
|
|
// has to travel with the path.
|
|
func TestHandleModelList_CustomProfileCarriesFixedArgs(t *testing.T) {
|
|
fx := newModelListFixture(t)
|
|
d := fx.daemon
|
|
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
d.runtimeIndex["rt-custom"] = Runtime{ID: "rt-custom", Provider: "claude", ProfileID: "prof-1"}
|
|
d.profileLaunchSpecs["prof-1"] = profileLaunchSpec{
|
|
path: "/usr/local/bin/ccms",
|
|
fixedArgs: []string{"start", "q36"},
|
|
}
|
|
|
|
d.handleModelList(context.Background(), d.runtimeIndex["rt-custom"], "req-1")
|
|
|
|
fx.mu.Lock()
|
|
path, prefix := fx.listedPath, append([]string(nil), fx.listedPrefix...)
|
|
fx.mu.Unlock()
|
|
|
|
if path != "/usr/local/bin/ccms" {
|
|
t.Fatalf("discovery path = %q, want the profile's command path", path)
|
|
}
|
|
if strings.Join(prefix, "\x00") != "start\x00q36" {
|
|
t.Fatalf("discovery launch prefix = %v, want the profile's fixed_args [start q36]", prefix)
|
|
}
|
|
}
|
|
|
|
// TestHandleModelList_FixedArgsFilteredBeforeDiscovery: the prefix a probe runs
|
|
// with is the same one a task launches with, protocol-critical flags dropped.
|
|
// If the two disagreed, the picker would enumerate a CLI configured
|
|
// differently from the one that runs.
|
|
func TestHandleModelList_FixedArgsFilteredBeforeDiscovery(t *testing.T) {
|
|
fx := newModelListFixture(t)
|
|
d := fx.daemon
|
|
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
d.runtimeIndex["rt-custom"] = Runtime{ID: "rt-custom", Provider: "claude", ProfileID: "prof-1"}
|
|
d.profileLaunchSpecs["prof-1"] = profileLaunchSpec{
|
|
path: "/usr/local/bin/ccms",
|
|
fixedArgs: []string{"start", "q36", "--output-format", "text"},
|
|
}
|
|
|
|
d.handleModelList(context.Background(), d.runtimeIndex["rt-custom"], "req-1")
|
|
|
|
fx.mu.Lock()
|
|
prefix := append([]string(nil), fx.listedPrefix...)
|
|
fx.mu.Unlock()
|
|
|
|
if strings.Join(prefix, "\x00") != "start\x00q36" {
|
|
t.Fatalf("discovery prefix = %v, want the protocol flag filtered out", prefix)
|
|
}
|
|
}
|