Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
bb8de70c5d fix(daemon): default auto-update off for self-host instances (MUL-2381)
A self-host operator running a fork of Multica with their own patches would
have their daemon silently upgraded to the upstream GitHub release, clobbering
the fork. Self-host setups also routinely pin to an older server, so a fresh
CLI may no longer talk to it.

Flip the default: auto-update remains opt-in on api.multica.ai and defaults to
off on any other server URL. Either side can override via
MULTICA_DAEMON_AUTO_UPDATE.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:12:11 +08:00
3 changed files with 183 additions and 5 deletions

View File

@@ -32,6 +32,8 @@ var autoUpdateInitialDelay = 2 * time.Minute
//
// Disabled when:
// - the operator opted out via --no-auto-update / MULTICA_DAEMON_AUTO_UPDATE=false;
// - the daemon points at a self-hosted server (default-off — set
// MULTICA_DAEMON_AUTO_UPDATE=true to opt back in);
// - the daemon was spawned by Desktop (the Electron app owns the binary);
// - the running version doesn't look like a tagged release (dev builds).
//

View File

@@ -72,7 +72,7 @@ type Config struct {
GCOrphanTTL time.Duration // clean orphan dirs with no meta, or dirs whose issue gc-check returns 404, once they exceed this age (default: 72h). The 404 path uses the same TTL — a scoped-down token can't instantly wipe live workspaces.
GCArtifactTTL time.Duration // when a task has been completed for at least this long but its issue is still open, drop regenerable artifacts (default: 12h, set 0 to disable)
GCArtifactPatterns []string // basename patterns whose subtrees are removed during artifact cleanup (default: node_modules, .next, .turbo)
AutoUpdateEnabled bool // periodically check for a newer CLI release and self-update when idle (default: true)
AutoUpdateEnabled bool // periodically check for a newer CLI release and self-update when idle (default: true on Multica Cloud, false on self-host)
AutoUpdateCheckInterval time.Duration // how often the auto-update loop polls for a new release (default: 6h)
PollInterval time.Duration
HeartbeatInterval time.Duration
@@ -355,10 +355,23 @@ func LoadConfig(overrides Overrides) (Config, error) {
}
gcArtifactPatterns := patternsFromEnv("MULTICA_GC_ARTIFACT_PATTERNS", DefaultGCArtifactPatterns)
// Auto-update config: env > defaults > CLI override.
autoUpdateEnabled := true
if v := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_AUTO_UPDATE")); v == "false" || v == "0" {
autoUpdateEnabled = false
// Auto-update config: default -> env override -> CLI override.
//
// Default is opt-in on Multica Cloud (api.multica.ai) and opt-out for
// self-hosted instances. Self-host operators frequently run a fork with
// their own patches, and silently upgrading their daemon to an upstream
// GitHub release would clobber that work; they also commonly stay on an
// older server build, which a fresh CLI may no longer talk to. Keeping
// auto-update off by default for self-host avoids both footguns (MUL-2381).
// Operators on either side can flip the default with MULTICA_DAEMON_AUTO_UPDATE.
autoUpdateEnabled := isOfficialCloudServer(serverBaseURL)
if v := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_AUTO_UPDATE")); v != "" {
switch strings.ToLower(v) {
case "false", "0", "no", "off":
autoUpdateEnabled = false
case "true", "1", "yes", "on":
autoUpdateEnabled = true
}
}
if overrides.DisableAutoUpdate {
autoUpdateEnabled = false
@@ -401,6 +414,26 @@ func LoadConfig(overrides Overrides) (Config, error) {
}, nil
}
// officialCloudHost is the hostname of Multica's hosted cloud. It's the only
// origin we treat as "official" for the auto-update default — staging,
// preview, and any future *.multica.ai subdomains are deliberately excluded
// so they inherit the safer self-host default until explicitly opted in.
const officialCloudHost = "api.multica.ai"
// isOfficialCloudServer reports whether the resolved server base URL points
// at Multica's hosted cloud. Used to pick the auto-update default: cloud
// users run a server that publishes the matching CLI release, so opt-in
// self-update is safe; self-host users may run a fork or pin to an older
// server, so the default flips to off. Matching is host-only and
// case-insensitive — port and path are ignored.
func isOfficialCloudServer(baseURL string) bool {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return false
}
return strings.EqualFold(u.Hostname(), officialCloudHost)
}
// NormalizeServerBaseURL converts a WebSocket or HTTP URL to a base HTTP URL.
func NormalizeServerBaseURL(raw string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))

View File

