mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
397
server/cmd/multica/cmd_runtime_profile.go
Normal file
397
server/cmd/multica/cmd_runtime_profile.go
Normal 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)
|
||||
}
|
||||
364
server/cmd/multica/cmd_runtime_profile_test.go
Normal file
364
server/cmd/multica/cmd_runtime_profile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("")
|
||||
|
||||
Reference in New Issue
Block a user