Files
multica/server/internal/cli/config.go
cc09c3c12a MUL-7008: Relax healthy WebSocket claim polling to 3 minutes (#7983)
* MUL-7008: relax websocket claim fallback polling

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): harden websocket claim polling

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): harden deferred claim scheduling

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 13:01:07 +08:00

397 lines
17 KiB
Go

package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
defaultCLIConfigPath = ".multica/config.json"
// TaskConfigRootEnv points daemon-managed CLI invocations at a private,
// per-task Multica config directory. It is deliberately Multica-specific:
// phase-one hardening keeps the real HOME/XDG environment available to
// provider tooling while preventing implicit Owner-profile discovery.
TaskConfigRootEnv = "MULTICA_TASK_CONFIG_ROOT"
)
// 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"`
// DeviceName is the human-readable label shown in the server's Runtimes
// UI for the daemon started with this profile. When multiple daemons run
// on the same host under different profiles (typical for shared servers
// where one Linux user hosts one profile), os.Hostname() collapses them
// all onto one indistinguishable "VM-…" row. Setting device_name per
// profile — usually to "<host>-<profile>" or the operator's real name —
// makes the runtimes list navigable.
//
// Resolution precedence (highest wins): --device-name flag,
// MULTICA_DAEMON_DEVICE_NAME env, this field, os.Hostname().
DeviceName string `json:"device_name,omitempty"`
// RuntimeName is the daemon's own runtime label ("Runtime display name"
// in the UI). Same shape as DeviceName but scopes to the runtime row
// rather than the host: users who run several distinct daemons per
// profile (rare, but supported) can pin different runtime names for
// each. Resolution precedence (highest wins): --runtime-name flag,
// MULTICA_AGENT_RUNTIME_NAME env, this field, the built-in
// DefaultRuntimeName.
RuntimeName string `json:"runtime_name,omitempty"`
// WorkspacesRoot is the base directory where the daemon creates task
// workspaces. Persisting it per profile avoids relying on a process-wide
// environment variable and preserves profile isolation. Empty means "not
// set — use env / built-in default". Resolution precedence (highest wins):
// --workspaces-root flag, MULTICA_WORKSPACES_ROOT env, this field, the
// profile-aware built-in default.
WorkspacesRoot string `json:"workspaces_root,omitempty"`
// MaxConcurrentTasks caps the number of task executions the daemon
// processes in parallel. Persist here to avoid re-passing
// --max-concurrent-tasks on every daemon start / auto-restart. 0 means
// "not set — use env / built-in default". Resolution precedence
// (highest wins): --max-concurrent-tasks flag,
// MULTICA_DAEMON_MAX_CONCURRENT_TASKS env, this field, default.
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
// PollInterval is how often the daemon polls the server for new tasks
// (Go duration string, e.g. "10s", "500ms"). Same persist-once
// motivation as MaxConcurrentTasks. Empty ("") means "not set — use
// env / built-in default". Only strictly positive durations are
// meaningful here: `config set poll_interval` rejects non-positive
// values (including "0s") and un-parseable durations at write time,
// so a value that reaches this field is always well-formed. Use
// `config set poll_interval ""` to clear a previously persisted
// value. Resolution precedence (highest wins): --poll-interval flag,
// MULTICA_DAEMON_POLL_INTERVAL env, this field, DefaultPollInterval.
PollInterval string `json:"poll_interval,omitempty"`
// WSClaimPollInterval is the upper bound for missed-event safety polls
// while task claims use a healthy WebSocket. The daemon applies
// downward-only jitter, and retains PollInterval for old servers and
// uncertain claims. Resolution precedence: --ws-claim-poll-interval,
// MULTICA_DAEMON_WS_CLAIM_POLL_INTERVAL, this field, 3m default.
WSClaimPollInterval string `json:"ws_claim_poll_interval,omitempty"`
// HeartbeatInterval is how often the daemon sends heartbeat pings to
// the server (Go duration string). Same persist-once motivation as
// PollInterval. Empty ("") means "not set — use env / built-in
// default"; `config set heartbeat_interval` rejects zero and
// negative durations. Resolution precedence: --heartbeat-interval
// flag, MULTICA_DAEMON_HEARTBEAT_INTERVAL env, this field,
// DefaultHeartbeatInterval.
HeartbeatInterval string `json:"heartbeat_interval,omitempty"`
// AgentTimeout is the absolute wall-clock cap per agent run (Go
// duration string). Unlike the other duration knobs, "0s" is a
// meaningful, non-default value here: it explicitly disables the cap
// so a run is bounded only by the inactivity watchdogs (see
// DefaultAgentTimeout). To distinguish "not persisted" from
// "persisted as disabled", we use a pointer: nil = not set, non-nil
// = use this string (which may be "0s"). `config set agent_timeout`
// accepts any non-negative Go duration; "" clears the persisted
// value. Resolution precedence: --agent-timeout flag (including
// explicit 0), MULTICA_AGENT_TIMEOUT env, this field,
// DefaultAgentTimeout.
AgentTimeout *string `json:"agent_timeout,omitempty"`
// CodexSemanticInactivityTimeout is the Codex-specific inactivity
// watchdog window (Go duration string). Persist-once semantics match
// PollInterval: empty = not set, positive = use this value.
// Resolution precedence: --codex-semantic-inactivity-timeout flag,
// MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT env, this field,
// DefaultCodexSemanticInactivityTimeout.
CodexSemanticInactivityTimeout string `json:"codex_semantic_inactivity_timeout,omitempty"`
// CodexHandshakeTimeout caps the Codex app-server startup RPCs (Go
// duration string). Persist-once semantics match PollInterval.
// Resolution precedence: --codex-handshake-timeout flag,
// MULTICA_CODEX_HANDSHAKE_TIMEOUT env, this field,
// DefaultCodexHandshakeTimeout.
CodexHandshakeTimeout string `json:"codex_handshake_timeout,omitempty"`
// DisableAutoUpdate, when true, turns off the daemon's periodic CLI
// self-update poll. Only a single direction is persistable — the
// --no-auto-update flag is likewise one-way — because the env/default
// already resolves to enabled on Multica Cloud. Absent / false means
// "let env/default decide". Resolution precedence:
// --no-auto-update flag, MULTICA_DAEMON_AUTO_UPDATE=false env, this
// field, cloud/self-host default.
DisableAutoUpdate bool `json:"disable_auto_update,omitempty"`
// AutoUpdateCheckInterval is how often the daemon polls GitHub for a
// newer CLI release (Go duration string). Persist-once semantics
// match PollInterval. Resolution precedence:
// --auto-update-interval flag, MULTICA_DAEMON_AUTO_UPDATE_INTERVAL
// env, this field, DefaultAutoUpdateCheckInterval.
AutoUpdateCheckInterval string `json:"auto_update_check_interval,omitempty"`
// DisableAutoReload, when true, stops the daemon from restarting into a
// multica binary that was replaced on disk out of band. Single-direction
// like DisableAutoUpdate, and separate from it on purpose: "don't pull
// new versions from GitHub" and "don't follow the binary I installed
// myself" are different decisions. Resolution precedence:
// --no-auto-reload flag, MULTICA_DAEMON_AUTO_RELOAD=false env, this
// field, default (enabled).
DisableAutoReload bool `json:"disable_auto_reload,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)
// CLITimeout: MULTICA_OPENCLAW_CLI_TIMEOUT (env) > backends.openclaw.cli_timeout > built-in default
//
// 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"`
// CLITimeout raises (or lowers) the per-invocation deadline the daemon
// applies to `openclaw config ...` during task preparation. Accepts a Go
// duration ("45s") or bare seconds ("45"); out-of-range values are clamped
// and unparseable ones ignored by the daemon. Empty keeps the built-in
// default, which fits every host measured so far — this exists for the
// slow-CLI tail (#7112), where discovery legitimately needs longer than the
// default and the user previously had no way to say so.
//
// Resolution mirrors the fields above: MULTICA_OPENCLAW_CLI_TIMEOUT (env) >
// backends.openclaw.cli_timeout > built-in default.
CLITimeout string `json:"cli_timeout,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.
// When TaskConfigRootEnv is set by the daemon, the same profile layout is
// rooted directly below that private task directory instead of the user's home.
func CLIConfigPathForProfile(profile string) (string, error) {
root, taskLocal, err := multicaConfigRoot()
if err != nil {
return "", fmt.Errorf("resolve CLI config path: %w", err)
}
if taskLocal {
if err := validateTaskLocalProfile(profile); err != nil {
return "", fmt.Errorf("resolve CLI config path: %w", err)
}
}
if profile == "" {
if taskLocal {
return filepath.Join(root, "config.json"), nil
}
return filepath.Join(root, defaultCLIConfigPath), nil
}
if taskLocal {
return filepath.Join(root, "profiles", profile, "config.json"), nil
}
return filepath.Join(root, ".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>/.
// Task invocations resolve the equivalent paths below TaskConfigRootEnv.
func ProfileDir(profile string) (string, error) {
root, taskLocal, err := multicaConfigRoot()
if err != nil {
return "", fmt.Errorf("resolve profile dir: %w", err)
}
if taskLocal {
if err := validateTaskLocalProfile(profile); err != nil {
return "", fmt.Errorf("resolve profile dir: %w", err)
}
}
if profile == "" {
if taskLocal {
return root, nil
}
return filepath.Join(root, ".multica"), nil
}
if taskLocal {
return filepath.Join(root, "profiles", profile), nil
}
return filepath.Join(root, ".multica", "profiles", profile), nil
}
func multicaConfigRoot() (root string, taskLocal bool, err error) {
if rawRoot := strings.TrimSpace(os.Getenv(TaskConfigRootEnv)); rawRoot != "" {
root := filepath.Clean(rawRoot)
if !filepath.IsAbs(root) {
return "", false, fmt.Errorf("%s must be an absolute path", TaskConfigRootEnv)
}
return root, true, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", false, err
}
return home, false, nil
}
func validateTaskLocalProfile(profile string) error {
if profile == "" {
return nil
}
if profile == "." || profile == ".." || filepath.IsAbs(profile) || strings.ContainsAny(profile, `/\\`) || filepath.Clean(profile) != profile {
return fmt.Errorf("invalid task-local Multica profile name %q", profile)
}
return 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)
dirMode := os.FileMode(0o755)
if strings.TrimSpace(os.Getenv(TaskConfigRootEnv)) != "" {
dirMode = 0o700
}
if err := os.MkdirAll(dir, dirMode); err != nil {
return fmt.Errorf("create CLI config directory: %w", err)
}
if dirMode == 0o700 {
root, _, err := multicaConfigRoot()
if err != nil {
return fmt.Errorf("resolve task-local CLI config root: %w", err)
}
for current := dir; ; current = filepath.Dir(current) {
if err := os.Chmod(current, 0o700); err != nil {
return fmt.Errorf("restrict task-local CLI config directory: %w", err)
}
if current == root {
break
}
parent := filepath.Dir(current)
if parent == current {
return fmt.Errorf("task-local CLI config directory %q escapes root %q", dir, root)
}
}
}
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
}