From d655762e2f347bdcd640c417a0298a81fb9bd5d0 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 16 Jun 2026 11:44:19 +0800 Subject: [PATCH] MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/cmd/multica/cmd_runtime_profile.go | 397 ++++++++++++++++++ .../cmd/multica/cmd_runtime_profile_test.go | 364 ++++++++++++++++ server/internal/cli/config.go | 13 + server/internal/cli/config_test.go | 86 ++++ server/internal/daemon/config.go | 28 +- server/internal/daemon/daemon.go | 56 ++- .../internal/daemon/runtime_profile_test.go | 76 +++- 7 files changed, 1009 insertions(+), 11 deletions(-) create mode 100644 server/cmd/multica/cmd_runtime_profile.go create mode 100644 server/cmd/multica/cmd_runtime_profile_test.go diff --git a/server/cmd/multica/cmd_runtime_profile.go b/server/cmd/multica/cmd_runtime_profile.go new file mode 100644 index 000000000..93cbdca00 --- /dev/null +++ b/server/cmd/multica/cmd_runtime_profile.go @@ -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 ", + Short: "Update a custom runtime profile (protocol family is immutable)", + Args: exactArgs(1), + RunE: runRuntimeProfileUpdate, +} + +var runtimeProfileDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a custom runtime profile", + Args: exactArgs(1), + RunE: runRuntimeProfileDelete, +} + +var runtimeProfileSetPathCmd = &cobra.Command{ + Use: "set-path ", + 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 ", + 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) +} diff --git a/server/cmd/multica/cmd_runtime_profile_test.go b/server/cmd/multica/cmd_runtime_profile_test.go new file mode 100644 index 000000000..d5d5c9541 --- /dev/null +++ b/server/cmd/multica/cmd_runtime_profile_test.go @@ -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) + } +} diff --git a/server/internal/cli/config.go b/server/internal/cli/config.go index 0419f23dc..cb7fa7e9a 100644 --- a/server/internal/cli/config.go +++ b/server/internal/cli/config.go @@ -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 diff --git a/server/internal/cli/config_test.go b/server/internal/cli/config_test.go index 93ad4cb29..b16a2b743 100644 --- a/server/internal/cli/config_test.go +++ b/server/internal/cli/config_test.go @@ -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 diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 70fb75340..0f600513e 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -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 } diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 0ce07b937..665cf30a9 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -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) diff --git a/server/internal/daemon/runtime_profile_test.go b/server/internal/daemon/runtime_profile_test.go index dba26e73b..82cc8de69 100644 --- a/server/internal/daemon/runtime_profile_test.go +++ b/server/internal/daemon/runtime_profile_test.go @@ -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("")