Files
multica/server/internal/cli/config.go
yushen d655762e2f 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>
2026-06-16 11:44:19 +08:00

186 lines
7.2 KiB
Go

package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
const defaultCLIConfigPath = ".multica/config.json"
// CLIConfig holds persistent CLI settings.
type CLIConfig struct {
ServerURL string `json:"server_url,omitempty"`
AppURL string `json:"app_url,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
Token string `json:"token,omitempty"`
// Backends contains per-backend overrides for users who want to point
// the daemon at non-default tool installations (e.g. an OpenClaw bundled
// inside another desktop app, or multiple isolated profiles on the same
// 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
// optional; nil means "no override for this backend". Keep new fields additive
// and tagged with `json:",omitempty"` so empty values do not change the saved
// config shape. Unknown-key preservation is a separate forward-compat concern:
// Go's encoding/json drops fields that are not represented in this struct on
// load/save round-trip (see TestCLIConfig_UnknownFieldsArePreserved).
type BackendOverrides struct {
OpenClaw *OpenClawOverride `json:"openclaw,omitempty"`
}
// OpenClawOverride configures the OpenClaw backend. All fields are optional;
// empty values fall through to the existing discovery path (PATH lookup for
// BinaryPath, default `~/.openclaw/` for StateDir).
//
// Resolution precedence (env beats config beats default, for back-compat):
//
// BinaryPath: MULTICA_OPENCLAW_PATH (env) > backends.openclaw.binary_path > PATH lookup
// StateDir: OPENCLAW_STATE_DIR (env) > backends.openclaw.state_dir > OpenClaw's built-in default (~/.openclaw)
//
// The StateDir env var here is OpenClaw's own OPENCLAW_STATE_DIR — NOT a new
// MULTICA_OPENCLAW_STATE_DIR. Rationale: OpenClaw already honors its own env
// var, the daemon already forwards inherited env to spawned children via
// `mergeEnv`, and a user who exports OPENCLAW_STATE_DIR in their shell
// already gets the right behavior with zero daemon changes today. This field
// is purely additive: when set, the daemon injects OPENCLAW_STATE_DIR=<value>
// into the spawned child's env unless the user already exported one upstream.
// (If a future use case needs daemon-namespaced isolation distinct from
// OpenClaw's own env, MULTICA_OPENCLAW_STATE_DIR can be layered on top
// without breaking this contract — see #3875 discussion.)
//
// Setting StateDir is the fix for the long-standing usability gap where
// users with non-default OpenClaw installations — multiple isolated
// profiles (dev/staging/prod, multiple accounts), containerized / CI
// deployments where ~/.openclaw isn't writable, or third-party desktop
// apps that bundle their own OpenClaw runtime — had to write a wrapper
// shell script to inject OPENCLAW_STATE_DIR + run `launchctl setenv`
// for GUI-launched daemons. With this field, those workarounds become
// unnecessary.
type OpenClawOverride struct {
BinaryPath string `json:"binary_path,omitempty"`
StateDir string `json:"state_dir,omitempty"`
}
// CLIConfigPath returns the default path for the CLI config file.
func CLIConfigPath() (string, error) {
return CLIConfigPathForProfile("")
}
// CLIConfigPathForProfile returns the config file path for the given profile.
// An empty profile returns the default path (~/.multica/config.json).
// A named profile returns ~/.multica/profiles/<name>/config.json.
func CLIConfigPathForProfile(profile string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve CLI config path: %w", err)
}
if profile == "" {
return filepath.Join(home, defaultCLIConfigPath), nil
}
return filepath.Join(home, ".multica", "profiles", profile, "config.json"), nil
}
// ProfileDir returns the base directory for a profile's state files (pid, log).
// An empty profile returns ~/.multica/. A named profile returns ~/.multica/profiles/<name>/.
func ProfileDir(profile string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve profile dir: %w", err)
}
if profile == "" {
return filepath.Join(home, ".multica"), nil
}
return filepath.Join(home, ".multica", "profiles", profile), nil
}
// LoadCLIConfig reads the CLI config from disk (default profile).
func LoadCLIConfig() (CLIConfig, error) {
return LoadCLIConfigForProfile("")
}
// LoadCLIConfigForProfile reads the CLI config for the given profile.
func LoadCLIConfigForProfile(profile string) (CLIConfig, error) {
path, err := CLIConfigPathForProfile(profile)
if err != nil {
return CLIConfig{}, err
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return CLIConfig{}, nil
}
return CLIConfig{}, fmt.Errorf("read CLI config: %w", err)
}
var cfg CLIConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return CLIConfig{}, fmt.Errorf("parse CLI config: %w", err)
}
return cfg, nil
}
// SaveCLIConfig writes the CLI config to disk atomically (default profile).
func SaveCLIConfig(cfg CLIConfig) error {
return SaveCLIConfigForProfile(cfg, "")
}
// SaveCLIConfigForProfile writes the CLI config for the given profile.
func SaveCLIConfigForProfile(cfg CLIConfig, profile string) error {
path, err := CLIConfigPathForProfile(profile)
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create CLI config directory: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode CLI config: %w", err)
}
// Write to a temp file in the same directory, then rename for atomicity.
tmp, err := os.CreateTemp(dir, ".config-*.json.tmp")
if err != nil {
return fmt.Errorf("create temp config file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(append(data, '\n')); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("write temp config file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close temp config file: %w", err)
}
if err := os.Chmod(tmpPath, 0o600); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("chmod temp config file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("rename config file: %w", err)
}
return nil
}