MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override

- 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>
This commit is contained in:
yushen
2026-06-16 11:44:19 +08:00
parent cadfae6ef1
commit d655762e2f
7 changed files with 1009 additions and 11 deletions

View File

@@ -0,0 +1,397 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/pkg/agent"
)
// ---------------------------------------------------------------------------
// `multica runtime profile ...` — custom runtime profiles (MUL-3284)
//
// A runtime profile lets a workspace declare a custom agent runtime built on
// top of a supported protocol family (the routing backend) but launched via a
// site-specific command_name (e.g. a wrapper that injects credentials). The
// profile lives server-side and is workspace-scoped; the daemon resolves the
// command_name on each host's PATH at registration time.
//
// `set-path` / `unset-path` are the per-machine escape hatch: they record a
// profile_id -> absolute executable path mapping in this machine's local CLI
// config so the daemon can launch a profile whose command isn't on PATH (or
// pick a specific install among several). That mapping never leaves the
// machine — it is not sent to the server.
// ---------------------------------------------------------------------------
var runtimeProfileCmd = &cobra.Command{
Use: "profile",
Short: "Manage custom runtime profiles",
}
var runtimeProfileListCmd = &cobra.Command{
Use: "list",
Short: "List custom runtime profiles in the workspace",
RunE: runRuntimeProfileList,
}
var runtimeProfileCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a custom runtime profile",
RunE: runRuntimeProfileCreate,
}
var runtimeProfileUpdateCmd = &cobra.Command{
Use: "update <profile-id>",
Short: "Update a custom runtime profile (protocol family is immutable)",
Args: exactArgs(1),
RunE: runRuntimeProfileUpdate,
}
var runtimeProfileDeleteCmd = &cobra.Command{
Use: "delete <profile-id>",
Short: "Delete a custom runtime profile",
Args: exactArgs(1),
RunE: runRuntimeProfileDelete,
}
var runtimeProfileSetPathCmd = &cobra.Command{
Use: "set-path <profile-id>",
Short: "Pin a per-machine executable path for a runtime profile (local only)",
Args: exactArgs(1),
RunE: runRuntimeProfileSetPath,
}
var runtimeProfileUnsetPathCmd = &cobra.Command{
Use: "unset-path <profile-id>",
Short: "Remove a per-machine executable path override for a runtime profile",
Args: exactArgs(1),
RunE: runRuntimeProfileUnsetPath,
}
func init() {
runtimeCmd.AddCommand(runtimeProfileCmd)
runtimeProfileCmd.AddCommand(runtimeProfileListCmd)
runtimeProfileCmd.AddCommand(runtimeProfileCreateCmd)
runtimeProfileCmd.AddCommand(runtimeProfileUpdateCmd)
runtimeProfileCmd.AddCommand(runtimeProfileDeleteCmd)
runtimeProfileCmd.AddCommand(runtimeProfileSetPathCmd)
runtimeProfileCmd.AddCommand(runtimeProfileUnsetPathCmd)
// list
runtimeProfileListCmd.Flags().String("output", "table", "Output format: table or json")
// create
runtimeProfileCreateCmd.Flags().String("protocol-family", "", "Supported backend the profile routes to (required)")
runtimeProfileCreateCmd.Flags().String("command-name", "", "Executable the daemon resolves on PATH (required)")
runtimeProfileCreateCmd.Flags().String("display-name", "", "Human-readable profile name (required)")
runtimeProfileCreateCmd.Flags().String("description", "", "Optional description")
runtimeProfileCreateCmd.Flags().StringArray("fixed-arg", nil, "Launch argument every agent on this runtime inherits (repeatable)")
runtimeProfileCreateCmd.Flags().String("visibility", "", "Visibility: workspace or private")
runtimeProfileCreateCmd.Flags().String("output", "json", "Output format: table or json")
// update
runtimeProfileUpdateCmd.Flags().String("display-name", "", "New display name")
runtimeProfileUpdateCmd.Flags().String("command-name", "", "New command name")
runtimeProfileUpdateCmd.Flags().String("description", "", "New description")
runtimeProfileUpdateCmd.Flags().StringArray("fixed-arg", nil, "Replace launch arguments (repeatable; pass once per arg)")
runtimeProfileUpdateCmd.Flags().String("visibility", "", "New visibility: workspace or private")
runtimeProfileUpdateCmd.Flags().Bool("enabled", true, "Enable or disable the profile")
runtimeProfileUpdateCmd.Flags().String("output", "json", "Output format: table or json")
// set-path
runtimeProfileSetPathCmd.Flags().String("path", "", "Absolute path to the executable on this machine (required)")
}
// runtimeProfilesPath builds the workspace-scoped collection path.
func runtimeProfilesPath(workspaceID string) string {
return fmt.Sprintf("/api/workspaces/%s/runtime-profiles", workspaceID)
}
// validateProtocolFamily checks a protocol family against the canonical agent
// whitelist client-side so an obvious typo fails fast with a helpful list
// instead of an opaque server 400.
func validateProtocolFamily(family string) error {
if !agent.IsSupportedType(family) {
return fmt.Errorf("invalid --protocol-family %q: must be one of %s",
family, strings.Join(agent.SupportedTypes, ", "))
}
return nil
}
// validateVisibility rejects anything other than the two server-accepted
// visibility values. An empty string is allowed (means "let the server pick
// its default" on create, "leave unchanged" on update).
func validateVisibility(visibility string) error {
switch visibility {
case "", "workspace", "private":
return nil
default:
return fmt.Errorf("invalid --visibility %q: must be workspace or private", visibility)
}
}
func runRuntimeProfileList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
var resp struct {
RuntimeProfiles []map[string]any `json:"runtime_profiles"`
}
if err := client.GetJSON(ctx, runtimeProfilesPath(workspaceID), &resp); err != nil {
return fmt.Errorf("list runtime profiles: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, resp.RuntimeProfiles)
}
printRuntimeProfileTable(resp.RuntimeProfiles)
return nil
}
func runRuntimeProfileCreate(cmd *cobra.Command, _ []string) error {
family, _ := cmd.Flags().GetString("protocol-family")
commandName, _ := cmd.Flags().GetString("command-name")
displayName, _ := cmd.Flags().GetString("display-name")
description, _ := cmd.Flags().GetString("description")
fixedArgs, _ := cmd.Flags().GetStringArray("fixed-arg")
visibility, _ := cmd.Flags().GetString("visibility")
if strings.TrimSpace(family) == "" {
return fmt.Errorf("--protocol-family is required")
}
if strings.TrimSpace(commandName) == "" {
return fmt.Errorf("--command-name is required")
}
if strings.TrimSpace(displayName) == "" {
return fmt.Errorf("--display-name is required")
}
if err := validateProtocolFamily(family); err != nil {
return err
}
if err := validateVisibility(visibility); err != nil {
return err
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
body := map[string]any{
"display_name": displayName,
"protocol_family": family,
"command_name": commandName,
}
if description != "" {
body["description"] = description
}
if len(fixedArgs) > 0 {
body["fixed_args"] = fixedArgs
}
if visibility != "" {
body["visibility"] = visibility
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
var profile map[string]any
if err := client.PostJSON(ctx, runtimeProfilesPath(workspaceID), body, &profile); err != nil {
return fmt.Errorf("create runtime profile: %w", err)
}
return outputRuntimeProfile(cmd, profile)
}
func runRuntimeProfileUpdate(cmd *cobra.Command, args []string) error {
profileID := args[0]
body := map[string]any{}
if cmd.Flags().Changed("display-name") {
v, _ := cmd.Flags().GetString("display-name")
body["display_name"] = v
}
if cmd.Flags().Changed("command-name") {
v, _ := cmd.Flags().GetString("command-name")
body["command_name"] = v
}
if cmd.Flags().Changed("description") {
v, _ := cmd.Flags().GetString("description")
body["description"] = v
}
if cmd.Flags().Changed("fixed-arg") {
v, _ := cmd.Flags().GetStringArray("fixed-arg")
body["fixed_args"] = v
}
if cmd.Flags().Changed("visibility") {
v, _ := cmd.Flags().GetString("visibility")
if err := validateVisibility(v); err != nil {
return err
}
body["visibility"] = v
}
if cmd.Flags().Changed("enabled") {
v, _ := cmd.Flags().GetBool("enabled")
body["enabled"] = v
}
if len(body) == 0 {
return fmt.Errorf("no fields to update: pass at least one of --display-name, --command-name, --description, --fixed-arg, --visibility, --enabled")
}
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
path := runtimeProfilesPath(workspaceID) + "/" + profileID
var profile map[string]any
if err := client.PatchJSON(ctx, path, body, &profile); err != nil {
return fmt.Errorf("update runtime profile: %w", err)
}
return outputRuntimeProfile(cmd, profile)
}
func runRuntimeProfileDelete(cmd *cobra.Command, args []string) error {
profileID := args[0]
client, err := newAPIClient(cmd)
if err != nil {
return err
}
workspaceID, err := requireWorkspaceID(cmd)
if err != nil {
return err
}
ctx, cancel := cli.APIContext(context.Background())
defer cancel()
path := runtimeProfilesPath(workspaceID) + "/" + profileID
if err := client.DeleteJSON(ctx, path); err != nil {
// 409 means the server refused because active agents are still bound
// to this profile. Surface the server's explanation verbatim rather
// than the generic HTTP wrapper so the user sees what to unbind.
var httpErr *cli.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusConflict {
msg := strings.TrimSpace(httpErr.Body)
if msg == "" {
msg = "profile still has active agents bound to it"
}
return fmt.Errorf("cannot delete runtime profile %s: %s", profileID, msg)
}
return fmt.Errorf("delete runtime profile: %w", err)
}
fmt.Printf("Deleted runtime profile %s\n", profileID)
return nil
}
func runRuntimeProfileSetPath(cmd *cobra.Command, args []string) error {
profileID := args[0]
path, _ := cmd.Flags().GetString("path")
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("--path is required")
}
if !filepath.IsAbs(path) {
return fmt.Errorf("--path must be an absolute path, got %q", path)
}
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return fmt.Errorf("load CLI config: %w", err)
}
if cfg.ProfileCommandOverrides == nil {
cfg.ProfileCommandOverrides = map[string]string{}
}
cfg.ProfileCommandOverrides[profileID] = path
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save CLI config: %w", err)
}
fmt.Printf("Pinned runtime profile %s to %s on this machine.\n", profileID, path)
fmt.Println("Restart the daemon for the change to take effect.")
return nil
}
func runRuntimeProfileUnsetPath(cmd *cobra.Command, args []string) error {
profileID := args[0]
profile := resolveProfile(cmd)
cfg, err := cli.LoadCLIConfigForProfile(profile)
if err != nil {
return fmt.Errorf("load CLI config: %w", err)
}
if _, ok := cfg.ProfileCommandOverrides[profileID]; !ok {
fmt.Printf("No per-machine path override set for runtime profile %s.\n", profileID)
return nil
}
delete(cfg.ProfileCommandOverrides, profileID)
if len(cfg.ProfileCommandOverrides) == 0 {
// Normalize back to nil so the key drops out of the saved JSON.
cfg.ProfileCommandOverrides = nil
}
if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil {
return fmt.Errorf("save CLI config: %w", err)
}
fmt.Printf("Removed per-machine path override for runtime profile %s.\n", profileID)
fmt.Println("Restart the daemon for the change to take effect.")
return nil
}
// outputRuntimeProfile renders a single profile honoring --output.
func outputRuntimeProfile(cmd *cobra.Command, profile map[string]any) error {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, profile)
}
printRuntimeProfileTable([]map[string]any{profile})
return nil
}
// printRuntimeProfileTable renders profiles as a stable, sorted table.
func printRuntimeProfileTable(profiles []map[string]any) {
headers := []string{"ID", "DISPLAY_NAME", "PROTOCOL_FAMILY", "COMMAND_NAME", "VISIBILITY", "ENABLED"}
rows := make([][]string, 0, len(profiles))
for _, p := range profiles {
rows = append(rows, []string{
strVal(p, "id"),
strVal(p, "display_name"),
strVal(p, "protocol_family"),
strVal(p, "command_name"),
strVal(p, "visibility"),
strVal(p, "enabled"),
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i][1] < rows[j][1] })
cli.PrintTable(os.Stdout, headers, rows)
}