@@ -187,6 +187,149 @@ func lookPathInPath(name string) (string, error) {
return exec.LookPath(name)
}
func TestIsOfficialCloudServer(t *testing.T) {
for _, tc := range []struct {
name string
url string
want bool
}{
{"canonical cloud https", "https://api.multica.ai", true},
{"canonical cloud with trailing slash stripped", "https://api.multica.ai/", true},
{"canonical cloud case-insensitive", "https://API.Multica.AI", true},
{"cloud over plain http (unusual but match host)", "http://api.multica.ai", true},
{"localhost is self-host", "http://localhost:8080", false},
{"loopback ip is self-host", "http://127.0.0.1:8080", false},
{"lan ip is self-host", "http://192.168.0.28:8080", false},
{"third-party host is self-host", "https://multica.example.com", false},
// Staging / preview / future subdomains deliberately follow the
// safer self-host default until explicitly opted in.
{"multica.ai apex is not the api host", "https://multica.ai", false},
{"staging subdomain is self-host", "https://staging.multica.ai", false},
{"preview subdomain is self-host", "https://api-preview.multica.ai", false},
// Malformed inputs must not falsely match.
{"empty string is self-host", "", false},
{"garbage string is self-host", "::not a url::", false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := isOfficialCloudServer(tc.url); got != tc.want {
t.Errorf("isOfficialCloudServer(%q) = %v, want %v", tc.url, got, tc.want)
}
})
}
}
// stageFakeAgent writes an executable `claude` script into a temp dir and
// points PATH (and the daemon-id env var) so LoadConfig can run end-to-end
// without poking the host's real agent installation. Returns the staged PATH
// so tests that need to add their own dirs can extend it.
func stageFakeAgent(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("POSIX shell not available on Windows")
}
binDir := t.TempDir()
fake := filepath.Join(binDir, "claude")
if err := os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write fake claude: %v", err)
}
t.Setenv("PATH", binDir)
t.Setenv("MULTICA_DAEMON_ID", "11111111-1111-1111-1111-111111111111")
// Clear any inherited env-var override so the test sees the URL-based
// default, not whatever the developer happens to have exported.
t.Setenv("MULTICA_DAEMON_AUTO_UPDATE", "")
return binDir
}
// TestLoadConfig_AutoUpdateDefault_SelfHostOff is the regression guard for
// MUL-2381: a daemon pointed at any non-cloud server URL must default
// AutoUpdateEnabled to false, because self-host operators frequently run a
// fork and the upstream GitHub release would silently overwrite it.
func TestLoadConfig_AutoUpdateDefault_SelfHostOff(t *testing.T) {
stageFakeAgent(t)
cfg, err := LoadConfig(Overrides{
ServerURL: "http://localhost:8080",
WorkspacesRoot: t.TempDir(),
})
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.AutoUpdateEnabled {
t.Fatalf("AutoUpdateEnabled = true for self-host (localhost) server, want false")
}
}
// TestLoadConfig_AutoUpdateDefault_CloudOn confirms the symmetric case: a
// daemon pointed at Multica's hosted cloud keeps the historical opt-in
// auto-update default. We pass the WSS form of the URL to also exercise that
// NormalizeServerBaseURL maps it through to the http host the detector
// inspects.
func TestLoadConfig_AutoUpdateDefault_CloudOn(t *testing.T) {
stageFakeAgent(t)
cfg, err := LoadConfig(Overrides{
ServerURL: "wss://api.multica.ai/ws",
WorkspacesRoot: t.TempDir(),
})
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if !cfg.AutoUpdateEnabled {
t.Fatalf("AutoUpdateEnabled = false for Multica Cloud server, want true")
}
}
// TestLoadConfig_AutoUpdateEnv_ForcesOnForSelfHost lets a self-host operator
// re-enable auto-update via env var, overriding the new conservative default.
func TestLoadConfig_AutoUpdateEnv_ForcesOnForSelfHost(t *testing.T) {
stageFakeAgent(t)
t.Setenv("MULTICA_DAEMON_AUTO_UPDATE", "true")
cfg, err := LoadConfig(Overrides{
ServerURL: "http://localhost:8080",
WorkspacesRoot: t.TempDir(),
})
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if !cfg.AutoUpdateEnabled {
t.Fatalf("AutoUpdateEnabled = false after explicit MULTICA_DAEMON_AUTO_UPDATE=true, want true")
}
}
// TestLoadConfig_AutoUpdateEnv_ForcesOffForCloud covers the inverse: a cloud
// user can still opt out via env var.
func TestLoadConfig_AutoUpdateEnv_ForcesOffForCloud(t *testing.T) {
stageFakeAgent(t)
t.Setenv("MULTICA_DAEMON_AUTO_UPDATE", "false")
cfg, err := LoadConfig(Overrides{
ServerURL: "https://api.multica.ai",
WorkspacesRoot: t.TempDir(),
})
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.AutoUpdateEnabled {
t.Fatalf("AutoUpdateEnabled = true after explicit MULTICA_DAEMON_AUTO_UPDATE=false, want false")
}
}
// TestLoadConfig_AutoUpdate_NoFlagWinsOverCloudDefault keeps the legacy CLI
// flag working: --no-auto-update (translated into overrides.DisableAutoUpdate)
// forces auto-update off even when the cloud default and env var would enable.
func TestLoadConfig_AutoUpdate_NoFlagWinsOverCloudDefault(t *testing.T) {
stageFakeAgent(t)
t.Setenv("MULTICA_DAEMON_AUTO_UPDATE", "true")
cfg, err := LoadConfig(Overrides{
ServerURL: "https://api.multica.ai",
WorkspacesRoot: t.TempDir(),
DisableAutoUpdate: true,
})
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.AutoUpdateEnabled {
t.Fatalf("AutoUpdateEnabled = true with --no-auto-update set; flag must win")
}
}
// TestResolveAgentsViaLoginShell_StripsAliasShadowing locks down the fix for
// #2512: when the user's rc file declares an alias with the same name as the
// agent CLI, the resolver must still return the real binary on PATH, not the