mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
/ unset-path for a per-machine command override. protocol-family validated
client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
validated; update only sends changed flags (protocol_family immutable);
delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
appendProfileRuntimes now prefers an override path when set AND executable,
else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
relative-path rejection, config preservation); cli/config round-trip;
daemon prefers-override / falls-back-when-not-executable.
Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.
Co-authored-by: multica-agent <github@multica.ai>
354 lines
13 KiB
Go
354 lines
13 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// stubLookPath swaps the package-level lookPath indirection used by
|
|
// registerRuntimesForWorkspace to resolve custom runtime-profile commands,
|
|
// so tests don't have to mutate the process PATH. resolved maps a command
|
|
// name to the absolute path it should resolve to; an absent name reports
|
|
// "not found".
|
|
func stubLookPath(t *testing.T, resolved map[string]string) {
|
|
t.Helper()
|
|
orig := lookPath
|
|
lookPath = func(cmd string) (string, error) {
|
|
if p, ok := resolved[cmd]; ok {
|
|
return p, nil
|
|
}
|
|
return "", &osExecNotFound{cmd: cmd}
|
|
}
|
|
t.Cleanup(func() { lookPath = orig })
|
|
}
|
|
|
|
type osExecNotFound struct{ cmd string }
|
|
|
|
func (e *osExecNotFound) Error() string { return "exec: " + e.cmd + ": not found in $PATH" }
|
|
|
|
// TestClient_GetRuntimeProfiles_RequestShape asserts the daemon GETs the
|
|
// documented path and parses the server's runtime_profiles payload.
|
|
func TestClient_GetRuntimeProfiles_RequestShape(t *testing.T) {
|
|
var gotMethod, gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotPath = r.URL.Path
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"workspace_id":"ws-1",
|
|
"runtime_profiles":[{
|
|
"id":"prof-1",
|
|
"workspace_id":"ws-1",
|
|
"display_name":"Company Codex",
|
|
"protocol_family":"codex",
|
|
"command_name":"company-codex",
|
|
"description":null,
|
|
"fixed_args":["--foo"],
|
|
"visibility":"workspace",
|
|
"created_by":null,
|
|
"enabled":true,
|
|
"created_at":"2026-01-01T00:00:00Z",
|
|
"updated_at":"2026-01-01T00:00:00Z"
|
|
}]
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL)
|
|
c.SetToken("tok")
|
|
resp, err := c.GetRuntimeProfiles(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("GetRuntimeProfiles: %v", err)
|
|
}
|
|
if gotMethod != http.MethodGet {
|
|
t.Errorf("method = %q, want GET", gotMethod)
|
|
}
|
|
if gotPath != "/api/daemon/workspaces/ws-1/runtime-profiles" {
|
|
t.Errorf("path = %q, want /api/daemon/workspaces/ws-1/runtime-profiles", gotPath)
|
|
}
|
|
if resp.WorkspaceID != "ws-1" || len(resp.RuntimeProfiles) != 1 {
|
|
t.Fatalf("unexpected response: %+v", resp)
|
|
}
|
|
p := resp.RuntimeProfiles[0]
|
|
if p.ID != "prof-1" || p.ProtocolFamily != "codex" || p.CommandName != "company-codex" {
|
|
t.Errorf("profile fields wrong: %+v", p)
|
|
}
|
|
if !p.Enabled {
|
|
t.Errorf("profile should be enabled")
|
|
}
|
|
if len(p.FixedArgs) != 1 || p.FixedArgs[0] != "--foo" {
|
|
t.Errorf("fixed_args = %v, want [--foo]", p.FixedArgs)
|
|
}
|
|
}
|
|
|
|
// profileRegisterFixture wires a Daemon against a fake server that serves a
|
|
// configurable set of runtime profiles and captures the runtimes array sent
|
|
// to /api/daemon/register.
|
|
type profileRegisterFixture struct {
|
|
daemon *Daemon
|
|
server *httptest.Server
|
|
sentRuntimes []map[string]any
|
|
}
|
|
|
|
func newProfileRegisterFixture(t *testing.T, profiles []RuntimeProfile, profilesStatus int) *profileRegisterFixture {
|
|
t.Helper()
|
|
fx := &profileRegisterFixture{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.URL.Path == "/api/daemon/register":
|
|
var body struct {
|
|
Runtimes []map[string]any `json:"runtimes"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
fx.sentRuntimes = body.Runtimes
|
|
// Echo back a Runtime row per requested runtime, threading
|
|
// profile_id so the caller can populate runtimeIndex from it.
|
|
var resp RegisterResponse
|
|
for i, rt := range body.Runtimes {
|
|
id := "rt-" + strconv.Itoa(i)
|
|
profileID, _ := rt["profile_id"].(string)
|
|
typ, _ := rt["type"].(string)
|
|
resp.Runtimes = append(resp.Runtimes, Runtime{
|
|
ID: id,
|
|
Name: "n",
|
|
Provider: typ,
|
|
Status: "online",
|
|
ProfileID: profileID,
|
|
})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
case len(r.URL.Path) > len("/runtime-profiles") && strings.HasSuffix(r.URL.Path, "/runtime-profiles"):
|
|
if profilesStatus != 0 && profilesStatus != http.StatusOK {
|
|
w.WriteHeader(profilesStatus)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(RuntimeProfilesResponse{
|
|
WorkspaceID: "ws-1",
|
|
RuntimeProfiles: profiles,
|
|
})
|
|
default:
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
d := freshDaemon(srv.URL)
|
|
d.profileCommandPaths = make(map[string]string)
|
|
fx.daemon = d
|
|
fx.server = srv
|
|
return fx
|
|
}
|
|
|
|
// TestRegisterRuntimes_AppendsProfileRuntime verifies that a custom profile
|
|
// whose command resolves on PATH is appended as a runtime entry carrying
|
|
// profile_id, and that its resolved command path is recorded for runTask.
|
|
// Uses a custom-only host (no built-in agents) to also prove that path still
|
|
// registers.
|
|
func TestRegisterRuntimes_AppendsProfileRuntime(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/opt/bin/company-codex"})
|
|
|
|
profiles := []RuntimeProfile{{
|
|
ID: "prof-1",
|
|
WorkspaceID: "ws-1",
|
|
DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex",
|
|
CommandName: "company-codex",
|
|
Visibility: "workspace",
|
|
Enabled: true,
|
|
}}
|
|
fx := newProfileRegisterFixture(t, profiles, http.StatusOK)
|
|
d := fx.daemon
|
|
// Custom-only host: no built-in agents configured.
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
|
|
resp, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("registerRuntimesForWorkspace: %v", err)
|
|
}
|
|
|
|
// The register request must carry exactly one runtime: the profile.
|
|
if len(fx.sentRuntimes) != 1 {
|
|
t.Fatalf("sent runtimes = %d, want 1: %+v", len(fx.sentRuntimes), fx.sentRuntimes)
|
|
}
|
|
sent := fx.sentRuntimes[0]
|
|
if sent["type"] != "codex" {
|
|
t.Errorf("sent type = %v, want codex", sent["type"])
|
|
}
|
|
if sent["profile_id"] != "prof-1" {
|
|
t.Errorf("sent profile_id = %v, want prof-1", sent["profile_id"])
|
|
}
|
|
if sent["status"] != "online" {
|
|
t.Errorf("sent status = %v, want online", sent["status"])
|
|
}
|
|
|
|
// The resolved command path must be recorded keyed by profile_id.
|
|
if got := d.profileCommandPaths["prof-1"]; got != "/opt/bin/company-codex" {
|
|
t.Errorf("profileCommandPaths[prof-1] = %q, want /opt/bin/company-codex", got)
|
|
}
|
|
|
|
// The response runtime carries the profile_id back.
|
|
if len(resp.Runtimes) != 1 || resp.Runtimes[0].ProfileID != "prof-1" {
|
|
t.Fatalf("response runtimes wrong: %+v", resp.Runtimes)
|
|
}
|
|
}
|
|
|
|
// TestRegisterRuntimes_SkipsProfileNotOnPath verifies a profile whose command
|
|
// is missing on this host is skipped, and that a host with no built-in agents
|
|
// and no resolvable profiles fails registration (len==0 guard preserved).
|
|
func TestRegisterRuntimes_SkipsProfileNotOnPath(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{}) // nothing resolves
|
|
|
|
profiles := []RuntimeProfile{{
|
|
ID: "prof-1",
|
|
WorkspaceID: "ws-1",
|
|
DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex",
|
|
CommandName: "company-codex",
|
|
Enabled: true,
|
|
}}
|
|
fx := newProfileRegisterFixture(t, profiles, http.StatusOK)
|
|
d := fx.daemon
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
|
|
_, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err == nil {
|
|
t.Fatalf("expected error when no runtimes resolve, got nil")
|
|
}
|
|
if _, ok := d.profileCommandPaths["prof-1"]; ok {
|
|
t.Errorf("profileCommandPaths should not record an unresolved profile")
|
|
}
|
|
}
|
|
|
|
// TestRegisterRuntimes_ProfilesFetchErrorIsBestEffort verifies a 404 from the
|
|
// profiles endpoint does not fail registration when a built-in agent exists.
|
|
func TestRegisterRuntimes_ProfilesFetchErrorIsBestEffort(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{})
|
|
|
|
fx := newProfileRegisterFixture(t, nil, http.StatusNotFound)
|
|
d := fx.daemon
|
|
// Built-in agent present so registration has something to register.
|
|
d.cfg.Agents = map[string]AgentEntry{"claude": {Path: "/usr/bin/true"}}
|
|
|
|
resp, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
|
|
if err != nil {
|
|
t.Fatalf("registration should succeed despite profiles 404: %v", err)
|
|
}
|
|
if len(fx.sentRuntimes) != 1 || fx.sentRuntimes[0]["type"] != "claude" {
|
|
t.Fatalf("expected only the built-in claude runtime, got %+v", fx.sentRuntimes)
|
|
}
|
|
if len(resp.Runtimes) != 1 {
|
|
t.Fatalf("response runtimes = %d, want 1", len(resp.Runtimes))
|
|
}
|
|
}
|
|
|
|
// TestRegisterRuntimes_PrefersCommandPathOverride verifies that a per-machine
|
|
// command path override (MUL-3284) is used in preference to the PATH lookup:
|
|
// the resolved/recorded path is the override, even when lookPath would resolve
|
|
// command_name to a different binary.
|
|
func TestRegisterRuntimes_PrefersCommandPathOverride(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
// PATH would resolve to a *different* binary; the override must win.
|
|
stubLookPath(t, map[string]string{"company-codex": "/usr/bin/company-codex"})
|
|
stubProfilePathExecutable(t, map[string]bool{"/opt/custom/company-codex": true})
|
|
|
|
profiles := []RuntimeProfile{{
|
|
ID: "prof-1",
|
|
WorkspaceID: "ws-1",
|
|
DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex",
|
|
CommandName: "company-codex",
|
|
Enabled: true,
|
|
}}
|
|
fx := newProfileRegisterFixture(t, profiles, http.StatusOK)
|
|
d := fx.daemon
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
d.cfg.ProfileCommandOverrides = map[string]string{"prof-1": "/opt/custom/company-codex"}
|
|
|
|
if _, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("registerRuntimesForWorkspace: %v", err)
|
|
}
|
|
|
|
if got := d.profileCommandPaths["prof-1"]; got != "/opt/custom/company-codex" {
|
|
t.Errorf("profileCommandPaths[prof-1] = %q, want the override /opt/custom/company-codex", got)
|
|
}
|
|
if len(fx.sentRuntimes) != 1 || fx.sentRuntimes[0]["profile_id"] != "prof-1" {
|
|
t.Fatalf("expected the profile runtime to register, got %+v", fx.sentRuntimes)
|
|
}
|
|
}
|
|
|
|
// TestRegisterRuntimes_OverrideNotExecutableFallsBackToPath verifies that an
|
|
// override pointing at a non-executable / missing path is ignored and the
|
|
// daemon falls back to resolving command_name on PATH.
|
|
func TestRegisterRuntimes_OverrideNotExecutableFallsBackToPath(t *testing.T) {
|
|
t.Cleanup(stubAgentVersion(t))
|
|
stubLookPath(t, map[string]string{"company-codex": "/usr/bin/company-codex"})
|
|
// Override path reports NOT executable -> must fall back to PATH.
|
|
stubProfilePathExecutable(t, map[string]bool{})
|
|
|
|
profiles := []RuntimeProfile{{
|
|
ID: "prof-1",
|
|
WorkspaceID: "ws-1",
|
|
DisplayName: "Company Codex",
|
|
ProtocolFamily: "codex",
|
|
CommandName: "company-codex",
|
|
Enabled: true,
|
|
}}
|
|
fx := newProfileRegisterFixture(t, profiles, http.StatusOK)
|
|
d := fx.daemon
|
|
d.cfg.Agents = map[string]AgentEntry{}
|
|
d.cfg.ProfileCommandOverrides = map[string]string{"prof-1": "/opt/stale/company-codex"}
|
|
|
|
if _, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1"); err != nil {
|
|
t.Fatalf("registerRuntimesForWorkspace: %v", err)
|
|
}
|
|
|
|
if got := d.profileCommandPaths["prof-1"]; got != "/usr/bin/company-codex" {
|
|
t.Errorf("profileCommandPaths[prof-1] = %q, want the PATH fallback /usr/bin/company-codex", got)
|
|
}
|
|
}
|
|
|
|
// stubProfilePathExecutable swaps the package-level profilePathExecutable
|
|
// indirection so override-preference tests can decide which paths are
|
|
// "executable" without staging real files. An absent path reports false.
|
|
func stubProfilePathExecutable(t *testing.T, executable map[string]bool) {
|
|
t.Helper()
|
|
orig := profilePathExecutable
|
|
profilePathExecutable = func(path string) bool { return executable[path] }
|
|
t.Cleanup(func() { profilePathExecutable = orig })
|
|
}
|
|
// bookkeeping that runTask relies on to override the launch path.
|
|
func TestCustomCommandPathForRuntime(t *testing.T) {
|
|
d := freshDaemon("")
|
|
d.profileCommandPaths = map[string]string{"prof-1": "/opt/bin/company-codex"}
|
|
// rt-custom is a custom-profile runtime; rt-builtin is a normal one.
|
|
d.runtimeIndex["rt-custom"] = Runtime{ID: "rt-custom", Provider: "codex", ProfileID: "prof-1"}
|
|
d.runtimeIndex["rt-builtin"] = Runtime{ID: "rt-builtin", Provider: "claude"}
|
|
|
|
if path, ok := d.customCommandPathForRuntime("rt-custom"); !ok || path != "/opt/bin/company-codex" {
|
|
t.Errorf("custom runtime: got (%q, %v), want (/opt/bin/company-codex, true)", path, ok)
|
|
}
|
|
if path, ok := d.customCommandPathForRuntime("rt-builtin"); ok || path != "" {
|
|
t.Errorf("built-in runtime: got (%q, %v), want (\"\", false)", path, ok)
|
|
}
|
|
if path, ok := d.customCommandPathForRuntime("rt-unknown"); ok || path != "" {
|
|
t.Errorf("unknown runtime: got (%q, %v), want (\"\", false)", path, ok)
|
|
}
|
|
// A custom runtime whose profile path was never resolved on this host
|
|
// (profile_id not in profileCommandPaths) must report not-custom so
|
|
// runTask falls back to its normal provider lookup rather than launching
|
|
// an empty path.
|
|
d.runtimeIndex["rt-unresolved"] = Runtime{ID: "rt-unresolved", Provider: "codex", ProfileID: "prof-missing"}
|
|
if path, ok := d.customCommandPathForRuntime("rt-unresolved"); ok || path != "" {
|
|
t.Errorf("unresolved profile: got (%q, %v), want (\"\", false)", path, ok)
|
|
}
|
|
}
|