View File

@@ -0,0 +1,364 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
// addCommonProfileFlags wires the persistent-style flags the run functions
// resolve (server-url, workspace-id, profile, token) onto a detached test
// command so the helpers can be invoked directly.
func addCommonProfileFlags(cmd *cobra.Command) {
cmd.Flags().String("server-url", "", "")
cmd.Flags().String("workspace-id", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().String("token", "", "")
}
func newProfileListTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "list"}
addCommonProfileFlags(cmd)
cmd.Flags().String("output", "json", "")
return cmd
}
func newProfileCreateTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "create"}
addCommonProfileFlags(cmd)
cmd.Flags().String("protocol-family", "", "")
cmd.Flags().String("command-name", "", "")
cmd.Flags().String("display-name", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().StringArray("fixed-arg", nil, "")
cmd.Flags().String("visibility", "", "")
cmd.Flags().String("output", "json", "")
return cmd
}
func newProfileUpdateTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "update"}
addCommonProfileFlags(cmd)
cmd.Flags().String("display-name", "", "")
cmd.Flags().String("command-name", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().StringArray("fixed-arg", nil, "")
cmd.Flags().String("visibility", "", "")
cmd.Flags().Bool("enabled", true, "")
cmd.Flags().String("output", "json", "")
return cmd
}
func newProfileDeleteTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "delete"}
addCommonProfileFlags(cmd)
return cmd
}
func newProfileSetPathTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "set-path"}
addCommonProfileFlags(cmd)
cmd.Flags().String("path", "", "")
return cmd
}
func newProfileUnsetPathTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "unset-path"}
addCommonProfileFlags(cmd)
return cmd
}
// TestRuntimeProfileCommandsRegistered verifies the subcommands are wired
// under `runtime profile`.
func TestRuntimeProfileCommandsRegistered(t *testing.T) {
for _, name := range []string{"list", "create", "update", "delete", "set-path", "unset-path"} {
cmd, _, err := runtimeProfileCmd.Find([]string{name})
if err != nil {
t.Fatalf("find %q: %v", name, err)
}
if cmd == nil || cmd.Name() != name {
t.Fatalf("%q not registered under `runtime profile`; got %#v", name, cmd)
}
}
// And `profile` itself must hang off `runtime`.
cmd, _, err := runtimeCmd.Find([]string{"profile", "list"})
if err != nil || cmd == nil || cmd.Name() != "list" {
t.Fatalf("`runtime profile list` not reachable from runtime command: %v / %#v", err, cmd)
}
}
func TestRunRuntimeProfileList(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
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")
_ = json.NewEncoder(w).Encode(map[string]any{
"runtime_profiles": []map[string]any{
{"id": "prof-1", "display_name": "Company Codex", "protocol_family": "codex", "command_name": "company-codex", "visibility": "workspace", "enabled": true},
},
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileListTestCmd()
_ = cmd.Flags().Set("output", "json")
if err := runRuntimeProfileList(cmd, nil); err != nil {
t.Fatalf("runRuntimeProfileList: %v", err)
}
if gotMethod != http.MethodGet {
t.Errorf("method = %s, want GET", gotMethod)
}
if gotPath != "/api/workspaces/ws-123/runtime-profiles" {
t.Errorf("path = %q, want /api/workspaces/ws-123/runtime-profiles", gotPath)
}
}
func TestRunRuntimeProfileCreate(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
var gotMethod, gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"id": "prof-1", "display_name": "Company Codex"})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileCreateTestCmd()
_ = cmd.Flags().Set("protocol-family", "codex")
_ = cmd.Flags().Set("command-name", "company-codex")
_ = cmd.Flags().Set("display-name", "Company Codex")
_ = cmd.Flags().Set("fixed-arg", "--foo")
_ = cmd.Flags().Set("fixed-arg", "--bar")
_ = cmd.Flags().Set("visibility", "workspace")
if err := runRuntimeProfileCreate(cmd, nil); err != nil {
t.Fatalf("runRuntimeProfileCreate: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %s, want POST", gotMethod)
}
if gotPath != "/api/workspaces/ws-123/runtime-profiles" {
t.Errorf("path = %q, want /api/workspaces/ws-123/runtime-profiles", gotPath)
}
if gotBody["protocol_family"] != "codex" || gotBody["command_name"] != "company-codex" || gotBody["display_name"] != "Company Codex" {
t.Errorf("unexpected body: %#v", gotBody)
}
args, ok := gotBody["fixed_args"].([]any)
if !ok || len(args) != 2 || args[0] != "--foo" || args[1] != "--bar" {
t.Errorf("fixed_args = %#v, want [--foo --bar]", gotBody["fixed_args"])
}
if gotBody["visibility"] != "workspace" {
t.Errorf("visibility = %v, want workspace", gotBody["visibility"])
}
}
func TestRunRuntimeProfileCreateRejectsBadFamily(t *testing.T) {
cmd := newProfileCreateTestCmd()
_ = cmd.Flags().Set("protocol-family", "not-a-real-backend")
_ = cmd.Flags().Set("command-name", "x")
_ = cmd.Flags().Set("display-name", "X")
// No server should ever be contacted; this must fail client-side.
if err := runRuntimeProfileCreate(cmd, nil); err == nil {
t.Fatal("expected invalid --protocol-family error")
}
}
func TestRunRuntimeProfileCreateRequiresFlags(t *testing.T) {
cmd := newProfileCreateTestCmd()
if err := runRuntimeProfileCreate(cmd, nil); err == nil {
t.Fatal("expected missing --protocol-family error")
}
}
func TestRunRuntimeProfileUpdateOnlySendsChangedFlags(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
var gotMethod, gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"id": "prof-1"})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileUpdateTestCmd()
_ = cmd.Flags().Set("command-name", "new-codex")
_ = cmd.Flags().Set("enabled", "false")
if err := runRuntimeProfileUpdate(cmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileUpdate: %v", err)
}
if gotMethod != http.MethodPatch {
t.Errorf("method = %s, want PATCH", gotMethod)
}
if gotPath != "/api/workspaces/ws-123/runtime-profiles/prof-1" {
t.Errorf("path = %q, want .../runtime-profiles/prof-1", gotPath)
}
// Only the two changed flags must be present.
if gotBody["command_name"] != "new-codex" {
t.Errorf("command_name = %v, want new-codex", gotBody["command_name"])
}
if gotBody["enabled"] != false {
t.Errorf("enabled = %v, want false", gotBody["enabled"])
}
if _, ok := gotBody["display_name"]; ok {
t.Errorf("display_name should not be sent when unchanged: %#v", gotBody)
}
if _, ok := gotBody["visibility"]; ok {
t.Errorf("visibility should not be sent when unchanged: %#v", gotBody)
}
}
func TestRunRuntimeProfileUpdateNoFieldsErrors(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0")
cmd := newProfileUpdateTestCmd()
if err := runRuntimeProfileUpdate(cmd, []string{"prof-1"}); err == nil {
t.Fatal("expected 'no fields to update' error")
}
}
func TestRunRuntimeProfileDeleteSuccess(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
var gotMethod, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileDeleteTestCmd()
if err := runRuntimeProfileDelete(cmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileDelete: %v", err)
}
if gotMethod != http.MethodDelete {
t.Errorf("method = %s, want DELETE", gotMethod)
}
if gotPath != "/api/workspaces/ws-123/runtime-profiles/prof-1" {
t.Errorf("path = %q, want .../runtime-profiles/prof-1", gotPath)
}
}
func TestRunRuntimeProfileDeleteConflictSurfacesServerMessage(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "ws-123")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte("2 active agents are bound to this profile"))
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newProfileDeleteTestCmd()
err := runRuntimeProfileDelete(cmd, []string{"prof-1"})
if err == nil {
t.Fatal("expected conflict error")
}
if got := err.Error(); !strings.Contains(got, "2 active agents are bound to this profile") {
t.Errorf("error %q should surface the server message", got)
}
}
func TestRunRuntimeProfileSetAndUnsetPath(t *testing.T) {
t.Setenv("HOME", t.TempDir())
// set-path
setCmd := newProfileSetPathTestCmd()
_ = setCmd.Flags().Set("path", "/opt/bin/company-codex")
if err := runRuntimeProfileSetPath(setCmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileSetPath: %v", err)
}
cfg, err := cli.LoadCLIConfig()
if err != nil {
t.Fatalf("LoadCLIConfig: %v", err)
}
if got := cfg.ProfileCommandOverrides["prof-1"]; got != "/opt/bin/company-codex" {
t.Fatalf("override after set = %q, want /opt/bin/company-codex", got)
}
// unset-path
unsetCmd := newProfileUnsetPathTestCmd()
if err := runRuntimeProfileUnsetPath(unsetCmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileUnsetPath: %v", err)
}
cfg, err = cli.LoadCLIConfig()
if err != nil {
t.Fatalf("LoadCLIConfig after unset: %v", err)
}
if _, ok := cfg.ProfileCommandOverrides["prof-1"]; ok {
t.Fatalf("override should be removed after unset, got %#v", cfg.ProfileCommandOverrides)
}
}
func TestRunRuntimeProfileSetPathRejectsRelative(t *testing.T) {
t.Setenv("HOME", t.TempDir())
cmd := newProfileSetPathTestCmd()
_ = cmd.Flags().Set("path", "relative/path")
if err := runRuntimeProfileSetPath(cmd, []string{"prof-1"}); err == nil {
t.Fatal("expected absolute-path error")
}
}
func TestRunRuntimeProfileSetPathPreservesExistingConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Seed an existing config with unrelated fields.
seed := cli.CLIConfig{ServerURL: "https://api.multica.ai", WorkspaceID: "ws-123", Token: "mul_xyz"}
if err := cli.SaveCLIConfig(seed); err != nil {
t.Fatal(err)
}
cmd := newProfileSetPathTestCmd()
_ = cmd.Flags().Set("path", "/opt/bin/company-codex")
if err := runRuntimeProfileSetPath(cmd, []string{"prof-1"}); err != nil {
t.Fatalf("runRuntimeProfileSetPath: %v", err)
}
cfg, err := cli.LoadCLIConfig()
if err != nil {
t.Fatal(err)
}
if cfg.ServerURL != "https://api.multica.ai" || cfg.WorkspaceID != "ws-123" || cfg.Token != "mul_xyz" {
t.Errorf("set-path clobbered existing config: %#v", cfg)
}
if cfg.ProfileCommandOverrides["prof-1"] != "/opt/bin/company-codex" {
t.Errorf("override not written: %#v", cfg.ProfileCommandOverrides)
}
}

View File

@@ -23,6 +23,19 @@ type CLIConfig struct {
// machine). Empty / absent means "discover from PATH and use vendor
// defaults" — the historical behavior. See issue #3875.
Backends *BackendOverrides `json:"backends,omitempty"`
// ProfileCommandOverrides is a per-machine map of custom runtime
// profile_id -> absolute executable path (MUL-3284). A workspace custom
// runtime profile records the command_name the daemon resolves on PATH,
// but the same logical profile may live at a different path on each
// machine (or not be on PATH at all). This map lets an operator pin the
// exact binary for a profile on this host via
// `multica runtime profile set-path`; the daemon prefers it over the
// PATH lookup in appendProfileRuntimes. Empty / absent means "resolve the
// profile's command_name on PATH" — the default behavior. The mapping is
// intentionally local-only (it is never sent to the server) because the
// path is a property of this machine, not of the shared profile.
ProfileCommandOverrides map[string]string `json:"profile_command_overrides,omitempty"`
}
// BackendOverrides holds per-backend configuration overrides. Each field is

View File

@@ -166,6 +166,92 @@ func TestCLIConfig_OpenClawOverride_PartialFieldsOmitted(t *testing.T) {
}
}
// TestCLIConfig_ProfileCommandOverrides_RoundTrip verifies that pinning a
// per-machine profile command path survives a save/load cycle AND that
// unrelated fields (server_url, token, backends) are preserved across the
// round-trip — the set-path / unset-path CLI commands rely on a
// load->modify->save cycle never dropping config the user already had.
func TestCLIConfig_ProfileCommandOverrides_RoundTrip(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
original := CLIConfig{
ServerURL: "https://api.multica.ai",
AppURL: "https://app.multica.ai",
WorkspaceID: "ws-123",
Token: "mul_xyz",
Backends: &BackendOverrides{
OpenClaw: &OpenClawOverride{StateDir: "/var/lib/openclaw-prod"},
},
ProfileCommandOverrides: map[string]string{
"prof-1": "/opt/bin/company-codex",
"prof-2": "/usr/local/bin/special-claude",
},
}
if err := SaveCLIConfig(original); err != nil {
t.Fatal(err)
}
loaded, err := LoadCLIConfig()
if err != nil {
t.Fatal(err)
}
// The override map must round-trip intact.
if len(loaded.ProfileCommandOverrides) != 2 {
t.Fatalf("ProfileCommandOverrides len = %d, want 2: %+v", len(loaded.ProfileCommandOverrides), loaded.ProfileCommandOverrides)
}
if got := loaded.ProfileCommandOverrides["prof-1"]; got != "/opt/bin/company-codex" {
t.Errorf("prof-1 override = %q, want /opt/bin/company-codex", got)
}
if got := loaded.ProfileCommandOverrides["prof-2"]; got != "/usr/local/bin/special-claude" {
t.Errorf("prof-2 override = %q, want /usr/local/bin/special-claude", got)
}
// Every other field must be preserved (no clobbering on round-trip).
if loaded.ServerURL != original.ServerURL {
t.Errorf("ServerURL = %q, want %q", loaded.ServerURL, original.ServerURL)
}
if loaded.AppURL != original.AppURL {
t.Errorf("AppURL = %q, want %q", loaded.AppURL, original.AppURL)
}
if loaded.WorkspaceID != original.WorkspaceID {
t.Errorf("WorkspaceID = %q, want %q", loaded.WorkspaceID, original.WorkspaceID)
}
if loaded.Token != original.Token {
t.Errorf("Token = %q, want %q", loaded.Token, original.Token)
}
if loaded.Backends == nil || loaded.Backends.OpenClaw == nil ||
loaded.Backends.OpenClaw.StateDir != "/var/lib/openclaw-prod" {
t.Errorf("Backends.OpenClaw not preserved: %+v", loaded.Backends)
}
}
// TestCLIConfig_ProfileCommandOverrides_OmittedWhenEmpty verifies the
// omitempty tag keeps the key out of the on-disk JSON when no overrides are
// set, so configs for users who never pin a path stay byte-stable.
func TestCLIConfig_ProfileCommandOverrides_OmittedWhenEmpty(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
cfg := CLIConfig{ServerURL: "https://api.multica.ai", Token: "mul_xyz"}
if err := SaveCLIConfig(cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(tmp, ".multica", "config.json"))
if err != nil {
t.Fatal(err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatal(err)
}
if _, ok := raw["profile_command_overrides"]; ok {
t.Errorf("profile_command_overrides should be omitted when empty, got: %s", string(data))
}
}
// TestCLIConfig_UnknownFieldsArePreserved verifies forward-compat: a future
// daemon that adds, say, a `backends.codex` key should not have its data
// destroyed when an older daemon (without knowledge of that key) reads and

View File

@@ -102,6 +102,14 @@ type Config struct {
ClaudeArgs []string
CodexArgs []string
CodebuddyArgs []string
// ProfileCommandOverrides maps a custom runtime profile_id -> the absolute
// executable path to use for that profile on THIS machine (MUL-3284).
// Sourced from the local CLI config (cli.CLIConfig.ProfileCommandOverrides),
// written by `multica runtime profile set-path`. appendProfileRuntimes
// prefers a matching, executable override over resolving the profile's
// command_name on PATH. nil/empty means "always resolve via PATH".
ProfileCommandOverrides map[string]string
}
// Overrides allows CLI flags to override environment variables and defaults.
@@ -165,11 +173,26 @@ func LoadConfig(overrides Overrides) (Config, error) {
// file should not prevent daemon startup, since the daemon can still run
// purely from env-var configuration. We log a warning and proceed with
// no overrides.
var profileCommandOverrides map[string]string
if cliCfg, err := cli.LoadCLIConfigForProfile(overrides.Profile); err != nil {
slog.Warn("could not load CLI config for backend overrides; proceeding without",
"profile", overrides.Profile, "err", err)
} else if oc := openclawOverrideFrom(cliCfg); oc != nil {
applyOpenclawOverride(oc)
} else {
if oc := openclawOverrideFrom(cliCfg); oc != nil {
applyOpenclawOverride(oc)
}
// Per-machine custom-runtime command path overrides (MUL-3284).
// Copy into our own map so later mutation of the loaded config can't
// alias daemon state, and so an empty map normalizes to nil.
if len(cliCfg.ProfileCommandOverrides) > 0 {
profileCommandOverrides = make(map[string]string, len(cliCfg.ProfileCommandOverrides))
for id, path := range cliCfg.ProfileCommandOverrides {
if id == "" || strings.TrimSpace(path) == "" {
continue
}
profileCommandOverrides[id] = path
}
}
}
// Probe available agent CLIs. exec.LookPath is the primary path, but on
@@ -500,6 +523,7 @@ func LoadConfig(overrides Overrides) (Config, error) {
ClaudeArgs: claudeArgs,
CodexArgs: codexArgs,
CodebuddyArgs: codebuddyArgs,
ProfileCommandOverrides: profileCommandOverrides,
}, nil
}

View File

@@ -64,6 +64,21 @@ var (
// resolve custom runtime-profile commands without manipulating the
// process PATH. Mirrors the detectAgentVersion hook above.
lookPath = exec.LookPath
// profilePathExecutable reports whether path points at an existing,
// non-directory file with at least one executable bit set. It is the
// gate appendProfileRuntimes uses before trusting a per-machine command
// path override (MUL-3284) — a stale or mistyped override must fall back
// to the PATH lookup rather than register a runtime that can't launch.
// Indirected as a package var so tests can assert override preference
// without staging a real executable on disk.
profilePathExecutable = func(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
return info.Mode().Perm()&0o111 != 0
}
)
// workspaceState tracks registered runtimes for a single workspace.
@@ -884,14 +899,39 @@ func (d *Daemon) appendProfileRuntimes(ctx context.Context, workspaceID string,
"workspace_id", workspaceID, "profile_id", profile.ID, "display_name", profile.DisplayName)
continue
}
resolved, err := lookPath(profile.CommandName)
if err != nil {
// Host doesn't have this command — expected on hosts that aren't
// provisioned for this profile. Skip without failing.
d.logger.Info("skip custom runtime profile: command not found on PATH",
"workspace_id", workspaceID, "profile_id", profile.ID,
"command_name", profile.CommandName, "error", err)
continue
// Resolve the executable to launch for this profile. A per-machine
// path override (MUL-3284, `multica runtime profile set-path`) wins
// over the PATH lookup when it is set AND points at a real
// executable — this is how an operator pins a profile to a binary
// that isn't on the daemon's PATH, or selects between multiple
// installs on the same host. A configured-but-unusable override
// (deleted/moved/non-executable) is logged and falls back to PATH
// rather than registering a runtime that can't launch. When neither
// the override nor PATH resolves, the profile is skipped (existing
// behavior).
var resolved string
if override := strings.TrimSpace(d.cfg.ProfileCommandOverrides[profile.ID]); override != "" {
if profilePathExecutable(override) {
resolved = override
d.logger.Info("custom runtime profile: using per-machine command path override",
"workspace_id", workspaceID, "profile_id", profile.ID, "command_path", resolved)
} else {
d.logger.Warn("custom runtime profile: command path override not executable; falling back to PATH",
"workspace_id", workspaceID, "profile_id", profile.ID,
"override_path", override, "command_name", profile.CommandName)
}
}
if resolved == "" {
r, err := lookPath(profile.CommandName)
if err != nil {
// Host doesn't have this command — expected on hosts that aren't
// provisioned for this profile. Skip without failing.
d.logger.Info("skip custom runtime profile: command not found on PATH",
"workspace_id", workspaceID, "profile_id", profile.ID,
"command_name", profile.CommandName, "error", err)
continue
}
resolved = r
}
// Best-effort version detection; an empty version is acceptable.
version, verErr := detectAgentVersion(ctx, resolved)

View File

@@ -250,7 +250,81 @@ func TestRegisterRuntimes_ProfilesFetchErrorIsBestEffort(t *testing.T) {
}
}
// TestCustomCommandPathForRuntime covers the runtimeID -> command-path
// 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("")