diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index 181bb1b80e1..7aaf8caea0d 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -169,6 +169,57 @@ availability stays independent of a third party's release cadence. Desktop-managed daemons ignore both, because the Desktop app owns its bundled CLI's lifecycle. +### Boot autostart + +Opt-in: only `multica daemon autostart enable` registers the profile's +daemon with the OS, so the machine brings it back after a reboot or re-login +— without that, every reboot takes the runtime offline and queued runs sit +unclaimed. `daemon start` never registers on its own: it prints a one-line +hint when nothing is registered, and otherwise stays out of the way. + +| Platform | Mechanism | Where | +| --- | --- | --- | +| Windows | Per-user Run key | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, value `Multica` | +| macOS | launchd LaunchAgent | `~/Library/LaunchAgents/ai.multica.daemon.plist` (runs at login) | +| Linux | systemd user unit | `~/.config/systemd/user/multica-daemon.service`; without systemd, an XDG autostart entry under `~/.config/autostart/` | + +Each entry runs `multica daemon start --foreground` for that profile — named +profiles get their own entry, so several daemons on one machine never collide. +Daemon settings are read from the profile's config (`multica config set ...`) +at start. Shell environment variables do **not** travel into a login session: +only `PATH` is snapshotted at registration (and refreshed while Multica owns +the entry), which is what keeps agent CLIs installed via Homebrew, nvm, or a +user bin directory discoverable — persist everything else with +`multica config set` or your user environment. On Linux, `enable` also tells +you (it does not run it for you) how to `loginctl enable-linger $USER` so a +headless machine starts the unit at boot rather than at first login. + +```bash +multica daemon autostart enable # the only thing that registers +multica daemon autostart status # what is registered, and where +multica daemon autostart status --output json +multica daemon autostart disable # remove the registration +``` + +Refresh: while an entry exists and carries Multica's ownership marker, +`daemon start` silently rewrites it so a moved executable (a Homebrew +upgrade, a self-update) heals. An entry at the same path that Multica did +not create — your own systemd unit, a hand-written LaunchAgent — is never +refreshed and never overwritten: `enable`/`disable` refuse it and say which +file is in the way, and the refresh is additionally skipped when the daemon +was launched by an external supervisor (systemd `INVOCATION_ID` for a unit +that is not ours). + +`daemon stop` stops the daemon but keeps the registration — it says nothing +about the next boot; `autostart disable` is what turns that off. Disabling +never disturbs a running daemon: on Linux the wants link is removed while +the managed unit file stays (unlinked), so the current session keeps the +unit's restart policy across a binary update and only the next-login start +goes away; on macOS the plist is deleted without a `launchctl bootout`, so +the process launchd is supervising keeps running. Daemons started by the +Multica Desktop app are never registered or refreshed here: the app owns +that daemon's lifecycle through its own app-start daemon preference. + ### Stop ```bash diff --git a/server/cmd/multica/cmd_agent_test.go b/server/cmd/multica/cmd_agent_test.go index 00647c34937..fbb995e92b6 100644 --- a/server/cmd/multica/cmd_agent_test.go +++ b/server/cmd/multica/cmd_agent_test.go @@ -251,7 +251,7 @@ func TestMissingServerConfigMessageExplainsPortOnlyContext(t *testing.T) { // and asserts the CLI refuses the config-PAT fallback from the escaped cwd. func TestNewAPIClient_WorkdirParentEscapeFailsClosed(t *testing.T) { // Seed a user config with a mul_ PAT that must never be picked up. - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) if err := cli.SaveCLIConfig(cli.CLIConfig{Token: "mul_owner_pat"}); err != nil { t.Fatalf("seed config: %v", err) } @@ -333,7 +333,7 @@ func TestNewAPIClient_LeftoverMarkerActionableError(t *testing.T) { // Outside agent context, the three-level fallback (flag → env → config) is // unchanged. func TestResolveWorkspaceID_AgentContextSkipsConfig(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) // Seed the global CLI config with a workspace_id that must NOT be // picked up while running inside an agent task. @@ -428,7 +428,7 @@ func TestResolveWorkspaceID_AgentContextSkipsConfig(t *testing.T) { } func TestResolveToken_AgentContextSkipsConfig(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) if err := cli.SaveCLIConfig(cli.CLIConfig{Token: "mul_profile_token"}); err != nil { t.Fatalf("seed config: %v", err) @@ -642,7 +642,7 @@ func TestNewAPIClient_AgentContextRequiresTaskToken(t *testing.T) { func TestNewAPIClient_DaemonPortRequiresTaskToken(t *testing.T) { t.Chdir(t.TempDir()) - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:8080") t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123") t.Setenv("MULTICA_AGENT_ID", "") @@ -667,7 +667,7 @@ func TestNewAPIClient_DaemonPortRequiresTaskToken(t *testing.T) { } func TestNewAPIClient_WorkdirMarkerRequiresTaskToken(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:8080") t.Setenv("MULTICA_AGENT_ID", "") t.Setenv("MULTICA_TASK_ID", "") @@ -775,7 +775,7 @@ func TestParseCustomEnv(t *testing.T) { // --custom-env* flags are gone from `agent update`; the hint must // surface their replacement so users discover the new audited path. func TestAgentUpdateNoFieldsErrorPointsAtEnvCommand(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", "http://127.0.0.1:0") t.Setenv("MULTICA_WORKSPACE_ID", "test-ws") t.Setenv("MULTICA_TOKEN", "test-token") @@ -831,7 +831,7 @@ func TestAgentMaxConcurrentTasksFlagValidation(t *testing.T) { })) defer srv.Close() - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", srv.URL) t.Setenv("MULTICA_WORKSPACE_ID", "ws-1") t.Setenv("MULTICA_TOKEN", "test-token") diff --git a/server/cmd/multica/cmd_auth_test.go b/server/cmd/multica/cmd_auth_test.go index 4e6d76336a4..2da3cc1bb02 100644 --- a/server/cmd/multica/cmd_auth_test.go +++ b/server/cmd/multica/cmd_auth_test.go @@ -25,7 +25,39 @@ func TestMain(m *testing.M) { } { os.Unsetenv(key) } - os.Exit(m.Run()) + + // Redirect both home variables to one scratch directory for the whole + // binary. On Windows os.UserHomeDir reads USERPROFILE, not HOME, so a + // test that redirects only HOME still resolves ~/.multica against the + // real home — which is how a full-suite run once wrote SaveCLIConfig + // fixtures over a real default-profile config.json. Process-wide + // redirection isolates tests that forget their own redirect on every + // platform; per-test t.Setenv overrides still take precedence. + var scratchHome string + if home, err := os.MkdirTemp("", "multica-cli-tests-home-"); err == nil { + scratchHome = home + os.Setenv("HOME", home) + os.Setenv("USERPROFILE", home) + } + + code := m.Run() + if scratchHome != "" { + os.RemoveAll(scratchHome) + } + os.Exit(code) +} + +// redirectTestHome points BOTH home environment variables at dir. Production +// resolves the config directory through os.UserHomeDir, which reads HOME on +// unix and USERPROFILE on Windows: redirecting only HOME splits the write +// path (tests creating fixtures under HOME) from the read path (code +// resolving ~/.multica through USERPROFILE), so on Windows the fixture lands +// where the code never looks — or, before the TestMain scratch home existed, +// in the real ~/.multica. +func redirectTestHome(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) } // testCmd returns a minimal cobra.Command with the --profile persistent flag @@ -333,7 +365,7 @@ func TestLoginTokenFlagParsing(t *testing.T) { func TestRunAuthStatusTaskContextDoesNotPrintCredential(t *testing.T) { const fakeTaskToken = "mat_task_status_sentinel" - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TOKEN", fakeTaskToken) @@ -372,7 +404,7 @@ func TestRunAuthStatusTaskContextDoesNotPrintCredential(t *testing.T) { func TestRunAuthStatusTaskContextRequiresTaskToken(t *testing.T) { ownerHome := t.TempDir() - t.Setenv("HOME", ownerHome) + redirectTestHome(t, ownerHome) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TASK_CONFIG_ROOT", filepath.Join(t.TempDir(), "task-multica")) @@ -433,7 +465,7 @@ func TestRunAuthStatusTaskContextRequiresTaskToken(t *testing.T) { func TestHumanAuthCommandsFailClosedInTaskContext(t *testing.T) { ownerHome := t.TempDir() - t.Setenv("HOME", ownerHome) + redirectTestHome(t, ownerHome) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TOKEN", "mat_task_sentinel") diff --git a/server/cmd/multica/cmd_compat_test.go b/server/cmd/multica/cmd_compat_test.go index 3a9634ed7b6..eff2ea4c66b 100644 --- a/server/cmd/multica/cmd_compat_test.go +++ b/server/cmd/multica/cmd_compat_test.go @@ -7,7 +7,7 @@ import ( ) func TestRunConfigSetPersistsValues(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) cmd := testCmd() if err := runConfigSet(cmd, []string{"server_url", "http://example.com"}); err != nil { diff --git a/server/cmd/multica/cmd_config_test.go b/server/cmd/multica/cmd_config_test.go index 84ba1fb51d7..da37f56f35f 100644 --- a/server/cmd/multica/cmd_config_test.go +++ b/server/cmd/multica/cmd_config_test.go @@ -19,7 +19,7 @@ func newConfigTestCmd() *cobra.Command { } func TestRunConfigSetPersistsSupportedKeysInProfile(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) workspacesRoot := filepath.Join(t.TempDir(), "multica-dev") cmd := newConfigTestCmd() @@ -51,7 +51,7 @@ func TestRunConfigSetPersistsSupportedKeysInProfile(t *testing.T) { } func TestRunConfigShowIncludesProfileAndDefaults(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) cmd := newConfigTestCmd() _ = cmd.Flags().Set("profile", "empty") @@ -98,7 +98,7 @@ func TestRunConfigShowIncludesProfileAndDefaults(t *testing.T) { func TestRunConfigCommandsUseTaskLocalConfigWithoutTouchingOwner(t *testing.T) { ownerHome := t.TempDir() taskRoot := filepath.Join(t.TempDir(), "task-multica") - t.Setenv("HOME", ownerHome) + redirectTestHome(t, ownerHome) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TASK_CONFIG_ROOT", taskRoot) @@ -151,7 +151,7 @@ func TestRunConfigCommandsUseTaskLocalConfigWithoutTouchingOwner(t *testing.T) { func TestRunConfigCommandsFailClosedWithoutTaskRoot(t *testing.T) { ownerHome := t.TempDir() - t.Setenv("HOME", ownerHome) + redirectTestHome(t, ownerHome) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TASK_CONFIG_ROOT", "") @@ -182,7 +182,7 @@ func TestRunConfigCommandsFailClosedWithoutTaskRoot(t *testing.T) { } func TestRunConfigSetRejectsUnknownKey(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) cmd := newConfigTestCmd() err := runConfigSet(cmd, []string{"token", "secret"}) diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index a889ac4176d..bd710e543b7 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -35,8 +35,11 @@ var daemonCmd = &cobra.Command{ var daemonStartCmd = &cobra.Command{ Use: "start", Short: "Start the local agent runtime daemon", - Long: "Start the daemon process that polls for runs and executes them using local agent CLIs (Claude, Codex).\nRuns in the background by default. Use --foreground to run in the current terminal.", - RunE: runDaemonStart, + Long: "Start the daemon process that polls for runs and executes them using local agent CLIs (Claude, Codex).\n" + + "Runs in the background by default. Use --foreground to run in the current terminal.\n" + + "Boot autostart is opt-in: when this profile's daemon has none, a hint points at " + + "'multica daemon autostart enable'; an existing Multica-created entry is refreshed in place.", + RunE: runDaemonStart, } var daemonStopCmd = &cobra.Command{ @@ -568,6 +571,9 @@ func runDaemonBackground(cmd *cobra.Command) error { if err := daemonIdentityMismatch(health, profile, healthPort); err != nil { return err } + // Even a no-op start keeps the autostart contract: hint when nothing + // is registered, silently refresh an entry Multica owns. + syncDaemonAutostart(profile, true) label := "daemon" if profile != "" { label = fmt.Sprintf("daemon [%s]", profile) @@ -580,6 +586,10 @@ func runDaemonBackground(cmd *cobra.Command) error { return err } + // Hint about boot autostart / refresh an owned entry. Never creates a + // registration — only 'multica daemon autostart enable' does that. + syncDaemonAutostart(profile, true) + // Resolve current executable so the foreground child reuses this binary. exePath, err := daemonExecutable() if err != nil { @@ -918,6 +928,14 @@ func runDaemonForeground(cmd *cobra.Command) error { profile := resolveProfile(cmd) + // Keep an entry this process may itself have been launched from fresh: + // rewriting is idempotent, and it heals a stale executable path after a + // self-update or an in-place upgrade moved the binary. Never creates an + // entry, never touches one without our marker, and never runs under an + // external supervisor (see syncDaemonAutostartDefault). The hint is + // announced only to a watching human (stderr is a terminal). + syncDaemonAutostart(profile, logger_pkg.StderrIsTerminal()) + // Load the profile config once — several daemon knobs fall back to // values persisted here when both the CLI flag and the env var are // unset. Errors reading the config are non-fatal for anything other @@ -1106,6 +1124,22 @@ func runDaemonForeground(cmd *cobra.Command) error { _ = logRotator.Close() } + // Under OUR OWN systemd unit, spawning a successor and exiting 0 + // loses it: systemd sees a clean stop of Type=simple and kills + // everything left in the cgroup — successor included (Setsid escapes + // a session, not a cgroup) — while Restart=on-failure never fires + // for exit 0. The first auto-update after a boot-autostarted start + // would leave the runtime offline until the next reboot. Exit with + // the dedicated handoff status instead; the generated unit carries + // RestartForceExitStatus for it, so systemd restarts the new binary. + // Any other supervisor (launchd's process-group kill, no supervisor + // at all) keeps the portable spawn handoff below. + if daemonUnderOwnSystemdUnit(profile) { + logger.Info("handing off to systemd for the updated binary", + "path", restartBin, "exit_code", daemonSystemdHandoffExitStatus) + os.Exit(daemonSystemdHandoffExitStatus) + } + args := buildDaemonStartArgs(cmd) child := exec.Command(restartBin, args...) diff --git a/server/cmd/multica/cmd_daemon_autostart.go b/server/cmd/multica/cmd_daemon_autostart.go new file mode 100644 index 00000000000..9aba64a1d1c --- /dev/null +++ b/server/cmd/multica/cmd_daemon_autostart.go @@ -0,0 +1,847 @@ +package main + +// Boot autostart for the local agent runtime daemon — strictly opt-in. +// +// A login entry re-invokes the profile's daemon so the machine brings it back +// after a reboot / re-login. Registration is created ONLY by an explicit +// `multica daemon autostart enable`: +// +// Windows HKCU\...\Run value (per-user, no elevation) +// macOS ~/Library/LaunchAgents plist (per-user, runs at login) +// Linux systemd user unit, or an XDG autostart .desktop fallback +// when no systemd user session is available +// +// `daemon start` never registers. It prints a one-line hint when nothing is +// registered, and may silently REFRESH an existing Multica-owned entry to +// heal a moved executable path — "owned" means carrying our marker (a +// comment line in a unit/.desktop, a ManagedBy key in a plist; the Run value +// name itself), so a hand-written file at the same path is never rewritten. +// Refresh is also skipped under an external supervisor (a user's own unit, +// a container) that we do not own. Daemons spawned by a manager +// (MULTICA_LAUNCHED_BY, e.g. the Desktop app's own app-launch toggle) are +// never touched: the manager owns that daemon's lifecycle. +// +// The registered command runs the FOREGROUND daemon on purpose: launchd, +// systemd, and a login session all supervise one long-lived process, whereas +// the background launcher would spawn a child and sit polling for up to 45s. + +import ( + "bytes" + "encoding/xml" + "errors" + "fmt" + "hash/fnv" + "io" + "os" + "runtime" + "strings" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +// errAutostartUnmanaged is the platform writers' and removers' backstop +// refusal: a file at a path Multica owns that lacks the ownership marker. +// The shared enable/disable/sync flows check Managed before acting, so +// hitting this means a race or a caller that skipped the guard — either way +// the safe answer is to not touch the user's file. +var errAutostartUnmanaged = errors.New("the existing file was not created by Multica; refusing to modify it") + +// Mechanism keys reported by status (`--output json`). Each maps to a human +// label through mechanismLabel for table output. +const ( + autostartMechanismWindowsRun = "windows-run-key" + autostartMechanismLaunchd = "launchd" + autostartMechanismSystemd = "systemd" + autostartMechanismXDG = "xdg-autostart" + autostartMechanismUnsupported = "unsupported" +) + +// Ownership markers. A file at our path that does NOT carry the platform's +// marker was written by the user (their own systemd unit with an +// EnvironmentFile=, a hand-crafted LaunchAgent, ...): refresh skips it and +// enable/disable refuse to touch it rather than silently overwriting. +// +// The Windows Run value has no comment channel, but its value name +// ("Multica" / "Multica ()") is ours alone, so name presence IS the +// marker there. +const ( + // autostartManagedComment prefixes the first line of every unit and + // .desktop file Multica writes. + autostartManagedComment = "# Managed by Multica (multica daemon autostart); remove with 'multica daemon autostart disable'" + // autostartPlistMarkerKey/Value are a key/value pair Multica writes into + // every LaunchAgent; launchd ignores unknown plist keys. + autostartPlistMarkerKey = "ManagedBy" + autostartPlistMarkerValue = "multica-daemon-autostart" +) + +// daemonSystemdHandoffExitStatus is what the foreground daemon exits with +// when it hands a binary-update restart over to OUR OWN systemd unit +// instead of spawning a successor (see runDaemonForeground). The generated +// unit carries RestartForceExitStatus= so systemd restarts the new +// binary; spawning + exit 0 under Type=simple would let cgroup cleanup kill +// the successor and leave the runtime offline. The two sites MUST agree — +// systemdUnitContent renders this constant into the unit. +const daemonSystemdHandoffExitStatus = 42 + +// autostartState is the platform-agnostic view of one profile's boot +// autostart entry: whether it is registered, under which mechanism, where the +// registration lives, what command it launches, and whether Multica itself +// created it (Managed). +// +// Location is populated even while disabled — it names the file / registry +// value `enable` would write, so `status` answers "where would this go?" +// before the user commits to anything. +type autostartState struct { + // Present is "a registration file/value exists at our path", deliberately + // separate from Enabled: on Linux Enabled only means "linked in + // default.target.wants", so a hand-written unit that is not enabled (or + // enabled under another target) reads Enabled=false while still being a + // file the guards must refuse to touch. Ownership checks key off Present; + // the enabled/disabled verdict keys off Enabled. + Present bool `json:"present,omitempty"` + Enabled bool `json:"enabled"` + Managed bool `json:"managed,omitempty"` + Mechanism string `json:"mechanism"` + Location string `json:"location,omitempty"` + Command string `json:"command,omitempty"` + // Note carries an optional platform follow-up the user should act on: + // the linger hint on systemd machines, or the warning that an entry at + // our path was not created by Multica. + Note string `json:"note,omitempty"` +} + +// autostartSpec is the login command to register: the resolved executable, +// its argv, and the PATH captured from the registering shell. +// +// PathEnv matters on macOS and Linux, where launchd / the systemd user +// manager start daemons with a minimal PATH that would miss agent CLIs +// installed through Homebrew, nvm, or a user-level bin directory. Windows +// needs no capture: a Run-key entry inherits the user's registry environment. +// The snapshot refreshes whenever an entry is written, so it tracks the shell +// the user actually runs from — and it is the ONLY environment that survives +// into the login session; `enable` says so explicitly. +type autostartSpec struct { + Exe string + Args []string + PathEnv string +} + +// Platform seams. Each build defines the platform* functions; they are +// variables so tests can exercise the enable/disable/status/sync flows +// without touching the developer's real registry, LaunchAgents, or systemd +// user directory. +var ( + autostartSupported = platformAutostartSupported + writeAutostart = platformWriteAutostart + removeAutostart = platformRemoveAutostart + readAutostart = platformReadAutostart + autostartRefreshAllowed = platformAutostartRefreshAllowed + daemonUnderOwnSystemdUnit = platformDaemonUnderOwnSystemdUnit +) + +// syncDaemonAutostart is the `daemon start` hook: hint when nothing is +// registered, silently heal an entry we own, do nothing otherwise. Behind a +// seam so the lifecycle paths under test never write (or read) autostart +// state on the machine running the tests. +var syncDaemonAutostart = syncDaemonAutostartDefault + +// --------------------------------------------------------------------------- +// command wiring +// --------------------------------------------------------------------------- + +var daemonAutostartCmd = &cobra.Command{ + Use: "autostart", + Short: "Manage boot autostart for this profile's daemon", + Long: "Manage whether this profile's daemon starts automatically at login/boot.\n\n" + + "Registration is opt-in: only 'multica daemon autostart enable' creates it. The OS entry " + + "re-runs 'multica daemon start --foreground' for this profile — a Run key on Windows, a " + + "launchd LaunchAgent on macOS, a systemd user unit (or an XDG autostart entry when systemd " + + "is unavailable) on Linux. 'multica daemon start' never registers on its own; it only hints " + + "and refreshes an existing Multica-created entry. 'daemon stop' stops the daemon for now and " + + "does not remove the registration.", +} + +var daemonAutostartEnableCmd = &cobra.Command{ + Use: "enable", + Short: "Register the daemon to start automatically at login/boot", + RunE: runDaemonAutostartEnable, +} + +var daemonAutostartDisableCmd = &cobra.Command{ + Use: "disable", + Short: "Remove the daemon's login/boot autostart registration", + RunE: runDaemonAutostartDisable, +} + +var daemonAutostartStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show whether this profile's daemon starts automatically", + RunE: runDaemonAutostartStatus, +} + +func init() { + daemonCmd.AddCommand(daemonAutostartCmd) + daemonAutostartCmd.AddCommand(daemonAutostartEnableCmd) + daemonAutostartCmd.AddCommand(daemonAutostartDisableCmd) + daemonAutostartCmd.AddCommand(daemonAutostartStatusCmd) + daemonAutostartStatusCmd.Flags().String("output", "table", "Output format: table or json") +} + +// requireAutostartSupported rejects the platform action on a GOOS with no +// registration mechanism. `daemon start` treats the same condition as "stay +// silent and skip" — a start must not fail on an exotic platform over an +// optional convenience — but an explicit autostart command has nothing else +// to do and says so instead of pretending it worked. +func requireAutostartSupported() error { + if autostartSupported() { + return nil + } + return fmt.Errorf("boot autostart is not supported on %s", runtime.GOOS) +} + +// refuseUnmanagedAutostart is the shared guard for enable and disable, run +// against the state the caller just read (both commands need that read for +// their own wording anyway): an entry at our path that lacks our marker was +// written by the user (their own unit with an EnvironmentFile=, a +// hand-written LaunchAgent, ...). Overwriting or deleting it would silently +// destroy their configuration, so both commands stop and say which file is +// in the way instead. +// +// Keyed off Present, not Enabled: on Linux a hand-written unit that was never +// enabled — or was enabled under another target, so systemdUnitLinked reads +// false — is still a file at our path, and guarding only "enabled" entries +// would let `disable` delete it. +func refuseUnmanagedAutostart(cur autostartState, action string) error { + if cur.Present && !cur.Managed { + return fmt.Errorf( + "%s exists at %s but was not created by Multica; refusing to %s it.\n"+ + "Remove that file yourself if you want Multica to manage autostart, then rerun the command", + mechanismLabel(cur.Mechanism), cur.Location, action) + } + return nil +} + +func runDaemonAutostartEnable(cmd *cobra.Command, _ []string) error { + if err := requireHumanLocalCommand("daemon autostart enable"); err != nil { + return err + } + profile := resolveProfile(cmd) + if err := requireKnownProfile(profile); err != nil { + return err + } + if err := requireAutostartSupported(); err != nil { + return err + } + cur, err := readAutostart(profile) + if err != nil { + return err + } + if err := refuseUnmanagedAutostart(cur, "overwrite"); err != nil { + return err + } + spec, err := autostartSpecFor(profile) + if err != nil { + return err + } + state, contentChanged, err := writeAutostart(profile, spec) + if err != nil { + return err + } + + // "already enabled" only when it already WAS enabled and nothing about + // the registration changed. Linux disable keeps the (unlinked) unit + // file, so re-enabling after a disable often rewrites no content — the + // change is the wants link coming back, and claiming "already enabled" + // there would describe the state the user just moved out of. + verb := "enabled" + if cur.Enabled && !contentChanged { + verb = "already enabled" + } + fmt.Fprintf(os.Stderr, "Boot autostart %s for profile %s (%s) — the daemon starts at login.\n", + verb, profileLabel(profile), mechanismLabel(state.Mechanism)) + if state.Location != "" { + fmt.Fprintf(os.Stderr, "Location: %s\n", state.Location) + } + // The login session only inherits what the entry itself carries. Spell + // out the consequence — and where the rest has to live — at the moment + // of registration, not as a surprise after a reboot. + fmt.Fprintln(os.Stderr, "Note: only PATH is carried into the login session; shell-exported variables "+ + "(API keys, HTTPS_PROXY, ...) are not. Persist daemon settings with 'multica config set' and put "+ + "other variables in your user environment.") + if state.Note != "" { + fmt.Fprintf(os.Stderr, "Note: %s\n", state.Note) + } + return nil +} + +func runDaemonAutostartDisable(cmd *cobra.Command, _ []string) error { + if err := requireHumanLocalCommand("daemon autostart disable"); err != nil { + return err + } + profile := resolveProfile(cmd) + // Deliberately no requireKnownProfile here: removal has to keep working + // after the profile's state directory is gone — cleaning up a stale + // registration is exactly when the profile may no longer exist. + if err := requireAutostartSupported(); err != nil { + return err + } + cur, err := readAutostart(profile) + if err != nil { + return err + } + if err := refuseUnmanagedAutostart(cur, "remove"); err != nil { + return err + } + _, changed, err := removeAutostart(profile) + if err != nil { + return err + } + if !changed { + fmt.Fprintf(os.Stderr, "Boot autostart is not enabled for profile %s.\n", profileLabel(profile)) + return nil + } + fmt.Fprintf(os.Stderr, "Boot autostart disabled for profile %s — the daemon no longer starts at login.\n", + profileLabel(profile)) + return nil +} + +// autostartStatusReport is the `--output json` document: the profile plus the +// platform state, inlined so consumers read one flat object. +type autostartStatusReport struct { + Profile string `json:"profile"` + autostartState +} + +func runDaemonAutostartStatus(cmd *cobra.Command, _ []string) error { + if err := requireHumanLocalCommand("daemon autostart status"); err != nil { + return err + } + profile := resolveProfile(cmd) + if err := requireKnownProfile(profile); err != nil { + return err + } + state, err := readAutostart(profile) + if err != nil { + return err + } + // A file at our path without our marker is not ours — regardless of + // whether it is enabled (on Linux an unlinked unit or one linked under + // another target reads disabled): say so before the user assumes + // `enable`/`disable`/refresh will act on it. + if state.Present && !state.Managed && state.Note == "" { + state.Note = "not created by Multica; enable, disable and daemon start leave it alone" + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, autostartStatusReport{Profile: profile, autostartState: state}) + } + printAutostartReport(os.Stdout, profile, state) + return nil +} + +// printAutostartReport renders the table view as aligned key/value rows, +// matching how `daemon status` prints its summary. +func printAutostartReport(w io.Writer, profile string, state autostartState) { + status := "disabled" + if state.Enabled { + status = "enabled" + } + rows := []struct{ key, value string }{ + {"Profile", profileLabel(profile)}, + {"Autostart", status}, + {"Mechanism", mechanismLabel(state.Mechanism)}, + } + if state.Location != "" { + rows = append(rows, struct{ key, value string }{"Location", state.Location}) + } + if state.Command != "" { + rows = append(rows, struct{ key, value string }{"Command", state.Command}) + } + if state.Note != "" { + rows = append(rows, struct{ key, value string }{"Note", state.Note}) + } + + keyWidth := 0 + for _, r := range rows { + if n := len(r.key); n > keyWidth { + keyWidth = n + } + } + for _, r := range rows { + fmt.Fprintf(w, "%-*s %s\n", keyWidth+1, r.key+":", r.value) + } +} + +func mechanismLabel(key string) string { + switch key { + case autostartMechanismWindowsRun: + return "Windows Run key" + case autostartMechanismLaunchd: + return "launchd LaunchAgent" + case autostartMechanismSystemd: + return "systemd user unit" + case autostartMechanismXDG: + return "XDG autostart" + case autostartMechanismUnsupported: + return "unsupported on " + runtime.GOOS + case "": + return "unknown" + default: + return key + } +} + +// --------------------------------------------------------------------------- +// the `daemon start` hook +// --------------------------------------------------------------------------- + +// shouldManageAutostart decides whether the hint/refresh side of +// `daemon start` applies to this invocation: +// +// - MULTICA_LAUNCHED_BY names a manager (the Desktop app) that spawned the +// daemon and owns its lifecycle — including its own app-start toggle for +// the daemon — so hinting or refreshing underneath it would fight that +// setting. +// - unsupported platforms skip silently; see requireAutostartSupported. +func shouldManageAutostart() bool { + if !autostartSupported() { + return false + } + return os.Getenv("MULTICA_LAUNCHED_BY") == "" +} + +// syncDaemonAutostartDefault implements the `daemon start` contract: +// +// - nothing registered at all → print a one-line hint (only where a human +// can see it) pointing at `multica daemon autostart enable`; +// - an enabled Multica-owned entry exists → silently rewrite it so a +// moved executable (Homebrew upgrade, self-update) or a refreshed PATH +// heals; +// - an entry exists at our path but is disabled or not ours, or we are +// under an external supervisor → leave it completely alone (never +// re-link what a disable turned off, never hint at a foreign file). +// +// It never creates a registration and never fails the start: the daemon +// itself is the deliverable of `daemon start`. +func syncDaemonAutostartDefault(profile string, announce bool) { + if !shouldManageAutostart() { + return + } + state, err := readAutostart(profile) + if err != nil { + return + } + if state.Enabled { + if !state.Managed || !autostartRefreshAllowed(profile) { + return + } + spec, err := autostartSpecFor(profile) + if err != nil { + return + } + // Best-effort and silent: a failed heal leaves a stale path for the + // next explicit `enable` to fix, and a warning on every start would + // be noise the user cannot act on anyway. + _, _, _ = writeAutostart(profile, spec) + return + } + // Nothing enabled. An entry that exists at our path — ours unlinked + // (the user ran `autostart disable` or `systemctl disable`) or a + // foreign file — is a state the user chose or owns: no rewrite (the + // refresh must never re-link an entry that was deliberately turned off) + // and no hint. The hint exists for discovery, i.e. nothing registered + // at all; nagging someone who just disabled it would be noise, and + // hinting at a foreign file would only lead to enable's refusal. + if state.Present { + return + } + if announce { + fmt.Fprintln(os.Stderr, "Tip: run 'multica daemon autostart enable' to start this daemon at login") + } +} + +// autostartSpecFor resolves the exact command a login entry should run. +// +// The executable goes through cli.StableSelfExecutable — the same resolver +// behind the daemon's binary-update restart target — before it is recorded: +// on brew installs os.Executable() reports the versioned keg path that +// `brew upgrade` cleans up, and an entry holding it would restart a deleted +// binary after the first upgrade (systemd fails ExecStart with 203/EXEC; +// a LaunchAgent would point at a missing file at the next login). +func autostartSpecFor(profile string) (autostartSpec, error) { + exe, err := daemonExecutable() + if err != nil { + return autostartSpec{}, fmt.Errorf("resolve executable path: %w", err) + } + return autostartSpec{ + Exe: cli.StableSelfExecutable(exe), + Args: autostartArgs(profile), + PathEnv: os.Getenv("PATH"), + }, nil +} + +// autostartArgs is the argv registered for a profile: the foreground daemon +// (the process login entries supervise) with the profile flag when named. +// Everything else — server URL, workspaces root, tunables — comes from the +// profile's config.json at runtime, so the entry never goes stale when a +// setting changes. +func autostartArgs(profile string) []string { + args := []string{"daemon", "start", "--foreground"} + if profile != "" { + args = append(args, "--profile", profile) + } + return args +} + +// autostartProfileSlug renders a profile name as a filename- and +// unit-name-safe token. Characters outside [A-Za-z0-9._-] become '-', and +// when anything changed a short hash of the original is appended so two +// distinct profiles that sanitize to the same text (team/dev vs team-dev) +// still get distinct entries. +func autostartProfileSlug(profile string) string { + var b strings.Builder + changed := false + for _, r := range profile { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteByte('-') + changed = true + } + } + slug := b.String() + if !changed { + return slug + } + h := fnv.New32a() + _, _ = h.Write([]byte(profile)) + return fmt.Sprintf("%s-%06x", slug, h.Sum32()&0xffffff) +} + +// --------------------------------------------------------------------------- +// platform-agnostic builders (pure; exercised by tests on every OS) +// --------------------------------------------------------------------------- + +// windowsQuoteArg quotes one argv element for a Windows command line using +// the CRT parsing rules (backslashes before quotes and at the end of a +// quoted run are doubled). Unquoted when no quoting is needed. +func windowsQuoteArg(s string) string { + if s != "" && !strings.ContainsAny(s, " \t\"") { + return s + } + var b strings.Builder + b.WriteByte('"') + backslashes := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '\\': + backslashes++ + case '"': + b.WriteString(strings.Repeat("\\", backslashes*2+1)) + b.WriteByte('"') + backslashes = 0 + default: + b.WriteString(strings.Repeat("\\", backslashes)) + backslashes = 0 + b.WriteByte(s[i]) + } + } + b.WriteString(strings.Repeat("\\", backslashes*2)) + b.WriteByte('"') + return b.String() +} + +// windowsRunValueName is the HKCU Run value this profile owns. Distinct per +// profile so several daemons on one machine never overwrite each other, and +// the name doubles as the ownership marker (see autostartManagedComment). +func windowsRunValueName(profile string) string { + if profile == "" { + return "Multica" + } + return "Multica (" + profile + ")" +} + +// windowsAutostartCommand renders the Run value: a single command line, since +// the registry stores no argv array. +func windowsAutostartCommand(spec autostartSpec) string { + parts := make([]string, 0, len(spec.Args)+1) + parts = append(parts, windowsQuoteArg(spec.Exe)) + for _, a := range spec.Args { + parts = append(parts, windowsQuoteArg(a)) + } + return strings.Join(parts, " ") +} + +// launchAgentLabel is the reverse-DNS label shared by the LaunchAgent's +// filename and its launchd Label key. +func launchAgentLabel(profile string) string { + if profile == "" { + return "ai.multica.daemon" + } + return "ai.multica.daemon." + autostartProfileSlug(profile) +} + +// launchAgentPlistContent renders the LaunchAgent. RunAtLoad starts it at +// login; there is deliberately no KeepAlive — `multica daemon stop` and +// logout must stay authoritative, and launchd would otherwise fight them by +// restarting the daemon forever. +func launchAgentPlistContent(spec autostartSpec, label string) string { + var b strings.Builder + b.WriteString("\n") + b.WriteString("\n") + b.WriteString("\n\n") + // Ownership marker first: launchd ignores unknown keys, refresh and + // enable/disable key off this pair before touching the file. + writePlistEntry(&b, autostartPlistMarkerKey, autostartPlistMarkerValue) + writePlistEntry(&b, "Label", label) + b.WriteString("\tProgramArguments\n\t\n") + for _, arg := range append([]string{spec.Exe}, spec.Args...) { + fmt.Fprintf(&b, "\t\t%s\n", xmlEscape(arg)) + } + b.WriteString("\t\n") + // The login session's PATH is the only environment launchd does not + // provide; embed the snapshot the registering shell handed us so agent + // CLIs outside the system directories stay discoverable. + if spec.PathEnv != "" { + b.WriteString("\tEnvironmentVariables\n\t\n") + writePlistEntry(&b, "PATH", spec.PathEnv) + b.WriteString("\t\n") + } + b.WriteString("\tRunAtLoad\n\t\n") + // Background process type: a poll/websocket daemon wants neither App Nap + // nor full user-interactive QoS. + b.WriteString("\tProcessType\n\tBackground\n") + b.WriteString("\n\n") + return b.String() +} + +func writePlistEntry(b *strings.Builder, key, value string) { + fmt.Fprintf(b, "\t%s\n\t%s\n", xmlEscape(key), xmlEscape(value)) +} + +func xmlEscape(s string) string { + var b strings.Builder + _ = xml.EscapeText(&b, []byte(s)) + return b.String() +} + +// autostartCommandDisplay renders a spec as a readable command line for +// status output. File-based mechanisms keep their argv in structured form +// (plist array, unit file) where no shell quoting applies, so a plain join is +// the honest rendering; the Windows registry stores a literal command line +// and reports that instead. +func autostartCommandDisplay(spec autostartSpec) string { + return strings.Join(append([]string{spec.Exe}, spec.Args...), " ") +} + +// launchAgentStoredCommand extracts ProgramArguments from a written plist so +// status reports the registered command as stored — including an executable +// path a later refresh has not rewritten yet — rather than a freshly +// resolved guess. The plist's only is ProgramArguments. +func launchAgentStoredCommand(content []byte) string { + var doc struct { + Dict struct { + Arrays []struct { + Strings []string `xml:"string"` + } `xml:"array"` + } `xml:"dict"` + } + if err := xml.Unmarshal(content, &doc); err != nil || len(doc.Dict.Arrays) == 0 { + return "" + } + return strings.Join(doc.Dict.Arrays[0].Strings, " ") +} + +// launchAgentManaged reports whether a plist at our path carries the +// Multica ownership marker. +func launchAgentManaged(content []byte) bool { + dec := xml.NewDecoder(bytes.NewReader(content)) + lastKey := "" + for { + tok, err := dec.Token() + if err != nil { + return false + } + switch t := tok.(type) { + case xml.StartElement: + var v string + switch t.Name.Local { + case "key": + lastKey = "" + if err := dec.DecodeElement(&v, &t); err == nil { + lastKey = v + } + case "string": + if err := dec.DecodeElement(&v, &t); err == nil && + lastKey == autostartPlistMarkerKey && v == autostartPlistMarkerValue { + return true + } + } + } + } +} + +// autostartCommentMarked reports whether a unit / .desktop file body carries +// the Multica ownership comment on any line. +func autostartCommentMarked(content string) bool { + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, autostartManagedComment) { + return true + } + } + return false +} + +// systemdStoredCommand extracts the ExecStart line from a written unit. +func systemdStoredCommand(content string) string { + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "ExecStart=") { + return strings.TrimPrefix(line, "ExecStart=") + } + } + return "" +} + +// xdgStoredCommand extracts the Exec line from a written .desktop entry. +func xdgStoredCommand(content string) string { + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "Exec=") { + return strings.TrimPrefix(line, "Exec=") + } + } + return "" +} + +// systemdUnitName is the user-unit filename for a profile (file and unit +// name are the same string in a systemd user directory). +func systemdUnitName(profile string) string { + if profile == "" { + return "multica-daemon.service" + } + return "multica-daemon-" + autostartProfileSlug(profile) + ".service" +} + +// systemdExecArg escapes one ExecStart word: '%' is a systemd specifier and +// must be doubled, and anything whitespace/quote-bearing goes through +// systemd's C-style quoting. +func systemdExecArg(s string) string { + s = strings.ReplaceAll(s, "%", "%%") + if s == "" { + return `""` + } + if !strings.ContainsAny(s, " \t\"'\\") { + return s + } + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} + +// systemdUnitContent renders the user unit. +// +// Restart=on-failure with a start limit bounds retries: a boot that races +// the network gets a few chances to reach the server, while a persistent +// failure (a manual daemon already holding the health port, a missing login) +// stops after StartLimitBurst attempts instead of spinning forever. +// `multica daemon stop` exits 0, so the restart policy never undoes it. +// +// RestartForceExitStatus pairs with daemonSystemdHandoffExitStatus: a +// binary-update restart under this unit exits with that status instead of +// spawning a successor and exiting 0 (which would let systemd's cgroup +// cleanup kill the successor), and this directive makes systemd restart the +// new binary — even if a user edited Restart= away from on-failure. +func systemdUnitContent(spec autostartSpec) string { + exec := make([]string, 0, len(spec.Args)+1) + exec = append(exec, systemdExecArg(spec.Exe)) + for _, a := range spec.Args { + exec = append(exec, systemdExecArg(a)) + } + + var b strings.Builder + b.WriteString(autostartManagedComment + "\n") + b.WriteString("[Unit]\n") + b.WriteString("Description=Multica agent runtime daemon\n") + b.WriteString("StartLimitIntervalSec=120\n") + b.WriteString("StartLimitBurst=5\n") + b.WriteString("\n[Service]\n") + b.WriteString("Type=simple\n") + b.WriteString("ExecStart=" + strings.Join(exec, " ") + "\n") + if spec.PathEnv != "" { + b.WriteString(`Environment="PATH=` + systemdEscapeEnvValue(spec.PathEnv) + "\"\n") + } + b.WriteString("Restart=on-failure\n") + b.WriteString("RestartSec=10\n") + b.WriteString(fmt.Sprintf("RestartForceExitStatus=%d\n", daemonSystemdHandoffExitStatus)) + b.WriteString("\n[Install]\n") + b.WriteString("WantedBy=default.target\n") + return b.String() +} + +// systemdEscapeEnvValue escapes a value inside a quoted systemd setting: +// '%' is specifier-expanded there too, and backslash starts C escapes. +func systemdEscapeEnvValue(s string) string { + s = strings.ReplaceAll(s, "%", "%%") + s = strings.ReplaceAll(s, `\`, `\\`) + return s +} + +// xdgAutostartFileName is the .desktop filename under ~/.config/autostart. +func xdgAutostartFileName(profile string) string { + if profile == "" { + return "multica-daemon.desktop" + } + return "multica-daemon-" + autostartProfileSlug(profile) + ".desktop" +} + +// desktopExecArg escapes one Exec word per the desktop-entry spec: '%' +// introduces field codes (double it), and reserved characters require +// double-quoted grouping with \ and " backslash-escaped inside. +func desktopExecArg(s string) string { + s = strings.ReplaceAll(s, "%", "%%") + if s == "" { + return `""` + } + if !strings.ContainsAny(s, " \t\"'`$&;|<>~*?#(){}[]\\") { + return s + } + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} + +// xdgAutostartContent renders the fallback entry used when systemd is not +// available. The session manager starts it at login with the session's +// environment, which — like launchd — may lack the shell-only PATH pieces, +// so PATH is injected through `env`. NoDisplay keeps it out of application +// menus; it exists only to autostart. +func xdgAutostartContent(spec autostartSpec) string { + execWords := []string{"env"} + if spec.PathEnv != "" { + execWords = append(execWords, "PATH="+spec.PathEnv) + } + execWords = append(execWords, spec.Exe) + execWords = append(execWords, spec.Args...) + + quoted := make([]string, 0, len(execWords)) + for _, w := range execWords { + quoted = append(quoted, desktopExecArg(w)) + } + + var b strings.Builder + b.WriteString("[Desktop Entry]\n") + b.WriteString(autostartManagedComment + "\n") + b.WriteString("Type=Application\n") + b.WriteString("Version=1.0\n") + b.WriteString("Name=Multica daemon\n") + b.WriteString("Comment=Start the Multica agent runtime daemon at login\n") + b.WriteString("Exec=" + strings.Join(quoted, " ") + "\n") + b.WriteString("Terminal=false\n") + b.WriteString("NoDisplay=true\n") + b.WriteString("X-GNOME-Autostart-enabled=true\n") + return b.String() +} diff --git a/server/cmd/multica/cmd_daemon_autostart_darwin.go b/server/cmd/multica/cmd_daemon_autostart_darwin.go new file mode 100644 index 00000000000..dd165b991f5 --- /dev/null +++ b/server/cmd/multica/cmd_daemon_autostart_darwin.go @@ -0,0 +1,146 @@ +//go:build darwin + +package main + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strconv" +) + +func platformAutostartSupported() bool { return true } + +// platformAutostartRefreshAllowed is unconditional on macOS: launchd has no +// cgroup-style cleanup that could punish a rewrite, and a file at our path +// is only ever rewritten when it carries our marker. +func platformAutostartRefreshAllowed(string) bool { return true } + +// platformDaemonUnderOwnSystemdUnit is always false outside Linux. +func platformDaemonUnderOwnSystemdUnit(string) bool { return false } + +// launchAgentsDir is where launchd loads per-user login agents from. Files +// dropped here are picked up at the next login without an explicit +// `launchctl load`, which is what makes the plist the registration itself. +func launchAgentsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents"), nil +} + +func launchAgentPath(profile string) (string, error) { + dir, err := launchAgentsDir() + if err != nil { + return "", err + } + return filepath.Join(dir, launchAgentLabel(profile)+".plist"), nil +} + +// platformWriteAutostart renders (or refreshes) the profile's LaunchAgent. +// The content comparison keeps every `daemon start` after the first a +// read-only no-op unless the executable or PATH snapshot actually changed. +func platformWriteAutostart(profile string, spec autostartSpec) (autostartState, bool, error) { + label := launchAgentLabel(profile) + path, err := launchAgentPath(profile) + if err != nil { + return autostartState{}, false, err + } + content := launchAgentPlistContent(spec, label) + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return autostartState{}, false, err + } + previous, readErr := os.ReadFile(path) + // Second line of defense behind the shared guard: never overwrite a file + // at our path that lacks the Multica marker — it is the user's own agent. + if readErr == nil && !launchAgentManaged(previous) { + return autostartState{ + Mechanism: autostartMechanismLaunchd, + Location: path, + }, false, errAutostartUnmanaged + } + changed := readErr != nil || string(previous) != content + if changed { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return autostartState{}, false, err + } + } + + // Best-effort `launchctl enable`: it clears a per-label disable someone + // may have set with `launchctl disable` and does not start anything. + // Failure is not an error — the plist alone still loads at next login. + _ = exec.Command("launchctl", "enable", "gui/"+strconv.Itoa(os.Getuid())+"/"+label).Run() + + return autostartState{ + Present: true, // a plist in LaunchAgents runs at login; presence is enablement + Enabled: true, + Managed: true, + Mechanism: autostartMechanismLaunchd, + Location: path, + Command: autostartCommandDisplay(spec), + }, changed, nil +} + +// platformRemoveAutostart deletes the LaunchAgent — on macOS the plist's +// presence IS the enablement, so removing it is what stops the next login +// from starting the daemon. +// +// Deliberately WITHOUT `launchctl bootout`: bootout would terminate the +// process launchd is supervising right now, and disable means "don't start +// at the next login", not "kill the current session". launchd keeps the +// already-running process going when the plist disappears; only the next +// login (where the file is gone) starts nothing. Mirrors the Linux disable, +// which unlinks the unit while keeping it (and its restart policy) for the +// running service. +func platformRemoveAutostart(profile string) (autostartState, bool, error) { + path, err := launchAgentPath(profile) + if err != nil { + return autostartState{}, false, err + } + + if _, statErr := os.Stat(path); statErr != nil { + if errors.Is(statErr, fs.ErrNotExist) { + return autostartState{ + Mechanism: autostartMechanismLaunchd, + Location: path, + }, false, nil + } + return autostartState{}, false, statErr + } + + if err := os.Remove(path); err != nil { + return autostartState{}, false, err + } + return autostartState{ + Mechanism: autostartMechanismLaunchd, + Location: path, + }, true, nil +} + +// platformReadAutostart treats the plist's presence as "enabled" — the file +// in ~/Library/LaunchAgents is what launchd honors at login. +func platformReadAutostart(profile string) (autostartState, error) { + path, err := launchAgentPath(profile) + if err != nil { + return autostartState{}, err + } + content, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return autostartState{Mechanism: autostartMechanismLaunchd, Location: path}, nil + } + if err != nil { + return autostartState{}, err + } + return autostartState{ + Present: true, // the plist's presence is what launchd honors + Enabled: true, + Managed: launchAgentManaged(content), + Mechanism: autostartMechanismLaunchd, + Location: path, + Command: launchAgentStoredCommand(content), + }, nil +} diff --git a/server/cmd/multica/cmd_daemon_autostart_linux.go b/server/cmd/multica/cmd_daemon_autostart_linux.go new file mode 100644 index 00000000000..5beb2fa20b3 --- /dev/null +++ b/server/cmd/multica/cmd_daemon_autostart_linux.go @@ -0,0 +1,433 @@ +//go:build linux + +package main + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" +) + +func platformAutostartSupported() bool { return true } + +// procSelfCgroupPath is a variable so tests can point the ownership check at +// a fixture instead of this process's real cgroup. +var procSelfCgroupPath = "/proc/self/cgroup" + +// platformAutostartRefreshAllowed gates the silent heal on `daemon start`. +// Outside systemd it is always fine. Under systemd, a daemon launched by an +// external unit (a hand-written service with its own EnvironmentFile=, a +// container manager that happens to run under systemd) must not let Multica +// rewrite anything: only our own unit gets to refresh its own file. +func platformAutostartRefreshAllowed(profile string) bool { + if os.Getenv("INVOCATION_ID") == "" { + return true + } + return runningUnderOwnSystemdUnit(profile) +} + +// platformDaemonUnderOwnSystemdUnit reports whether THIS process was started +// by the user unit Multica itself generates. Used by the foreground daemon's +// binary-update handoff: under our unit it exits with +// daemonSystemdHandoffExitStatus instead of spawning a successor, because +// systemd's cgroup cleanup would kill a successor spawned before a clean +// exit 0 (see runDaemonForeground). +func platformDaemonUnderOwnSystemdUnit(profile string) bool { + if os.Getenv("INVOCATION_ID") == "" { + return false + } + return runningUnderOwnSystemdUnit(profile) +} + +// runningUnderOwnSystemdUnit matches the unit name against this process's +// cgroup path — systemd puts the unit name there for user services, so a +// daemon started by the user's own differently-named unit (INVOCATION_ID set, +// unit not ours) reads false and keeps the portable spawn handoff. +func runningUnderOwnSystemdUnit(profile string) bool { + data, err := os.ReadFile(procSelfCgroupPath) + if err != nil { + return false + } + return strings.Contains(string(data), systemdUnitName(profile)) +} + +// systemdAvailable reports whether registration should target a systemd user +// unit. It is the preferred mechanism: it supervises restarts (bounded — see +// systemdUnitContent) and is what headless servers actually have, since a +// desktop autostart entry never fires on a machine nobody logs into. +func systemdAvailable() bool { + _, err := exec.LookPath("systemctl") + return err == nil +} + +// configSubdir resolves an XDG base dir: $XDG_CONFIG_HOME when set (systemd +// and the session managers honor it too), else ~/.config. +func configSubdir(parts ...string) (string, error) { + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + return filepath.Join(append([]string{xdg}, parts...)...), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(append([]string{home, ".config"}, parts...)...), nil +} + +func systemdUnitPath(profile string) (string, error) { + dir, err := configSubdir("systemd", "user") + if err != nil { + return "", err + } + return filepath.Join(dir, systemdUnitName(profile)), nil +} + +// systemdUnitLinked reports whether the unit is enabled the way `systemctl +// enable` leaves it: a wants symlink under default.target.wants. Reading the +// symlink instead of calling `systemctl is-enabled` keeps `status` working +// without a live user bus (e.g. over a bare SSH session). +func systemdUnitLinked(unitPath string) bool { + wantsDir := filepath.Join(filepath.Dir(unitPath), "default.target.wants") + info, err := os.Lstat(filepath.Join(wantsDir, filepath.Base(unitPath))) + return err == nil && info.Mode()&fs.ModeSymlink != 0 +} + +func xdgAutostartPath(profile string) (string, error) { + dir, err := configSubdir("autostart") + if err != nil { + return "", err + } + return filepath.Join(dir, xdgAutostartFileName(profile)), nil +} + +// platformWriteAutostart registers the profile for boot/login start via +// systemd (preferred) or an XDG autostart entry. +func platformWriteAutostart(profile string, spec autostartSpec) (autostartState, bool, error) { + if systemdAvailable() { + return writeSystemdAutostart(profile, spec) + } + return writeXdgAutostart(profile, spec) +} + +func writeSystemdAutostart(profile string, spec autostartSpec) (autostartState, bool, error) { + path, err := systemdUnitPath(profile) + if err != nil { + return autostartState{}, false, err + } + content := systemdUnitContent(spec) + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return autostartState{}, false, err + } + previous, readErr := os.ReadFile(path) + // Second line of defense behind the shared guard: never overwrite a unit + // at our path that lacks the Multica marker — a user's hand-written unit + // (e.g. one with an EnvironmentFile=) lives at this exact name. + if readErr == nil && !autostartCommentMarked(string(previous)) { + return autostartState{ + Mechanism: autostartMechanismSystemd, + Location: path, + }, false, errAutostartUnmanaged + } + changed := readErr != nil || string(previous) != content + if changed { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return autostartState{}, false, err + } + } + + // enable creates the default.target.wants symlink; it does NOT start the + // unit (`daemon start` already did that) and it is idempotent when the + // link exists. daemon-reload is best-effort: without it systemd still + // picks the unit up at the next login, and a user manager that is not + // running yet has nothing to reload. + if out, err := exec.Command("systemctl", "--user", "enable", systemdUnitName(profile)).CombinedOutput(); err != nil { + return autostartState{}, false, systemdCommandError("enable", err, out) + } + _ = exec.Command("systemctl", "--user", "daemon-reload").Run() + + // Removing a stale XDG entry keeps one registration authoritative after + // a systemd-capable environment replaced an earlier fallback. + if removeXdgAutostartFile(profile) { + changed = true + } + + return autostartState{ + Present: true, + Enabled: true, + Managed: true, + Mechanism: autostartMechanismSystemd, + Location: path, + Command: autostartCommandDisplay(spec), + Note: lingerNote(), + }, changed, nil +} + +func writeXdgAutostart(profile string, spec autostartSpec) (autostartState, bool, error) { + path, err := xdgAutostartPath(profile) + if err != nil { + return autostartState{}, false, err + } + content := xdgAutostartContent(spec) + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return autostartState{}, false, err + } + previous, readErr := os.ReadFile(path) + if readErr == nil && !autostartCommentMarked(string(previous)) { + return autostartState{ + Mechanism: autostartMechanismXDG, + Location: path, + }, false, errAutostartUnmanaged + } + changed := readErr != nil || string(previous) != content + if changed { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return autostartState{}, false, err + } + } + return autostartState{ + Present: true, + Enabled: true, + Managed: true, + Mechanism: autostartMechanismXDG, + Location: path, + Command: autostartCommandDisplay(spec), + }, changed, nil +} + +// lingerNote reports whether this user's manager starts at boot (linger) or +// only at first login — the difference between boot autostart and login +// autostart on a server nobody logs into. It only READS state: enabling +// linger is a system-level change the review explicitly reserved for the +// user to make, so the note hands them the exact command instead of running +// it. Empty when already lingering, when loginctl is missing, or when the +// query fails (we cannot claim either way). +func lingerNote() string { + u, err := user.Current() + if err != nil { + return "" + } + out, err := exec.Command("loginctl", "show-user", "--value", "-p", "Linger", u.Username).CombinedOutput() + if err != nil { + return "" + } + if strings.TrimSpace(string(out)) == "yes" { + return "" + } + return "starts at login; to start at boot without logging in, run: loginctl enable-linger " + u.Username +} + +// platformRemoveAutostart clears the registration from both mechanisms: a +// profile registered under the fallback and later re-registered under +// systemd must not leave either half behind, and each half is a no-op when +// it was never written. +// +// Every removal re-checks the ownership marker on the file itself first — +// the same backstop the writers carry — so an unmarked file at our path is +// never deleted even if a caller skipped the shared guard (or raced one): +// deleting a user's hand-written unit or .desktop is exactly the loss the +// marker exists to prevent. +func platformRemoveAutostart(profile string) (autostartState, bool, error) { + state := autostartState{Mechanism: autostartMechanismXDG} + changed := false + + if systemdAvailable() { + path, err := systemdUnitPath(profile) + if err != nil { + return autostartState{}, false, err + } + state = autostartState{Mechanism: autostartMechanismSystemd, Location: path} + + if _, statErr := os.Stat(path); statErr == nil { + // Marker check BEFORE touching the unit: refusing must not have + // unlinked the user's file as a side effect. + previous, readErr := os.ReadFile(path) + if readErr != nil { + return autostartState{}, false, readErr + } + if !autostartCommentMarked(string(previous)) { + return state, false, errAutostartUnmanaged + } + // Remove the enablement but KEEP the managed unit file. The + // running daemon is still supervised by this unit, and its + // restart configuration (Restart=, RestartForceExitStatus=) is + // what lets the exit-42 binary-update handoff come back in this + // session: deleting the file (as disable used to) leaves the + // live service at LoadState=not-found / Restart=no, so the next + // handoff exits into failure and stays down until someone + // starts it manually. Unlinked, the unit never starts at the + // next login — boot autostart is off — while the current + // session keeps its restart policy. + if systemdUnitLinkedAnywhere(path) { + if _, err := exec.Command("systemctl", "--user", "disable", systemdUnitName(profile)).CombinedOutput(); err != nil { + // A missing user bus must not strand the links: unlink + // every wants entry directly. + removeSystemdWantsLinks(path) + } + _ = exec.Command("systemctl", "--user", "daemon-reload").Run() + changed = true + } + } else if !errors.Is(statErr, fs.ErrNotExist) { + return autostartState{}, false, statErr + } + } else { + path, err := xdgAutostartPath(profile) + if err != nil { + return autostartState{}, false, err + } + state.Location = path + } + + if removeXdgAutostartFile(profile) { + changed = true + } + return state, changed, nil +} + +// systemdUnitLinkedAnywhere reports whether ANY wants link for the unit +// exists under the user unit dir — default.target or any other target +// (graphical-session.target and friends). Disable has to clear all of them, +// and a link that exists somewhere is what makes a disable call a real +// change rather than an idempotent no-op. +func systemdUnitLinkedAnywhere(unitPath string) bool { + matches, err := filepath.Glob(filepath.Join(filepath.Dir(unitPath), "*.wants", filepath.Base(unitPath))) + return err == nil && len(matches) > 0 +} + +// removeSystemdWantsLinks unlinks every wants entry for the unit — the +// fallback when `systemctl --user disable` cannot run (no user bus). +func removeSystemdWantsLinks(unitPath string) { + matches, err := filepath.Glob(filepath.Join(filepath.Dir(unitPath), "*.wants", filepath.Base(unitPath))) + if err != nil { + return + } + for _, match := range matches { + _ = os.Remove(match) + } +} + +// removeXdgAutostartFile deletes the fallback entry if present, reporting +// whether anything was actually removed. Only a marked (Multica-created) +// file is removed: an unmarked .desktop at our name — reachable from the +// systemd sweep on every enable/refresh and from disable — is the user's, +// and is left in place. A missing file is not an error — removal is +// expected to be idempotent. +func removeXdgAutostartFile(profile string) bool { + path, err := xdgAutostartPath(profile) + if err != nil { + return false + } + previous, err := os.ReadFile(path) + if err != nil { + // Missing (the common case) and unreadable both mean "nothing we + // created is here": never delete what we cannot positively identify. + return false + } + if !autostartCommentMarked(string(previous)) { + return false + } + return os.Remove(path) == nil +} + +// platformReadAutostart inspects both mechanisms so status stays truthful +// across a fallback/systemd transition, then reports the mechanism `enable` +// would use today when nothing is registered. +func platformReadAutostart(profile string) (autostartState, error) { + if systemdAvailable() { + path, err := systemdUnitPath(profile) + if err != nil { + return autostartState{}, err + } + content, err := os.ReadFile(path) + switch { + case err == nil: + // Present tracks the FILE, Enabled tracks the wants link: an + // unlinked unit — or one linked under another target — is + // present-but-disabled, and the ownership guards key off Present. + return autostartState{ + Present: true, + Enabled: systemdUnitLinked(path), + Managed: autostartCommentMarked(string(content)), + Mechanism: autostartMechanismSystemd, + Location: path, + Command: systemdStoredCommand(string(content)), + }, nil + case !errors.Is(err, fs.ErrNotExist): + return autostartState{}, err + } + // Unit file absent — an XDG entry may still predate systemd. + if state, xdgErr := readXdgAutostart(profile); xdgErr == nil && state.Present { + return state, nil + } + return autostartState{Mechanism: autostartMechanismSystemd, Location: path}, nil + } + + path, err := xdgAutostartPath(profile) + if err != nil { + return autostartState{}, err + } + content, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return autostartState{Mechanism: autostartMechanismXDG, Location: path}, nil + } + if err != nil { + return autostartState{}, err + } + return autostartState{ + Present: true, + Enabled: true, + Managed: autostartCommentMarked(string(content)), + Mechanism: autostartMechanismXDG, + Location: path, + Command: xdgStoredCommand(string(content)), + }, nil +} + +func readXdgAutostart(profile string) (autostartState, error) { + path, err := xdgAutostartPath(profile) + if err != nil { + return autostartState{}, err + } + content, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return autostartState{Mechanism: autostartMechanismXDG, Location: path}, nil + } + if err != nil { + return autostartState{}, err + } + return autostartState{ + Present: true, + Enabled: true, + Managed: autostartCommentMarked(string(content)), + Mechanism: autostartMechanismXDG, + Location: path, + Command: xdgStoredCommand(string(content)), + }, nil +} + +// systemdCommandError renders a failed `systemctl --user` call with its +// output, since systemctl's own message is what names the real cause (no +// user bus, read-only home, ...). +func systemdCommandError(action string, err error, out []byte) error { + return &autostartCommandFailure{Action: action, Err: err, Output: strings.TrimSpace(string(out))} +} + +type autostartCommandFailure struct { + Action string + Err error + Output string +} + +func (e *autostartCommandFailure) Error() string { + msg := "systemctl --user " + e.Action + " failed: " + e.Err.Error() + if e.Output != "" { + msg += " (" + e.Output + ")" + } + return msg +} + +func (e *autostartCommandFailure) Unwrap() error { return e.Err } diff --git a/server/cmd/multica/cmd_daemon_autostart_linux_test.go b/server/cmd/multica/cmd_daemon_autostart_linux_test.go new file mode 100644 index 00000000000..7e7e8e1a475 --- /dev/null +++ b/server/cmd/multica/cmd_daemon_autostart_linux_test.go @@ -0,0 +1,395 @@ +//go:build linux + +package main + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeCgroupFixture points procSelfCgroupPath at a fixture containing the +// given cgroup table for the duration of the test. +func writeCgroupFixture(t *testing.T, content string) { + t.Helper() + path := filepath.Join(t.TempDir(), "cgroup") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write cgroup fixture: %v", err) + } + orig := procSelfCgroupPath + procSelfCgroupPath = path + t.Cleanup(func() { procSelfCgroupPath = orig }) +} + +// TestSystemdOwnUnitDetection pins the matcher behind BOTH systemd decisions: +// the `daemon start` refresh gate and the binary-update handoff. systemd +// embeds the unit name in the cgroup path of a user service, so a daemon +// started by our own unit matches, while one started by the user's own +// differently-named unit does not — that is what keeps the spawn handoff and +// the no-refresh rule pointed at foreign supervisors only. +func TestSystemdOwnUnitDetection(t *testing.T) { + ownCgroup := "0::/user.slice/user-1000.slice/user@1000.service/app.slice/multica-daemon.service-2841.service\n" + ownProfileCgroup := "0::/user.slice/user-1000.slice/user@1000.service/app.slice/multica-daemon-staging.service-12.service\n" + foreignCgroup := "0::/user.slice/user-1000.slice/user@1000.service/app.slice/my-daemon.service-9.service\n" + + t.Run("our unit matches the default profile", func(t *testing.T) { + writeCgroupFixture(t, ownCgroup) + if !runningUnderOwnSystemdUnit("") { + t.Error("runningUnderOwnSystemdUnit(\"\") = false for our own unit's cgroup") + } + // A different profile's unit must not match: the '-staging' infix + // keeps the plain name from reading as a prefix hit. + if runningUnderOwnSystemdUnit("staging") { + t.Error("runningUnderOwnSystemdUnit(\"staging\") = true on the default profile's unit") + } + }) + + t.Run("named profile unit matches its own profile only", func(t *testing.T) { + writeCgroupFixture(t, ownProfileCgroup) + if !runningUnderOwnSystemdUnit("staging") { + t.Error("runningUnderOwnSystemdUnit(\"staging\") = false for that profile's own unit") + } + if runningUnderOwnSystemdUnit("") { + t.Error("runningUnderOwnSystemdUnit(\"\") = true on the staging profile's unit") + } + }) + + t.Run("a foreign unit never matches", func(t *testing.T) { + writeCgroupFixture(t, foreignCgroup) + if runningUnderOwnSystemdUnit("") { + t.Error("runningUnderOwnSystemdUnit(\"\") = true for the user's own unit") + } + }) + + t.Run("unreadable cgroup never matches", func(t *testing.T) { + orig := procSelfCgroupPath + procSelfCgroupPath = filepath.Join(t.TempDir(), "missing") + t.Cleanup(func() { procSelfCgroupPath = orig }) + if runningUnderOwnSystemdUnit("") { + t.Error("runningUnderOwnSystemdUnit(\"\") = true with no readable cgroup") + } + }) +} + +// TestSystemdSupervisorGates pins the two env-gated decisions: outside +// systemd (no INVOCATION_ID) both the refresh and the spawn handoff behave +// as they always have; under systemd only our own unit keeps them. +func TestSystemdSupervisorGates(t *testing.T) { + own := "0::/app.slice/multica-daemon.service-1.service\n" + foreign := "0::/app.slice/user-service.service-1.service\n" + + t.Run("outside systemd refresh is always allowed", func(t *testing.T) { + t.Setenv("INVOCATION_ID", "") + if !platformAutostartRefreshAllowed("") { + t.Error("platformAutostartRefreshAllowed = false outside systemd") + } + if platformDaemonUnderOwnSystemdUnit("") { + t.Error("platformDaemonUnderOwnSystemdUnit = true outside systemd") + } + }) + + t.Run("under our unit both refresh and handoff are allowed", func(t *testing.T) { + t.Setenv("INVOCATION_ID", "abc123") + writeCgroupFixture(t, own) + if !platformAutostartRefreshAllowed("") { + t.Error("platformAutostartRefreshAllowed = false under our own unit") + } + if !platformDaemonUnderOwnSystemdUnit("") { + t.Error("platformDaemonUnderOwnSystemdUnit = false under our own unit") + } + }) + + t.Run("under a foreign unit refresh and handoff are refused", func(t *testing.T) { + t.Setenv("INVOCATION_ID", "abc123") + writeCgroupFixture(t, foreign) + if platformAutostartRefreshAllowed("") { + t.Error("platformAutostartRefreshAllowed = true under a foreign unit") + } + if platformDaemonUnderOwnSystemdUnit("") { + t.Error("platformDaemonUnderOwnSystemdUnit = true under a foreign unit") + } + }) +} + +// useAutostartConfigHome points XDG_CONFIG_HOME at a fresh temp dir so every +// path helper (the systemd user dir, the autostart dir) resolves into a +// sandbox — never the test host's real ~/.config. +func useAutostartConfigHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + return dir +} + +func writeAutostartFixture(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create fixture dir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write fixture %s: %v", path, err) + } +} + +// unmarkedUnitFixture is a plausible hand-written unit at Multica's own name — +// no marker, and WantedBy a target other than default, exactly the shape the +// review found deletable. +const unmarkedUnitFixture = `[Unit] +Description=user's own service with an EnvironmentFile +[Service] +ExecStart=/usr/bin/true +[Install] +WantedBy=graphical-session.target +` + +func markedUnitFixture(t *testing.T) string { + t.Helper() + return systemdUnitContent(autostartSpec{Exe: "/usr/local/bin/multica", Args: autostartArgs("")}) +} + +// TestPlatformReadAutostartReportsUnmarkedUnitPresence pins the read side of +// the review's gap: Enabled tracks only default.target.wants, so a +// hand-written unit reads disabled whether it is unlinked or linked under +// another target — while Present stays true and Managed false, which is what +// the ownership guards now key off. +func TestPlatformReadAutostartReportsUnmarkedUnitPresence(t *testing.T) { + if !systemdAvailable() { + t.Skip("systemctl not available") + } + useAutostartConfigHome(t) + + readUnmarked := func(t *testing.T) autostartState { + t.Helper() + path, err := systemdUnitPath("") + if err != nil { + t.Fatalf("systemdUnitPath: %v", err) + } + writeAutostartFixture(t, path, unmarkedUnitFixture) + state, err := platformReadAutostart("") + if err != nil { + t.Fatalf("platformReadAutostart: %v", err) + } + return state + } + + t.Run("unlinked unmarked unit is present but disabled", func(t *testing.T) { + state := readUnmarked(t) + if !state.Present || state.Enabled || state.Managed { + t.Fatalf("state = %+v, want Present && !Enabled && !Managed", state) + } + }) + + t.Run("unmarked unit linked under another target reads disabled", func(t *testing.T) { + state := readUnmarked(t) + path, err := systemdUnitPath("") + if err != nil { + t.Fatalf("systemdUnitPath: %v", err) + } + linkUnit(t, path, "graphical-session.target.wants") + state, err = platformReadAutostart("") + if err != nil { + t.Fatalf("platformReadAutostart: %v", err) + } + // Linked — but not under default.target, so Enabled stays false and + // the old Enabled-keyed guard would have missed it entirely. + if !state.Present || state.Enabled || state.Managed { + t.Fatalf("state = %+v, want Present && !Enabled && !Managed despite the other-target link", state) + } + }) + + t.Run("unmarked unit linked under default.target is enabled but still foreign", func(t *testing.T) { + state := readUnmarked(t) + path, err := systemdUnitPath("") + if err != nil { + t.Fatalf("systemdUnitPath: %v", err) + } + linkUnit(t, path, "default.target.wants") + state, err = platformReadAutostart("") + if err != nil { + t.Fatalf("platformReadAutostart: %v", err) + } + if !state.Present || !state.Enabled || state.Managed { + t.Fatalf("state = %+v, want Present && Enabled && !Managed", state) + } + }) +} + +// linkUnit creates the wants symlink systemctl enable would create, under the +// named wants directory relative to the unit's directory. +func linkUnit(t *testing.T, unitPath, wantsSubdir string) { + t.Helper() + wantsDir := filepath.Join(filepath.Dir(unitPath), wantsSubdir) + if err := os.MkdirAll(wantsDir, 0o755); err != nil { + t.Fatalf("create wants dir: %v", err) + } + if err := os.Symlink(unitPath, filepath.Join(wantsDir, filepath.Base(unitPath))); err != nil { + t.Fatalf("link unit: %v", err) + } +} + +// TestPlatformRemoveAutostartRefusesUnmarkedUnit pins the removal backstop: +// even if a caller skips the shared guard, an unmarked file at our unit path +// is never deleted — and the refusal fires BEFORE systemctl disable, so the +// user's unit is not unlinked as a side effect either. +func TestPlatformRemoveAutostartRefusesUnmarkedUnit(t *testing.T) { + if !systemdAvailable() { + t.Skip("systemctl not available") + } + useAutostartConfigHome(t) + path, err := systemdUnitPath("") + if err != nil { + t.Fatalf("systemdUnitPath: %v", err) + } + writeAutostartFixture(t, path, unmarkedUnitFixture) + + _, changed, err := platformRemoveAutostart("") + if !errors.Is(err, errAutostartUnmanaged) { + t.Fatalf("platformRemoveAutostart = %v, want errAutostartUnmanaged", err) + } + if changed { + t.Fatalf("changed = true, want false — nothing may be removed") + } + if _, statErr := os.Stat(path); statErr != nil { + t.Fatalf("unmarked unit was touched: %v", statErr) + } +} + +// TestPlatformRemoveAutostartKeepsUnmarkedXdgAlongsideManagedUnit is the +// review's coexistence scenario: a Multica-created unit plus a hand-written +// .desktop at our XDG name. Disabling Multica's registration must leave the +// user's .desktop alone — the sweep funnels through the marker-checked +// removeXdgAutostartFile, which enable and the `daemon start` refresh use as +// well, so this covers all three call sites. Per round 5's keep-the-unit +// semantics, disable unlinks the marked unit (retaining its restart policy) +// rather than deleting it. +func TestPlatformRemoveAutostartKeepsUnmarkedXdgAlongsideManagedUnit(t *testing.T) { + if !systemdAvailable() { + t.Skip("systemctl not available") + } + useAutostartConfigHome(t) + unitPath, err := systemdUnitPath("") + if err != nil { + t.Fatalf("systemdUnitPath: %v", err) + } + xdgPath, err := xdgAutostartPath("") + if err != nil { + t.Fatalf("xdgAutostartPath: %v", err) + } + writeAutostartFixture(t, unitPath, markedUnitFixture(t)) + linkUnit(t, unitPath, "default.target.wants") + writeAutostartFixture(t, xdgPath, "[Desktop Entry]\nType=Application\nName=hand-written\nExec=/usr/bin/true\n") + + _, changed, err := platformRemoveAutostart("") + if err != nil { + t.Fatalf("platformRemoveAutostart = %v", err) + } + if !changed { + t.Fatalf("changed = false, want true — the unit's enablement link should be removed") + } + + // The marked unit is unlinked but retained (restart policy for the + // running service); the user's unmarked .desktop must be untouched. + if _, statErr := os.Stat(unitPath); statErr != nil { + t.Fatalf("managed unit file was deleted (%v) — disable must keep it, unlinked", statErr) + } + if systemdUnitLinkedAnywhere(unitPath) { + t.Fatal("managed unit still wants-linked — it would start at the next login") + } + if _, statErr := os.Stat(xdgPath); statErr != nil { + t.Fatalf("unmarked .desktop was deleted (%v) — only Multica-created files may be removed", statErr) + } +} + +// TestRemoveXdgAutostartFileOnlyRemovesMarkedEntries covers the shared XDG +// sweeper directly: every removal path (enable's stale sweep, the daemon +// start refresh, disable) funnels through it, so an unmarked file there must +// survive all of them. +func TestRemoveXdgAutostartFileOnlyRemovesMarkedEntries(t *testing.T) { + useAutostartConfigHome(t) + path, err := xdgAutostartPath("") + if err != nil { + t.Fatalf("xdgAutostartPath: %v", err) + } + + unmarked := "[Desktop Entry]\nType=Application\nName=hand-written\nExec=/usr/bin/true\n" + writeAutostartFixture(t, path, unmarked) + if removeXdgAutostartFile("") { + t.Fatal("removeXdgAutostartFile removed an unmarked file") + } + if _, statErr := os.Stat(path); statErr != nil { + t.Fatalf("unmarked .desktop no longer exists: %v", statErr) + } + + marked := xdgAutostartContent(autostartSpec{Exe: "/usr/local/bin/multica", Args: autostartArgs("")}) + writeAutostartFixture(t, path, marked) + if !removeXdgAutostartFile("") { + t.Fatal("removeXdgAutostartFile left a marked file in place") + } + if _, statErr := os.Stat(path); !errors.Is(statErr, fs.ErrNotExist) { + t.Fatalf("marked .desktop still present (stat err = %v), want it removed", statErr) + } +} + +// TestDisableKeepsRunningServiceRecoverable pins the review's P2 regression: +// running service → `autostart disable` → binary-update handoff. Disable must +// unlink the unit (so no start happens at the next login) while KEEPING the +// managed unit file: its RestartForceExitStatus is what lets the exit-42 +// handoff come back in the current session. Deleting the file — as disable +// used to — left the live service at LoadState=not-found / Restart=no, so the +// handoff exited into failure and the runtime stayed offline. The follow-up +// sync (what `daemon start` runs) must also not silently re-enable it. +func TestDisableKeepsRunningServiceRecoverable(t *testing.T) { + if !systemdAvailable() { + t.Skip("systemctl not available") + } + useAutostartConfigHome(t) + unitPath, err := systemdUnitPath("") + if err != nil { + t.Fatalf("systemdUnitPath: %v", err) + } + writeAutostartFixture(t, unitPath, markedUnitFixture(t)) + linkUnit(t, unitPath, "default.target.wants") + + if _, changed, err := platformRemoveAutostart(""); err != nil || !changed { + t.Fatalf("platformRemoveAutostart: changed=%v err=%v, want the link removed", changed, err) + } + + // 1. Restart configuration retained for the running, supervised service. + content, err := os.ReadFile(unitPath) + if err != nil { + t.Fatalf("managed unit file was deleted — the running service loses its restart policy: %v", err) + } + if !strings.Contains(string(content), "RestartForceExitStatus=") { + t.Fatalf("retained unit lost its handoff restart policy:\n%s", content) + } + + // 2. Boot autostart disabled: nothing links the unit anymore. + if systemdUnitLinkedAnywhere(unitPath) { + t.Fatal("unit still wants-linked — it would start at the next login") + } + + // 3. The running process would still take the exit-42 handoff branch + // (its cgroup still names the unit), so with the policy retained above + // systemd restarts it — and the next daemon start must not re-enable. + t.Setenv("INVOCATION_ID", "fixture") + writeCgroupFixture(t, "0::/app.slice/multica-daemon.service-1.service\n") + if !platformDaemonUnderOwnSystemdUnit("") { + t.Fatal("handoff detection lost its own unit after disable — the daemon would fall back to spawn+exit 0") + } + t.Setenv("MULTICA_LAUNCHED_BY", "") + syncDaemonAutostartDefault("", false) + if systemdUnitLinkedAnywhere(unitPath) { + t.Fatal("daemon start re-linked the unit — disable must survive a restart cycle") + } + if _, statErr := os.Stat(unitPath); statErr != nil { + t.Fatalf("sync touched the retained unit: %v", statErr) + } + + // A second disable is a no-op, not a second "disabled". + if _, changed, err := platformRemoveAutostart(""); err != nil || changed { + t.Fatalf("second disable: changed=%v err=%v, want false/nil", changed, err) + } +} diff --git a/server/cmd/multica/cmd_daemon_autostart_other.go b/server/cmd/multica/cmd_daemon_autostart_other.go new file mode 100644 index 00000000000..8fbf855059c --- /dev/null +++ b/server/cmd/multica/cmd_daemon_autostart_other.go @@ -0,0 +1,27 @@ +//go:build !windows && !darwin && !linux + +package main + +// Boot autostart has no registration mechanism on this platform. These +// functions exist so the package still builds (and `daemon start` still +// works) on every GOOS the toolchain accepts; shouldManageAutostart skips +// silently there, while the explicit `daemon autostart` commands report that +// there is nothing to do. + +func platformAutostartSupported() bool { return false } + +func platformAutostartRefreshAllowed(string) bool { return false } + +func platformDaemonUnderOwnSystemdUnit(string) bool { return false } + +func platformWriteAutostart(string, autostartSpec) (autostartState, bool, error) { + return autostartState{Mechanism: autostartMechanismUnsupported}, false, nil +} + +func platformRemoveAutostart(string) (autostartState, bool, error) { + return autostartState{Mechanism: autostartMechanismUnsupported}, false, nil +} + +func platformReadAutostart(string) (autostartState, error) { + return autostartState{Mechanism: autostartMechanismUnsupported}, nil +} diff --git a/server/cmd/multica/cmd_daemon_autostart_test.go b/server/cmd/multica/cmd_daemon_autostart_test.go new file mode 100644 index 00000000000..d30e5cff817 --- /dev/null +++ b/server/cmd/multica/cmd_daemon_autostart_test.go @@ -0,0 +1,904 @@ +package main + +import ( + "errors" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// autostartCmdFor builds a command carrying the flags the autostart commands +// read (profile, output). +func autostartCmdFor(t *testing.T, profile, output string) *cobra.Command { + t.Helper() + return daemonStatusCmdFor(t, profile, output) +} + +func TestAutostartArgs(t *testing.T) { + t.Parallel() + + if got, want := strings.Join(autostartArgs(""), " "), "daemon start --foreground"; got != want { + t.Errorf("autostartArgs(default) = %q, want %q", got, want) + } + if got, want := strings.Join(autostartArgs("staging"), " "), "daemon start --foreground --profile staging"; got != want { + t.Errorf("autostartArgs(staging) = %q, want %q", got, want) + } +} + +func TestAutostartProfileSlug(t *testing.T) { + t.Parallel() + + safe := []string{"staging", "desktop-api.multica.ai", "dev_2", "prod-eu"} + for _, profile := range safe { + if got := autostartProfileSlug(profile); got != profile { + t.Errorf("autostartProfileSlug(%q) = %q, want the profile unchanged", profile, got) + } + } + + // Separators and other unsafe characters must not survive into a + // filename or unit name... + slug := autostartProfileSlug("team/dev") + if strings.ContainsAny(slug, `/\`) || slug == "team/dev" { + t.Errorf("autostartProfileSlug(team/dev) = %q, want a separator-free token", slug) + } + // ...and two profiles that sanitize to the same prefix must stay + // distinguishable, or one daemon's registration would overwrite the + // other's. + if autostartProfileSlug("team/dev") == autostartProfileSlug("team-dev") { + t.Errorf("slugs of team/dev and team-dev collide; the hash suffix must keep them distinct") + } + // Deterministic: the same profile must map to the same entry forever. + if autostartProfileSlug("team/dev") != autostartProfileSlug("team/dev") { + t.Errorf("autostartProfileSlug is not deterministic") + } +} + +func TestWindowsAutostartCommand(t *testing.T) { + t.Parallel() + + const exe = `C:\Program Files\multica\multica.exe` + got := windowsAutostartCommand(autostartSpec{Exe: exe, Args: autostartArgs("")}) + want := `"C:\Program Files\multica\multica.exe" daemon start --foreground` + if got != want { + t.Errorf("windowsAutostartCommand(default) = %q, want %q", got, want) + } + + got = windowsAutostartCommand(autostartSpec{Exe: exe, Args: autostartArgs("staging")}) + want = `"C:\Program Files\multica\multica.exe" daemon start --foreground --profile staging` + if got != want { + t.Errorf("windowsAutostartCommand(staging) = %q, want %q", got, want) + } +} + +func TestWindowsQuoteArg(t *testing.T) { + t.Parallel() + + cases := []struct { + in, want string + }{ + {"daemon", "daemon"}, + {`C:\multica.exe`, `C:\multica.exe`}, + {"", `""`}, + {"a b", `"a b"`}, + // Trailing backslashes inside a quoted run must be doubled or the + // parser counts them as escaping the closing quote. + {`C:\dir with space\`, `"C:\dir with space\\"`}, + {`C:\dir \`, `"C:\dir \\"`}, + {`say "hi"`, `"say \"hi\""`}, + } + for _, c := range cases { + if got := windowsQuoteArg(c.in); got != c.want { + t.Errorf("windowsQuoteArg(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestWindowsRunValueName(t *testing.T) { + t.Parallel() + + if got := windowsRunValueName(""); got != "Multica" { + t.Errorf("windowsRunValueName(default) = %q, want %q", got, "Multica") + } + if got := windowsRunValueName("staging"); got != "Multica (staging)" { + t.Errorf("windowsRunValueName(staging) = %q, want %q", got, "Multica (staging)") + } +} + +func TestLaunchAgentPlistContent(t *testing.T) { + t.Parallel() + + spec := autostartSpec{ + Exe: "/opt/homebrew/bin/multica", + Args: autostartArgs("staging"), + PathEnv: "/usr/bin:/opt/homebrew/bin", + } + label := launchAgentLabel("staging") + if label != "ai.multica.daemon.staging" { + t.Fatalf("launchAgentLabel(staging) = %q", label) + } + if got := launchAgentLabel(""); got != "ai.multica.daemon" { + t.Fatalf("launchAgentLabel(default) = %q", got) + } + + content := launchAgentPlistContent(spec, label) + for _, want := range []string{ + // Ownership marker: refresh and enable/disable refuse the file + // without it, so it has to be there in everything we write. + "" + autostartPlistMarkerKey + "", + "" + autostartPlistMarkerValue + "", + "ai.multica.daemon.staging", + "RunAtLoad", + "ProgramArguments", + "/opt/homebrew/bin/multica", + "--foreground", + "--profile", + "staging", + "PATH", + "/usr/bin:/opt/homebrew/bin", + "ProcessType", + } { + if !strings.Contains(content, want) { + t.Errorf("plist missing %q:\n%s", want, content) + } + } + // No KeepAlive: it would restart a daemon the user (or launchd at + // logout) just stopped. + if strings.Contains(content, "KeepAlive") { + t.Errorf("plist must not set KeepAlive:\n%s", content) + } + + if !launchAgentManaged([]byte(content)) { + t.Errorf("launchAgentManaged() = false for a plist we just wrote") + } + if launchAgentManaged([]byte(`Labelx`)) { + t.Errorf("launchAgentManaged() = true for a plist without our marker") + } + + // status must be able to read the argv back out of the written file. + if got, want := launchAgentStoredCommand([]byte(content)), + "/opt/homebrew/bin/multica daemon start --foreground --profile staging"; got != want { + t.Errorf("launchAgentStoredCommand = %q, want %q", got, want) + } +} + +func TestLaunchAgentPlistContentEscapesXML(t *testing.T) { + t.Parallel() + + spec := autostartSpec{Exe: "/opt/a&b/multica", Args: autostartArgs("")} + content := launchAgentPlistContent(spec, launchAgentLabel("")) + if !strings.Contains(content, "/opt/a&b/multica") { + t.Errorf("plist must XML-escape the executable path:\n%s", content) + } + if got, want := launchAgentStoredCommand([]byte(content)), "/opt/a&b/multica daemon start --foreground"; got != want { + t.Errorf("launchAgentStoredCommand = %q, want the unescaped argv back", got) + } +} + +func TestSystemdUnitContent(t *testing.T) { + t.Parallel() + + spec := autostartSpec{ + Exe: "/usr/local/bin/multica", + Args: autostartArgs("staging"), + PathEnv: "/home/u/.local/bin:/usr/bin", + } + content := systemdUnitContent(spec) + + for _, want := range []string{ + // Ownership marker on the first line. + autostartManagedComment, + "ExecStart=/usr/local/bin/multica daemon start --foreground --profile staging", + `Environment="PATH=/home/u/.local/bin:/usr/bin"`, + "Restart=on-failure", + "RestartSec=10", + // The binary-update handoff contract: runDaemonForeground exits with + // this status under our unit and systemd must restart the new binary. + "RestartForceExitStatus=" + strconv.Itoa(daemonSystemdHandoffExitStatus), + // Bounded retries: enough for a boot racing the network, few enough + // that a persistent failure stops instead of spinning. + "StartLimitIntervalSec=120", + "StartLimitBurst=5", + // The enable hook links default.target.wants — keep them in step. + "WantedBy=default.target", + } { + if !strings.Contains(content, want) { + t.Errorf("unit missing %q:\n%s", want, content) + } + } + if !autostartCommentMarked(content) { + t.Errorf("autostartCommentMarked() = false for a unit we just wrote") + } + if autostartCommentMarked("Description=Some user unit\nExecStart=/other\n") { + t.Errorf("autostartCommentMarked() = true for a foreign unit") + } + + if got := systemdStoredCommand(content); !strings.HasPrefix(got, "/usr/local/bin/multica daemon start") { + t.Errorf("systemdStoredCommand = %q, want the ExecStart command", got) + } +} + +func TestSystemdExecArgEscaping(t *testing.T) { + t.Parallel() + + cases := []struct{ in, want string }{ + {"daemon", "daemon"}, + // '%' is a systemd specifier and must be doubled everywhere. + {"/a%b", "/a%%b"}, + {"a b", `"a b"`}, + {"", `""`}, + {`a"b`, `"a\"b"`}, + } + for _, c := range cases { + if got := systemdExecArg(c.in); got != c.want { + t.Errorf("systemdExecArg(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestXdgAutostartContent(t *testing.T) { + t.Parallel() + + spec := autostartSpec{ + Exe: "/home/u/bin/multica", + Args: autostartArgs(""), + PathEnv: "/home/u/.local/bin:/usr/bin", + } + content := xdgAutostartContent(spec) + + for _, want := range []string{ + "[Desktop Entry]", + autostartManagedComment, + "Type=Application", + // Autostart only — it must not show up as an application. + "NoDisplay=true", + "Exec=env PATH=/home/u/.local/bin:/usr/bin /home/u/bin/multica daemon start --foreground", + "Terminal=false", + } { + if !strings.Contains(content, want) { + t.Errorf("desktop entry missing %q:\n%s", want, content) + } + } + if !autostartCommentMarked(content) { + t.Errorf("autostartCommentMarked() = false for a .desktop we just wrote") + } + + if got := xdgStoredCommand(content); !strings.HasPrefix(got, "env PATH=") { + t.Errorf("xdgStoredCommand = %q, want the Exec line", got) + } + + // '%' in PATH would be read as a desktop-entry field code unescaped. + spec.PathEnv = "/a%b" + content = xdgAutostartContent(spec) + if !strings.Contains(content, "PATH=/a%%b") { + t.Errorf("desktop entry must double '%%' in PATH:\n%s", content) + } +} + +// stubAutostartPlatform replaces the platform seams for the duration of the +// test so no registry / LaunchAgents / systemd state is touched. +type stubAutostartPlatform struct { + supported bool + refreshAllowed bool + write func(profile string, spec autostartSpec) (autostartState, bool, error) + remove func(profile string) (autostartState, bool, error) + read func(profile string) (autostartState, error) +} + +func stubPlatform(t *testing.T, s stubAutostartPlatform) { + t.Helper() + origSupported := autostartSupported + origWrite := writeAutostart + origRemove := removeAutostart + origRead := readAutostart + origRefreshAllowed := autostartRefreshAllowed + autostartSupported = func() bool { return s.supported } + autostartRefreshAllowed = func(string) bool { return s.refreshAllowed } + if s.write != nil { + writeAutostart = s.write + } + if s.remove != nil { + removeAutostart = s.remove + } + if s.read != nil { + readAutostart = s.read + } + t.Cleanup(func() { + autostartSupported = origSupported + writeAutostart = origWrite + removeAutostart = origRemove + readAutostart = origRead + autostartRefreshAllowed = origRefreshAllowed + }) +} + +func TestShouldManageAutostart(t *testing.T) { + t.Run("plain human starts are managed", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "") + stubPlatform(t, stubAutostartPlatform{supported: true}) + if !shouldManageAutostart() { + t.Fatal("shouldManageAutostart() = false, want true for a plain human start") + } + }) + + t.Run("manager-launched daemons are left to their manager", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "desktop") + stubPlatform(t, stubAutostartPlatform{supported: true}) + if shouldManageAutostart() { + t.Fatal("shouldManageAutostart() = true, want false when a manager owns the daemon") + } + }) + + t.Run("unsupported platform skips", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "") + stubPlatform(t, stubAutostartPlatform{supported: false}) + if shouldManageAutostart() { + t.Fatal("shouldManageAutostart() = true, want false on an unsupported platform") + } + }) +} + +// TestSyncDaemonAutostart pins the opt-in contract of `daemon start`: it may +// hint and it may heal an owned entry — it must never create one, and it +// must never touch a foreign file or refresh under an external supervisor. +func TestSyncDaemonAutostart(t *testing.T) { + t.Run("nothing registered prints the hint and writes nothing", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "") + writes := 0 + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{Mechanism: autostartMechanismWindowsRun}, nil + }, + write: func(string, autostartSpec) (autostartState, bool, error) { + writes++ + return autostartState{}, false, nil + }, + }) + sc := captureStderr(t) + syncDaemonAutostart("staging", true) + out := sc.read() + if writes != 0 { + t.Fatalf("writeAutostart calls = %d, want 0 — only 'enable' may create", writes) + } + if !strings.Contains(out, "multica daemon autostart enable") { + t.Errorf("stderr = %q, want the enable hint", out) + } + }) + + t.Run("unregistered but unannounced stays silent", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "") + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{}, nil + }, + }) + sc := captureStderr(t) + syncDaemonAutostart("staging", false) + if out := sc.read(); out != "" { + t.Errorf("stderr = %q, want silence when announce is false", out) + } + }) + + t.Run("owned entry is healed silently", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "") + var wroteArgs []string + stubPlatform(t, stubAutostartPlatform{ + supported: true, + refreshAllowed: true, + read: func(string) (autostartState, error) { + return autostartState{Enabled: true, Managed: true}, nil + }, + write: func(_ string, spec autostartSpec) (autostartState, bool, error) { + wroteArgs = spec.Args + return autostartState{Enabled: true, Managed: true}, false, nil + }, + }) + sc := captureStderr(t) + syncDaemonAutostart("staging", true) + out := sc.read() + if strings.Join(wroteArgs, " ") != "daemon start --foreground --profile staging" { + t.Fatalf("refresh args = %q, want the profile's foreground command", wroteArgs) + } + if out != "" { + t.Errorf("stderr = %q, a healing refresh must stay silent", out) + } + }) + + t.Run("external supervisor blocks the refresh", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "") + writes := 0 + stubPlatform(t, stubAutostartPlatform{ + supported: true, + refreshAllowed: false, + read: func(string) (autostartState, error) { + return autostartState{Enabled: true, Managed: true}, nil + }, + write: func(string, autostartSpec) (autostartState, bool, error) { + writes++ + return autostartState{}, false, nil + }, + }) + syncDaemonAutostart("staging", true) + if writes != 0 { + t.Fatalf("writeAutostart calls = %d, want 0 under an external supervisor", writes) + } + }) + + t.Run("foreign entry is never rewritten and gets no hint", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "") + writes := 0 + stubPlatform(t, stubAutostartPlatform{ + supported: true, + refreshAllowed: true, + read: func(string) (autostartState, error) { + return autostartState{Present: true, Enabled: true, Managed: false}, nil + }, + write: func(string, autostartSpec) (autostartState, bool, error) { + writes++ + return autostartState{}, false, nil + }, + }) + sc := captureStderr(t) + syncDaemonAutostart("staging", true) + out := sc.read() + if writes != 0 { + t.Fatalf("writeAutostart calls = %d, want 0 for a file Multica does not own", writes) + } + if out != "" { + t.Errorf("stderr = %q, want silence — it IS registered, just not ours", out) + } + }) + + t.Run("present but unlinked foreign entry stays silent too", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "") + writes := 0 + stubPlatform(t, stubAutostartPlatform{ + supported: true, + refreshAllowed: true, + read: func(string) (autostartState, error) { + // The review's Linux shape: file at our path, not linked, not ours. + return autostartState{Present: true, Enabled: false, Managed: false}, nil + }, + write: func(string, autostartSpec) (autostartState, bool, error) { + writes++ + return autostartState{}, false, nil + }, + }) + sc := captureStderr(t) + syncDaemonAutostart("staging", true) + out := sc.read() + if writes != 0 || out != "" { + t.Fatalf("writes=%d stderr=%q, want neither a rewrite nor an enable-hint for a foreign unlinked file", writes, out) + } + }) + + t.Run("owned but unlinked entry is left alone entirely", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "") + writes := 0 + stubPlatform(t, stubAutostartPlatform{ + supported: true, + refreshAllowed: true, + read: func(string) (autostartState, error) { + // What `autostart disable` leaves behind on Linux: the marked + // unit file, unlinked. A plain `daemon start` must neither + // re-link it (the user turned it off) nor nag them to. + return autostartState{Present: true, Enabled: false, Managed: true}, nil + }, + write: func(string, autostartSpec) (autostartState, bool, error) { + writes++ + return autostartState{}, false, nil + }, + }) + sc := captureStderr(t) + syncDaemonAutostart("staging", true) + out := sc.read() + if writes != 0 { + t.Fatalf("writeAutostart calls = %d, want 0 — refresh must not re-enable a disabled entry", writes) + } + if out != "" { + t.Errorf("stderr = %q, want silence — the hint is only for a never-registered profile", out) + } + }) + + t.Run("manager-launched daemons get nothing", func(t *testing.T) { + t.Setenv("MULTICA_LAUNCHED_BY", "desktop") + reads := 0 + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + reads++ + return autostartState{}, nil + }, + }) + sc := captureStderr(t) + syncDaemonAutostart("", true) + out := sc.read() + if reads != 0 || out != "" { + t.Fatalf("reads=%d stderr=%q, want no interaction at all", reads, out) + } + }) +} + +func TestRunDaemonAutostartEnableDisableStatus(t *testing.T) { + t.Run("enable registers and reports the PATH-only caveat", func(t *testing.T) { + mkProfiles(t, "staging") + var gotProfile string + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{Mechanism: autostartMechanismLaunchd}, nil + }, + write: func(profile string, spec autostartSpec) (autostartState, bool, error) { + gotProfile = profile + return autostartState{ + Enabled: true, + Managed: true, + Mechanism: autostartMechanismLaunchd, + Location: "/tmp/agent.plist", + }, true, nil + }, + }) + + sc := captureStderr(t) + err := runDaemonAutostartEnable(autostartCmdFor(t, "staging", ""), nil) + out := sc.read() + if err != nil { + t.Fatalf("runDaemonAutostartEnable = %v", err) + } + if gotProfile != "staging" { + t.Errorf("registered profile = %q, want staging", gotProfile) + } + for _, want := range []string{ + "Boot autostart enabled", "staging", "launchd LaunchAgent", "/tmp/agent.plist", + // The review's requirement: spell out what does NOT survive into + // the login session, at the moment of registration. + "only PATH is carried", + } { + if !strings.Contains(out, want) { + t.Errorf("enable output %q missing %q", out, want) + } + } + }) + + t.Run("enable is idempotent in its wording", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + // Pre-state: enabled and ours — enable changes nothing. + return autostartState{Present: true, Enabled: true, Managed: true, Mechanism: autostartMechanismWindowsRun}, nil + }, + write: func(string, autostartSpec) (autostartState, bool, error) { + return autostartState{Enabled: true, Managed: true, Mechanism: autostartMechanismWindowsRun}, false, nil + }, + }) + sc := captureStderr(t) + err := runDaemonAutostartEnable(autostartCmdFor(t, "", ""), nil) + out := sc.read() + if err != nil { + t.Fatalf("runDaemonAutostartEnable = %v", err) + } + if !strings.Contains(out, "already enabled") { + t.Errorf("enable output = %q, want 'already enabled' when nothing changed", out) + } + }) + + // Linux disable keeps the (unlinked) unit file, so re-enabling after a + // disable often rewrites no content — the change is the wants link + // coming back. Claiming "already enabled" there would describe the state + // the user just moved out of. + t.Run("enable says enabled when re-linking a disabled registration", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{Present: true, Enabled: false, Managed: true, Mechanism: autostartMechanismSystemd}, nil + }, + write: func(string, autostartSpec) (autostartState, bool, error) { + // Content identical — only the link changes. + return autostartState{Present: true, Enabled: true, Managed: true, Mechanism: autostartMechanismSystemd}, false, nil + }, + }) + sc := captureStderr(t) + err := runDaemonAutostartEnable(autostartCmdFor(t, "", ""), nil) + out := sc.read() + if err != nil { + t.Fatalf("runDaemonAutostartEnable = %v", err) + } + if strings.Contains(out, "already enabled") || !strings.Contains(out, "Boot autostart enabled") { + t.Errorf("enable output = %q, want 'enabled' (not 'already enabled') when re-linking", out) + } + }) + + t.Run("enable refuses to overwrite a file Multica does not own", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{ + Present: true, + Enabled: true, + Managed: false, + Mechanism: autostartMechanismSystemd, + Location: "/home/u/.config/systemd/user/multica-daemon.service", + }, nil + }, + write: func(string, autostartSpec) (autostartState, bool, error) { + t.Fatal("writeAutostart must not run for an unmanaged file") + return autostartState{}, false, nil + }, + }) + err := runDaemonAutostartEnable(autostartCmdFor(t, "", ""), nil) + if err == nil || !strings.Contains(err.Error(), "not created by Multica") { + t.Fatalf("runDaemonAutostartEnable = %v, want a refusal naming the foreign file", err) + } + }) + + t.Run("enable rejects an unknown profile", func(t *testing.T) { + mkProfiles(t, "known") + stubPlatform(t, stubAutostartPlatform{ + supported: true, + write: func(string, autostartSpec) (autostartState, bool, error) { + t.Fatal("writeAutostart must not run for an unknown profile") + return autostartState{}, false, nil + }, + }) + err := runDaemonAutostartEnable(autostartCmdFor(t, "nope", ""), nil) + var unknown *unknownProfileError + if !errors.As(err, &unknown) { + t.Fatalf("runDaemonAutostartEnable = %v, want an unknown-profile error", err) + } + }) + + t.Run("disable removes and reports", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{Enabled: true, Managed: true, Mechanism: autostartMechanismSystemd}, nil + }, + remove: func(string) (autostartState, bool, error) { + return autostartState{Mechanism: autostartMechanismSystemd}, true, nil + }, + }) + sc := captureStderr(t) + err := runDaemonAutostartDisable(autostartCmdFor(t, "", ""), nil) + out := sc.read() + if err != nil { + t.Fatalf("runDaemonAutostartDisable = %v", err) + } + if !strings.Contains(out, "Boot autostart disabled") { + t.Errorf("disable output = %q, want the disabled confirmation", out) + } + }) + + t.Run("disable refuses to remove a file Multica does not own", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{ + Present: true, + Enabled: true, + Managed: false, + Mechanism: autostartMechanismSystemd, + Location: "/home/u/.config/systemd/user/multica-daemon.service", + }, nil + }, + remove: func(string) (autostartState, bool, error) { + t.Fatal("removeAutostart must not run for an unmanaged file") + return autostartState{}, false, nil + }, + }) + err := runDaemonAutostartDisable(autostartCmdFor(t, "", ""), nil) + if err == nil || !strings.Contains(err.Error(), "not created by Multica") { + t.Fatalf("runDaemonAutostartDisable = %v, want a refusal naming the foreign file", err) + } + }) + + // The review's Linux gap: Enabled only means "linked in + // default.target.wants", so a hand-written unit that is not enabled (or + // enabled under another target) reads disabled — guarding on Enabled let + // `disable` delete it. Presence, not enablement, must drive the refusal. + t.Run("disable refuses an unlinked file Multica does not own", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{ + Present: true, + Enabled: false, + Managed: false, + Mechanism: autostartMechanismSystemd, + Location: "/home/u/.config/systemd/user/multica-daemon.service", + }, nil + }, + remove: func(string) (autostartState, bool, error) { + t.Fatal("removeAutostart must not run for an unmanaged, unlinked file") + return autostartState{}, false, nil + }, + }) + err := runDaemonAutostartDisable(autostartCmdFor(t, "", ""), nil) + if err == nil || !strings.Contains(err.Error(), "not created by Multica") { + t.Fatalf("runDaemonAutostartDisable = %v, want a refusal for the present-but-unlinked foreign file", err) + } + }) + + t.Run("disable of a never-enabled profile says so without failing", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{Mechanism: autostartMechanismWindowsRun}, nil + }, + remove: func(string) (autostartState, bool, error) { + return autostartState{Mechanism: autostartMechanismWindowsRun}, false, nil + }, + }) + sc := captureStderr(t) + err := runDaemonAutostartDisable(autostartCmdFor(t, "", ""), nil) + out := sc.read() + if err != nil { + t.Fatalf("runDaemonAutostartDisable = %v, want nil for an idempotent cleanup", err) + } + if !strings.Contains(out, "not enabled") { + t.Errorf("disable output = %q, want 'not enabled'", out) + } + }) + + t.Run("status renders json", func(t *testing.T) { + mkProfiles(t, "staging") + stubPlatform(t, stubAutostartPlatform{ + read: func(string) (autostartState, error) { + return autostartState{ + Enabled: true, + Managed: true, + Mechanism: autostartMechanismSystemd, + Location: "/home/u/.config/systemd/user/multica-daemon-staging.service", + Command: "/usr/local/bin/multica daemon start --foreground --profile staging", + }, nil + }, + }) + out, err := captureStdout(t, func() error { + return runDaemonAutostartStatus(daemonStatusCmdFor(t, "staging", "json"), nil) + }) + if err != nil { + t.Fatalf("runDaemonAutostartStatus = %v", err) + } + // PrintJSON pretty-prints with a space after the colon; assert on + // the rendered shape rather than a compact encoding we do not own. + for _, want := range []string{`"profile": "staging"`, `"enabled": true`, `"mechanism": "systemd"`, `"command": "`} { + if !strings.Contains(out, want) { + t.Errorf("status json = %s, want it to contain %s", out, want) + } + } + }) + + t.Run("status renders table", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{ + Enabled: true, + Managed: true, + Mechanism: autostartMechanismWindowsRun, + Location: `HKCU\Run\Multica`, + }, nil + }, + }) + out, err := captureStdout(t, func() error { + return runDaemonAutostartStatus(daemonStatusCmdFor(t, "", ""), nil) + }) + if err != nil { + t.Fatalf("runDaemonAutostartStatus = %v", err) + } + for _, want := range []string{"Profile", "enabled", "Windows Run key", `HKCU\Run\Multica`} { + if !strings.Contains(out, want) { + t.Errorf("status table = %q, want it to contain %q", out, want) + } + } + }) + + t.Run("status flags an entry Multica does not own", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{ + Present: true, + Enabled: true, + Managed: false, + Mechanism: autostartMechanismSystemd, + Location: "/home/u/.config/systemd/user/multica-daemon.service", + }, nil + }, + }) + out, err := captureStdout(t, func() error { + return runDaemonAutostartStatus(daemonStatusCmdFor(t, "", ""), nil) + }) + if err != nil { + t.Fatalf("runDaemonAutostartStatus = %v", err) + } + if !strings.Contains(out, "not created by Multica") { + t.Errorf("status = %q, want it to flag the unmanaged entry", out) + } + }) + + // Present-but-unlinked (or linked under another target): status must + // still say whose file it is even though the verdict line says disabled. + t.Run("status flags an unlinked entry Multica does not own", func(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{ + supported: true, + read: func(string) (autostartState, error) { + return autostartState{ + Present: true, + Enabled: false, + Managed: false, + Mechanism: autostartMechanismSystemd, + Location: "/home/u/.config/systemd/user/multica-daemon.service", + }, nil + }, + }) + out, err := captureStdout(t, func() error { + return runDaemonAutostartStatus(daemonStatusCmdFor(t, "", ""), nil) + }) + if err != nil { + t.Fatalf("runDaemonAutostartStatus = %v", err) + } + if !strings.Contains(out, "disabled") { + t.Errorf("status = %q, want the enabled/disabled verdict to stay disabled", out) + } + if !strings.Contains(out, "not created by Multica") { + t.Errorf("status = %q, want it to flag the present-but-unlinked unmanaged entry", out) + } + }) +} + +func TestAutostartCommandsRejectUnsupportedPlatform(t *testing.T) { + mkProfiles(t) + stubPlatform(t, stubAutostartPlatform{supported: false}) + + for name, run := range map[string]func(*cobra.Command, []string) error{ + "enable": runDaemonAutostartEnable, + "disable": runDaemonAutostartDisable, + } { + err := run(autostartCmdFor(t, "", ""), nil) + if err == nil || !strings.Contains(err.Error(), "not supported") { + t.Errorf("autostart %s on an unsupported platform = %v, want a clear refusal", name, err) + } + } +} + +// TestAutostartSpecForUsesStableBrewPath pins the review's Linuxbrew failure +// mode end to end: os.Executable() reports the versioned keg path on brew +// installs, `brew upgrade` cleans the old keg, and an autostart entry +// recorded with that path would restart a deleted binary (systemd 203/EXEC) +// or point at a missing file at the next login. The spec must carry the +// stable /bin/multica symlink instead — resolved through the same +// shared helper as the daemon's restart target so the two cannot drift. +func TestAutostartSpecForUsesStableBrewPath(t *testing.T) { + orig := daemonExecutable + daemonExecutable = func() (string, error) { + return "/opt/homebrew/Cellar/multica/0.4.33/bin/multica", nil + } + t.Cleanup(func() { daemonExecutable = orig }) + + spec, err := autostartSpecFor("") + if err != nil { + t.Fatalf("autostartSpecFor = %v", err) + } + want := filepath.Join("/opt/homebrew", "bin", "multica") + if spec.Exe != want { + t.Fatalf("autostartSpecFor exe = %q, want the stable brew symlink path %q — a recorded keg path is deleted by the next brew upgrade", + spec.Exe, want) + } + if got, want := strings.Join(spec.Args, " "), "daemon start --foreground"; got != want { + t.Errorf("autostartSpecFor args = %q, want %q", got, want) + } +} diff --git a/server/cmd/multica/cmd_daemon_autostart_windows.go b/server/cmd/multica/cmd_daemon_autostart_windows.go new file mode 100644 index 00000000000..657aec2d1af --- /dev/null +++ b/server/cmd/multica/cmd_daemon_autostart_windows.go @@ -0,0 +1,125 @@ +//go:build windows + +package main + +import ( + "errors" + + "golang.org/x/sys/windows/registry" +) + +// autostartRunKeyPath is the per-user Run key. It is a variable purely so a +// test can point registration at a throwaway subkey instead of writing the +// developer's real login entries. +var autostartRunKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` + +func platformAutostartSupported() bool { return true } + +// platformAutostartRefreshAllowed is unconditional on Windows: a Run-key +// entry is not supervised by anything that could object to a rewrite, and +// ownership is the value name itself (see windowsRunValueName). +func platformAutostartRefreshAllowed(string) bool { return true } + +// platformDaemonUnderOwnSystemdUnit is always false outside Linux: there is +// no systemd to hand a binary-update restart over to. +func platformDaemonUnderOwnSystemdUnit(string) bool { return false } + +// platformWriteAutostart sets the profile's Run value. The HKCU hive needs +// no elevation, and the value is rewritten only when the command line +// actually changed — a no-op refresh on every `daemon start`. +func platformWriteAutostart(profile string, spec autostartSpec) (autostartState, bool, error) { + name := windowsRunValueName(profile) + command := windowsAutostartCommand(spec) + + // QUERY_VALUE rides along with SET_VALUE because the idempotence check + // below reads the current value through this same handle — a handle + // opened write-only denies GetStringValue with ERROR_ACCESS_DENIED. + key, _, err := registry.CreateKey(registry.CURRENT_USER, autostartRunKeyPath, registry.SET_VALUE|registry.QUERY_VALUE) + if err != nil { + return autostartState{}, false, err + } + defer key.Close() + + previous, _, err := key.GetStringValue(name) + changed := errors.Is(err, registry.ErrNotExist) || previous != command + if err != nil && !errors.Is(err, registry.ErrNotExist) { + return autostartState{}, false, err + } + if changed { + if err := key.SetStringValue(name, command); err != nil { + return autostartState{}, false, err + } + } + return autostartState{ + Present: true, // a Run value either exists (and runs at login) or not + Enabled: true, + Managed: true, // the value name is ours alone; see windowsRunValueName + Mechanism: autostartMechanismWindowsRun, + Location: `HKCU\` + autostartRunKeyPath + `\` + name, + Command: command, + }, changed, nil +} + +// platformRemoveAutostart deletes the profile's Run value. Removing a value +// that is not there reports changed=false so `disable` can say "not enabled" +// rather than erroring on an idempotent cleanup. +func platformRemoveAutostart(profile string) (autostartState, bool, error) { + name := windowsRunValueName(profile) + + key, err := registry.OpenKey(registry.CURRENT_USER, autostartRunKeyPath, registry.SET_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return autostartState{Mechanism: autostartMechanismWindowsRun}, false, nil + } + return autostartState{}, false, err + } + defer key.Close() + + err = key.DeleteValue(name) + if errors.Is(err, registry.ErrNotExist) { + return autostartState{ + Mechanism: autostartMechanismWindowsRun, + Location: `HKCU\` + autostartRunKeyPath + `\` + name, + }, false, nil + } + if err != nil { + return autostartState{}, false, err + } + return autostartState{ + Mechanism: autostartMechanismWindowsRun, + Location: `HKCU\` + autostartRunKeyPath + `\` + name, + }, true, nil +} + +// platformReadAutostart reports the Run value. A missing key or value reads +// as "disabled" (with the location `enable` would use), not as an error — +// status must answer for a machine that never registered anything. +func platformReadAutostart(profile string) (autostartState, error) { + name := windowsRunValueName(profile) + location := `HKCU\` + autostartRunKeyPath + `\` + name + + key, err := registry.OpenKey(registry.CURRENT_USER, autostartRunKeyPath, registry.QUERY_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return autostartState{Mechanism: autostartMechanismWindowsRun, Location: location}, nil + } + return autostartState{}, err + } + defer key.Close() + + command, _, err := key.GetStringValue(name) + if errors.Is(err, registry.ErrNotExist) { + return autostartState{Mechanism: autostartMechanismWindowsRun, Location: location}, nil + } + if err != nil { + return autostartState{}, err + } + return autostartState{ + Present: true, // a Run value either exists (and runs at login) or not + Enabled: true, + Managed: true, // the value name is ours alone; see windowsRunValueName + Mechanism: autostartMechanismWindowsRun, + Location: location, + Command: command, + }, nil +} diff --git a/server/cmd/multica/cmd_daemon_autostart_windows_test.go b/server/cmd/multica/cmd_daemon_autostart_windows_test.go new file mode 100644 index 00000000000..63f371232ce --- /dev/null +++ b/server/cmd/multica/cmd_daemon_autostart_windows_test.go @@ -0,0 +1,107 @@ +//go:build windows + +package main + +import ( + "strings" + "testing" + + "golang.org/x/sys/windows/registry" +) + +// TestWindowsAutostartRegistryRoundTrip exercises the real HKCU Run +// mechanism against a throwaway key. It calls the platform functions +// directly (not the writeAutostart seam) so other tests' stubs cannot leak +// in, and deletes the key afterwards — Windows-only, so it runs on the +// developer's machine but not in the ubuntu CI job; the shared command/ +// quoting logic is covered platform-independently by the untagged tests. +func TestWindowsAutostartRegistryRoundTrip(t *testing.T) { + origPath := autostartRunKeyPath + testKey := "Software\\MulticaAutostartRoundTripTest" + autostartRunKeyPath = testKey + t.Cleanup(func() { + autostartRunKeyPath = origPath + _ = registry.DeleteKey(registry.CURRENT_USER, testKey) + }) + // Start from a clean slate if a previous run left the key behind. + // DeleteKey removes a key together with its values; only subkeys would + // block it, and this test creates none. + _ = registry.DeleteKey(registry.CURRENT_USER, testKey) + + spec := autostartSpec{ + Exe: `C:\Program Files\multica\multica.exe`, + Args: autostartArgs("staging"), + } + + // First registration reports a change and lands in the registry. + state, changed, err := platformWriteAutostart("staging", spec) + if err != nil { + t.Fatalf("platformWriteAutostart = %v", err) + } + if !changed || !state.Enabled { + t.Fatalf("first write: changed=%v enabled=%v, want true/true", changed, state.Enabled) + } + if state.Mechanism != autostartMechanismWindowsRun { + t.Errorf("mechanism = %q, want %q", state.Mechanism, autostartMechanismWindowsRun) + } + if !strings.Contains(state.Location, `Multica (staging)`) { + t.Errorf("location = %q, want it to name the profile's value", state.Location) + } + wantCommand := windowsAutostartCommand(spec) + if state.Command != wantCommand { + t.Errorf("command = %q, want %q", state.Command, wantCommand) + } + + // Refreshing with the same spec is a no-op — the property that keeps + // every `daemon start` after the first read-only. + if _, changed, err := platformWriteAutostart("staging", spec); err != nil || changed { + t.Fatalf("identical rewrite: changed=%v err=%v, want a no-op", changed, err) + } + + // status reads back exactly what was stored. + read, err := platformReadAutostart("staging") + if err != nil { + t.Fatalf("platformReadAutostart = %v", err) + } + if !read.Enabled || read.Command != wantCommand { + t.Fatalf("read = %+v, want enabled with command %q", read, wantCommand) + } + + // A moved binary rewrites the value (the self-update heal path). + moved := spec + moved.Exe = `D:\tools\multica.exe` + if _, changed, err := platformWriteAutostart("staging", moved); err != nil || !changed { + t.Fatalf("path change: changed=%v err=%v, want a rewrite", changed, err) + } + read, _ = platformReadAutostart("staging") + if !strings.HasPrefix(read.Command, `D:\tools\multica.exe `) { + t.Errorf("command after move = %q, want the new executable", read.Command) + } + + // A different profile owns a different value — registrations coexist. + if _, _, err := platformWriteAutostart("", autostartSpec{Exe: moved.Exe, Args: autostartArgs("")}); err != nil { + t.Fatalf("write default profile: %v", err) + } + if _, changed, err := platformRemoveAutostart("staging"); err != nil || !changed { + t.Fatalf("remove staging: changed=%v err=%v, want true", changed, err) + } + if read, err := platformReadAutostart("staging"); err != nil || read.Enabled { + t.Fatalf("staging after remove: enabled=%v err=%v, want disabled", read.Enabled, err) + } + // Removing the default profile's value must not have been affected by + // staging's removal: it is still there. + if read, err := platformReadAutostart(""); err != nil || !read.Enabled { + t.Fatalf("default profile after removing staging: enabled=%v err=%v, want still enabled", read.Enabled, err) + } + + // Removal is idempotent: the second call finds nothing to remove. + if _, changed, err := platformRemoveAutostart("staging"); err != nil || changed { + t.Fatalf("second remove: changed=%v err=%v, want false/nil", changed, err) + } + + // Cleanup of the default profile's value (the key itself is removed by + // the outer cleanup). + if _, _, err := platformRemoveAutostart(""); err != nil { + t.Fatalf("remove default profile: %v", err) + } +} diff --git a/server/cmd/multica/cmd_daemon_diskusage_status_test.go b/server/cmd/multica/cmd_daemon_diskusage_status_test.go index d5cc9861a1d..adbecf7d442 100644 --- a/server/cmd/multica/cmd_daemon_diskusage_status_test.go +++ b/server/cmd/multica/cmd_daemon_diskusage_status_test.go @@ -175,7 +175,7 @@ func TestDiskUsageNeedsParentStatus(t *testing.T) { func TestRunDaemonDiskUsageByWorkspaceTableMakesNoRequest(t *testing.T) { pinHumanCLIContext(t) home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") t.Setenv("MULTICA_SERVER_URL", "") @@ -206,7 +206,7 @@ func TestRunDaemonDiskUsageByWorkspaceTableMakesNoRequest(t *testing.T) { func TestRunDaemonDiskUsageTaskTableResolvesStatus(t *testing.T) { pinHumanCLIContext(t) home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") t.Setenv("MULTICA_SERVER_URL", "") @@ -233,7 +233,7 @@ func TestRunDaemonDiskUsageTaskTableResolvesStatus(t *testing.T) { func TestRunDaemonDiskUsageJSONSurvivesServerFailure(t *testing.T) { pinHumanCLIContext(t) home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") t.Setenv("MULTICA_SERVER_URL", "") @@ -278,7 +278,7 @@ func TestRunDaemonDiskUsageJSONSurvivesServerFailure(t *testing.T) { func TestRunDaemonDiskUsageAllProfilesUsesPerProfileToken(t *testing.T) { pinHumanCLIContext(t) home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") t.Setenv("MULTICA_SERVER_URL", "") @@ -376,7 +376,7 @@ func TestPrintRepoCacheLineSilentWhenEmpty(t *testing.T) { func setupTaskDiskUsageContext(t *testing.T, home, ownerServerURL string) string { t.Helper() t.Chdir(t.TempDir()) - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") t.Setenv("MULTICA_SERVER_URL", "") @@ -482,7 +482,7 @@ func TestResolveDiskUsageRootTaskContext(t *testing.T) { t.Run("outside a task keeps profile resolution", func(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") t.Setenv(daemon.TaskWorkspacesRootEnv, filepath.Join(t.TempDir(), "ignored")) @@ -500,7 +500,7 @@ func TestRunDaemonDiskUsageHonorsProfileWorkspacesRoot(t *testing.T) { pinHumanCLIContext(t) home := t.TempDir() customRoot := filepath.Join(t.TempDir(), "configured-workspaces") - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") if err := cli.SaveCLIConfig(cli.CLIConfig{WorkspacesRoot: customRoot}); err != nil { t.Fatalf("SaveCLIConfig: %v", err) @@ -528,7 +528,7 @@ func TestResolveDiskUsageRootEnvOverridesProfileConfig(t *testing.T) { home := t.TempDir() configRoot := filepath.Join(t.TempDir(), "configured-workspaces") envRoot := filepath.Join(t.TempDir(), "env-workspaces") - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", envRoot) if err := cli.SaveCLIConfig(cli.CLIConfig{WorkspacesRoot: configRoot}); err != nil { t.Fatalf("SaveCLIConfig: %v", err) @@ -550,7 +550,7 @@ func TestEnumerateDiskUsageRootsUsesAndDeduplicatesProfileConfig(t *testing.T) { sharedRoot := filepath.Join(t.TempDir(), "shared-root") uniqueRoot := filepath.Join(t.TempDir(), "unique-root") neverRanRoot := filepath.Join(t.TempDir(), "never-ran-root") - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") configs := []struct { diff --git a/server/cmd/multica/cmd_daemon_precedence_test.go b/server/cmd/multica/cmd_daemon_precedence_test.go index 68f6e397592..60fe2a63021 100644 --- a/server/cmd/multica/cmd_daemon_precedence_test.go +++ b/server/cmd/multica/cmd_daemon_precedence_test.go @@ -52,8 +52,7 @@ func TestResolveDaemonWorkspacesRootPrecedence(t *testing.T) { flagRoot := filepath.Join(t.TempDir(), "flag") envRoot := filepath.Join(t.TempDir(), "env") configRoot := filepath.Join(t.TempDir(), "config") - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) + redirectTestHome(t, home) if err := cli.SaveCLIConfigForProfile(cli.CLIConfig{WorkspacesRoot: configRoot}, "dev"); err != nil { t.Fatalf("SaveCLIConfigForProfile: %v", err) } diff --git a/server/cmd/multica/cmd_daemon_test.go b/server/cmd/multica/cmd_daemon_test.go index be016bc4bf3..72019f8cabc 100644 --- a/server/cmd/multica/cmd_daemon_test.go +++ b/server/cmd/multica/cmd_daemon_test.go @@ -56,11 +56,14 @@ func TestDaemonLocalCommandsFailClosedInTaskContext(t *testing.T) { t.Setenv("MULTICA_TASK_CONFIG_ROOT", filepath.Join(t.TempDir(), "task-multica")) cases := map[string]func() error{ - "probe-runtimes": func() error { return runDaemonProbeRuntimes(daemonProbeRuntimesCmd, nil) }, - "start": func() error { return runDaemonStart(daemonStartCmd, nil) }, - "restart": func() error { return runDaemonRestart(daemonRestartCmd, nil) }, - "stop": func() error { return runDaemonStop(daemonStopCmd, nil) }, - "logs": func() error { return runDaemonLogs(daemonLogsCmd, nil) }, + "probe-runtimes": func() error { return runDaemonProbeRuntimes(daemonProbeRuntimesCmd, nil) }, + "start": func() error { return runDaemonStart(daemonStartCmd, nil) }, + "restart": func() error { return runDaemonRestart(daemonRestartCmd, nil) }, + "stop": func() error { return runDaemonStop(daemonStopCmd, nil) }, + "logs": func() error { return runDaemonLogs(daemonLogsCmd, nil) }, + "autostart enable": func() error { return runDaemonAutostartEnable(daemonAutostartEnableCmd, nil) }, + "autostart disable": func() error { return runDaemonAutostartDisable(daemonAutostartDisableCmd, nil) }, + "autostart status": func() error { return runDaemonAutostartStatus(daemonAutostartStatusCmd, nil) }, } for name, run := range cases { err := run() @@ -219,7 +222,7 @@ func TestPrintDaemonStatusOmitsVersionWhenMissing(t *testing.T) { // already died with "not authenticated". func TestRequireDaemonAuth(t *testing.T) { t.Run("not logged in", func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) err := requireDaemonAuth("") if err == nil || !strings.Contains(err.Error(), "multica login") { t.Fatalf("requireDaemonAuth() = %v, want error mentioning 'multica login'", err) @@ -227,7 +230,7 @@ func TestRequireDaemonAuth(t *testing.T) { }) t.Run("not logged in with profile", func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) err := requireDaemonAuth("staging") if err == nil || !strings.Contains(err.Error(), "multica login --profile staging") { t.Fatalf("requireDaemonAuth(staging) = %v, want error mentioning profile login hint", err) @@ -235,7 +238,7 @@ func TestRequireDaemonAuth(t *testing.T) { }) t.Run("authenticated", func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) if err := cli.SaveCLIConfig(cli.CLIConfig{Token: "mul_test_token"}); err != nil { t.Fatalf("SaveCLIConfig: %v", err) } @@ -249,7 +252,19 @@ func TestRequireDaemonAuth(t *testing.T) { // `daemon start` background path: without a stored token it must error out // before spawning the child (and long before the 45s readiness wait). func TestDaemonStartBackgroundUnauthenticatedFailsFast(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + // Run from a scratch directory: the human-local-command guard walks UP + // from the CWD for a daemon-task marker, and this suite may run inside a + // real task workdir — mkProfiles makes the same move for the same reason. + t.Chdir(t.TempDir()) + redirectTestHome(t, t.TempDir()) + + // A start that cannot succeed must not reach the autostart hook at all: + // there is nothing to hint about or refresh when the start dies on auth. + // The seam keeps the assertion off the real registry / LaunchAgents. + origSync := syncDaemonAutostart + autostartCalls := 0 + syncDaemonAutostart = func(string, bool) { autostartCalls++ } + t.Cleanup(func() { syncDaemonAutostart = origSync }) cmd := &cobra.Command{Use: "start"} cmd.Flags().Bool("foreground", false, "") @@ -271,6 +286,9 @@ func TestDaemonStartBackgroundUnauthenticatedFailsFast(t *testing.T) { if elapsed > 10*time.Second { t.Fatalf("runDaemonStart took %s, want fail-fast before the readiness wait", elapsed) } + if autostartCalls != 0 { + t.Fatalf("syncDaemonAutostart called %d times before login, want 0", autostartCalls) + } } // TestReadLogTailSince pins the log-excerpt helper used when the daemon child @@ -418,7 +436,11 @@ list workspaces: GET /api/workspaces returned 401: {"error":"invalid token"} // The spawned child is stubbed to `false` via daemonExecutable so it dies // immediately with a non-zero status, the same shape as a failed preflight. func TestDaemonStartBackgroundReportsEarlyChildExit(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + // Out of the marker walk first (see the unauthenticated test above); the + // home redirect keeps the profile config this test writes inside the + // scratch home instead of the real one on Windows. + t.Chdir(t.TempDir()) + redirectTestHome(t, t.TempDir()) falseBin, err := exec.LookPath("false") if err != nil { @@ -428,6 +450,16 @@ func TestDaemonStartBackgroundReportsEarlyChildExit(t *testing.T) { daemonExecutable = func() (string, error) { return falseBin, nil } t.Cleanup(func() { daemonExecutable = orig }) + // An authenticated start reaches the autostart hook (hint / owned-entry + // refresh) — seammed so this test never touches the machine's real Run + // key / LaunchAgents / user units. + origSync := syncDaemonAutostart + synced := make([]string, 0, 1) + syncDaemonAutostart = func(profile string, _ bool) { + synced = append(synced, profile) + } + t.Cleanup(func() { syncDaemonAutostart = origSync }) + const profile = "child-exit-test" if err := cli.SaveCLIConfigForProfile(cli.CLIConfig{Token: "mul_fake"}, profile); err != nil { t.Fatalf("SaveCLIConfigForProfile: %v", err) @@ -451,6 +483,9 @@ func TestDaemonStartBackgroundReportsEarlyChildExit(t *testing.T) { if elapsed > 15*time.Second { t.Fatalf("runDaemonStart took %s, want early-exit detection well before the 45s readiness window", elapsed) } + if len(synced) != 1 || synced[0] != profile { + t.Fatalf("syncDaemonAutostart calls = %q, want exactly one for profile %q", synced, profile) + } } // TestDaemonRestartUnauthenticatedFailsBeforeStopping pins the ordering that @@ -459,7 +494,7 @@ func TestDaemonStartBackgroundReportsEarlyChildExit(t *testing.T) { // and only then discovers it cannot start a replacement, leaving the user // with no daemon at all. func TestDaemonRestartUnauthenticatedFailsBeforeStopping(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) const profile = "restart-authtest" @@ -522,7 +557,7 @@ func newRestartTestCmd(t *testing.T, profile string) *cobra.Command { // running daemon is stopped. Otherwise restart kills the working daemon and // the replacement child dies in preflight, leaving no daemon at all (#5165). func TestDaemonRestartRejectedTokenFailsBeforeStopping(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", "") const profile = "restart-401test" @@ -557,7 +592,7 @@ func TestDaemonRestartRejectedTokenFailsBeforeStopping(t *testing.T) { // restart must abort before stopping the running daemon, because the // replacement child would die in preflight against the same dead server. func TestDaemonRestartUnreachableServerFailsBeforeStopping(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", "") const profile = "restart-unreachable-test" @@ -624,7 +659,7 @@ func TestPrintDaemonStatusAlignsValuesWithProfileLabel(t *testing.T) { func TestPrintDiskUsageOtherRootsHintSuggestsProfilesWithTasks(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") mkdirProfile(t, home, "empty") @@ -672,7 +707,7 @@ func TestPrintDiskUsageOtherRootsHintSuggestsProfilesWithTasks(t *testing.T) { // a non-empty default root. func TestPrintDiskUsageOtherRootsHintFiresWhenCurrentRootNonEmpty(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") mkdirProfile(t, home, "desktop-host") @@ -692,7 +727,7 @@ func TestPrintDiskUsageOtherRootsHintFiresWhenCurrentRootNonEmpty(t *testing.T) func TestPrintDiskUsageOtherRootsHintSuggestsDefaultFromNamedProfile(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") writeDefaultDiskUsageTaskFile(t, home, "ws0", "task0", "workdir/main.go") @@ -711,7 +746,7 @@ func TestPrintDiskUsageOtherRootsHintSuggestsDefaultFromNamedProfile(t *testing. func TestPrintDiskUsageOtherRootsHintUsesProfileConfig(t *testing.T) { home := t.TempDir() customRoot := filepath.Join(t.TempDir(), "custom-profile-root") - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") if err := cli.SaveCLIConfigForProfile(cli.CLIConfig{WorkspacesRoot: customRoot}, "custom"); err != nil { t.Fatalf("SaveCLIConfigForProfile: %v", err) @@ -734,7 +769,7 @@ func TestPrintDiskUsageOtherRootsHintUsesProfileConfig(t *testing.T) { func TestPrintDiskUsageOtherRootsHintSkipsExplicitRootOverride(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") mkdirProfile(t, home, "has-task") @@ -752,7 +787,7 @@ func TestPrintDiskUsageOtherRootsHintSkipsExplicitRootOverride(t *testing.T) { func TestEnumerateDiskUsageRoots(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) t.Setenv("MULTICA_WORKSPACES_ROOT", "") // Two profiles configured under ~/.multica/profiles, but only one has its diff --git a/server/cmd/multica/cmd_daemon_unknown_profile_test.go b/server/cmd/multica/cmd_daemon_unknown_profile_test.go index 7d4b8b8ad22..543a13b4f2c 100644 --- a/server/cmd/multica/cmd_daemon_unknown_profile_test.go +++ b/server/cmd/multica/cmd_daemon_unknown_profile_test.go @@ -22,7 +22,7 @@ func mkProfiles(t *testing.T, names ...string) string { t.Helper() t.Chdir(t.TempDir()) home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) for _, name := range names { dir := filepath.Join(home, ".multica", "profiles", filepath.FromSlash(name)) if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/server/cmd/multica/cmd_issue_test.go b/server/cmd/multica/cmd_issue_test.go index dbd8b69d314..c751ddff2af 100644 --- a/server/cmd/multica/cmd_issue_test.go +++ b/server/cmd/multica/cmd_issue_test.go @@ -4043,7 +4043,7 @@ func TestRunIssueAssignRejectsNoStartWithUnassign(t *testing.T) { func TestIssueReadCommandsUseInjectedTaskToken(t *testing.T) { const fakeTaskToken = "mat_task_issue_sentinel" ownerHome := t.TempDir() - t.Setenv("HOME", ownerHome) + redirectTestHome(t, ownerHome) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TOKEN", fakeTaskToken) @@ -4109,7 +4109,7 @@ func TestIssueReadCommandsUseInjectedTaskToken(t *testing.T) { func TestIssueReadCommandsFailClosedWithoutTaskToken(t *testing.T) { ownerHome := t.TempDir() - t.Setenv("HOME", ownerHome) + redirectTestHome(t, ownerHome) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TOKEN", "") diff --git a/server/cmd/multica/cmd_login_test.go b/server/cmd/multica/cmd_login_test.go index 59c3860b521..936cfc7c682 100644 --- a/server/cmd/multica/cmd_login_test.go +++ b/server/cmd/multica/cmd_login_test.go @@ -20,7 +20,7 @@ func newLoginTestCmd() *cobra.Command { } func TestResolveLoginTokenServerURLDefaultsToCloud(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", "") if got := resolveLoginTokenServerURL(newLoginTestCmd()); got != defaultCloudServerURL { @@ -30,7 +30,7 @@ func TestResolveLoginTokenServerURLDefaultsToCloud(t *testing.T) { func TestResolveLoginTokenServerURLPrefersConfiguredServer(t *testing.T) { t.Chdir(t.TempDir()) - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", "") // A stale host/container value is not proof that this process is running // inside a daemon task. Login still needs the selected human profile. @@ -53,7 +53,7 @@ func TestResolveLoginTokenServerURLPrefersConfiguredServer(t *testing.T) { func TestRunLoginTokenAutoWatchesDiscoveredWorkspaces(t *testing.T) { t.Chdir(t.TempDir()) - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "") t.Setenv("MULTICA_WORKSPACE_ID", "") // Regression for #6779: older container setups may leave this daemon- diff --git a/server/cmd/multica/cmd_runtime_profile_test.go b/server/cmd/multica/cmd_runtime_profile_test.go index a42ac5ef728..21ae75bae1b 100644 --- a/server/cmd/multica/cmd_runtime_profile_test.go +++ b/server/cmd/multica/cmd_runtime_profile_test.go @@ -74,7 +74,7 @@ func newProfileUnsetPathTestCmd() *cobra.Command { } func TestRunRuntimeProfileList(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "ws-123") @@ -106,7 +106,7 @@ func TestRunRuntimeProfileList(t *testing.T) { } func TestRunRuntimeProfileCreate(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "ws-123") @@ -170,7 +170,7 @@ func TestRunRuntimeProfileCreateRequiresFlags(t *testing.T) { } func TestRunRuntimeProfileUpdateOnlySendsChangedFlags(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "ws-123") @@ -215,7 +215,7 @@ func TestRunRuntimeProfileUpdateOnlySendsChangedFlags(t *testing.T) { } func TestRunRuntimeProfileUpdateNoFieldsErrors(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, 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") @@ -227,7 +227,7 @@ func TestRunRuntimeProfileUpdateNoFieldsErrors(t *testing.T) { } func TestRunRuntimeProfileDeleteSuccess(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "ws-123") @@ -253,7 +253,7 @@ func TestRunRuntimeProfileDeleteSuccess(t *testing.T) { } func TestRunRuntimeProfileDeleteConflictSurfacesServerMessage(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "ws-123") @@ -275,7 +275,7 @@ func TestRunRuntimeProfileDeleteConflictSurfacesServerMessage(t *testing.T) { } func TestRunRuntimeProfileSetAndUnsetPath(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) // set-path setCmd := newProfileSetPathTestCmd() @@ -307,7 +307,7 @@ func TestRunRuntimeProfileSetAndUnsetPath(t *testing.T) { } func TestRunRuntimeProfileSetPathRejectsRelative(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) cmd := newProfileSetPathTestCmd() _ = cmd.Flags().Set("path", "relative/path") if err := runRuntimeProfileSetPath(cmd, []string{"prof-1"}); err == nil { @@ -317,7 +317,7 @@ func TestRunRuntimeProfileSetPathRejectsRelative(t *testing.T) { func TestRunRuntimeProfileSetPathPreservesExistingConfig(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + redirectTestHome(t, home) // Seed an existing config with unrelated fields. seed := cli.CLIConfig{ServerURL: "https://api.multica.ai", WorkspaceID: "ws-123", Token: "mul_xyz"} @@ -345,7 +345,7 @@ func TestRunRuntimeProfileSetPathPreservesExistingConfig(t *testing.T) { func TestRuntimeProfilePathMutationFailsClosedInTaskContext(t *testing.T) { ownerHome := t.TempDir() - t.Setenv("HOME", ownerHome) + redirectTestHome(t, ownerHome) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TASK_CONFIG_ROOT", filepath.Join(t.TempDir(), "task-multica")) @@ -377,7 +377,7 @@ func TestRuntimeProfilePathMutationFailsClosedInTaskContext(t *testing.T) { } func TestRunRuntimeProfileCreateOmpTarget(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "ws-123") var body map[string]any diff --git a/server/cmd/multica/cmd_runtime_test.go b/server/cmd/multica/cmd_runtime_test.go index 9257ae6c08a..16bddb2b7de 100644 --- a/server/cmd/multica/cmd_runtime_test.go +++ b/server/cmd/multica/cmd_runtime_test.go @@ -59,7 +59,7 @@ func captureRuntimeStdout(t *testing.T, fn func() error) (string, error) { } func TestRunRuntimeDeleteStrictSuccessPrintsJSON(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") var deleteCount int @@ -100,7 +100,7 @@ func TestRunRuntimeDeleteStrictSuccessPrintsJSON(t *testing.T) { } func TestRunRuntimeDeleteConflictSuggestsCascade(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -129,7 +129,7 @@ func TestRunRuntimeDeleteConflictSuggestsCascade(t *testing.T) { } func TestRunRuntimeDeleteCascadeConfirmsActiveAgentSnapshot(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") var gotExpectedIDs []string @@ -198,7 +198,7 @@ func TestRunRuntimeDeleteCascadeConfirmsActiveAgentSnapshot(t *testing.T) { // HTTPError.Error() used to print the whole JSON response at the user, burying // that guidance (GH #8456). func TestRunRuntimeDeleteProfileInstanceConflictShowsServerGuidance(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") const guidance = `cannot delete "MSI-S3TEST" on its own: it is registered from the custom runtime profile "Devin CLI (WSL)". It is offline, and Multica removes offline runtimes automatically after 7 days.` @@ -229,7 +229,7 @@ func TestRunRuntimeDeleteProfileInstanceConflictShowsServerGuidance(t *testing.T // A 409 with no readable sentence must still fall back to the wrapper rather // than surfacing an empty error. func TestRunRuntimeDeleteConflictWithoutMessageKeepsWrapper(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/cmd/multica/cmd_setup_test.go b/server/cmd/multica/cmd_setup_test.go index 2d0062274a4..ef8e178c381 100644 --- a/server/cmd/multica/cmd_setup_test.go +++ b/server/cmd/multica/cmd_setup_test.go @@ -17,7 +17,7 @@ import ( // probe and bailing — which left the user logged out with no recovery. func TestPersistSelfHostConfigIfReachable(t *testing.T) { t.Run("unreachable server preserves existing config and token", func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) existing := cli.CLIConfig{ ServerURL: "https://api.old.example", AppURL: "https://old.example", @@ -52,7 +52,7 @@ func TestPersistSelfHostConfigIfReachable(t *testing.T) { }) t.Run("reachable server writes new self-host config", func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) proceed, err := persistSelfHostConfigIfReachable( "https://api.new.example", "https://new.example", "", @@ -403,7 +403,7 @@ func TestServerHostIsLocal(t *testing.T) { func TestSetupCommandsFailClosedInTaskContext(t *testing.T) { ownerHome := t.TempDir() - t.Setenv("HOME", ownerHome) + redirectTestHome(t, ownerHome) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TASK_CONFIG_ROOT", filepath.Join(t.TempDir(), "task-multica")) diff --git a/server/cmd/multica/cmd_skill_test.go b/server/cmd/multica/cmd_skill_test.go index c8179edca08..6c4cc3228fb 100644 --- a/server/cmd/multica/cmd_skill_test.go +++ b/server/cmd/multica/cmd_skill_test.go @@ -58,7 +58,7 @@ func captureStdout(t *testing.T, fn func() error) (string, error) { } func TestRunSkillImportJsonTreatsDuplicateAsConflictResult(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123") @@ -127,7 +127,7 @@ func TestRunSkillImportJsonTreatsDuplicateAsConflictResult(t *testing.T) { } func TestRunSkillImportSendsOnConflictAndPrintsStructuredResult(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123") @@ -475,7 +475,7 @@ func TestRunSkillInlineEmptyContentKeepsExistingBehavior(t *testing.T) { } func TestRunSkillRefreshPostsToRefreshEndpointAndPrintsTable(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123") @@ -520,7 +520,7 @@ func TestRunSkillRefreshPostsToRefreshEndpointAndPrintsTable(t *testing.T) { } func TestRunSkillRefreshJsonPrintsSkill(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123") @@ -604,7 +604,7 @@ func TestRunSkillGetAsksForMetadataUnlessContentRequested(t *testing.T) { {"--with-content", true, "include=content"}, } { t.Run(tc.name, func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) var gotQuery string srv := newSkillQueryCaptureServer(t, "/api/skills/skill-123", &gotQuery, map[string]any{ "id": "skill-123", @@ -634,7 +634,7 @@ func TestRunSkillFilesListAsksForMetadataUnlessContentRequested(t *testing.T) { {"--with-content", true, "include=content"}, } { t.Run(tc.name, func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) var gotQuery string srv := newSkillQueryCaptureServer(t, "/api/skills/skill-123/files", &gotQuery, []any{}) setSkillServerEnv(t, srv.URL) @@ -655,7 +655,7 @@ func TestRunSkillFilesListAsksForMetadataUnlessContentRequested(t *testing.T) { // strVal would print a 1.2MB file as "1.234567e+06" — JSON numbers decode as // float64 — which is unreadable at exactly the sizes that matter. func TestRunSkillFilesListRendersReadableSizes(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) var gotQuery string srv := newSkillQueryCaptureServer(t, "/api/skills/skill-123/files", &gotQuery, []any{ map[string]any{"id": "f1", "path": "reference.md", "size": 1234567, "content_hash": "abc"}, @@ -691,7 +691,7 @@ func newSkillLabelTestCmd(action string) *cobra.Command { } func TestRunSkillLabelCommands(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_WORKSPACE_ID", "ws-1") t.Setenv("MULTICA_TOKEN", "test-token") diff --git a/server/cmd/multica/cmd_squad_test.go b/server/cmd/multica/cmd_squad_test.go index 31ef9c98d02..add3481428b 100644 --- a/server/cmd/multica/cmd_squad_test.go +++ b/server/cmd/multica/cmd_squad_test.go @@ -22,7 +22,7 @@ func newSquadMemberSetRoleTestCmd() *cobra.Command { } func TestRunSquadMemberSetRolePatchesRole(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123") diff --git a/server/cmd/multica/cmd_workspace_mcp_test.go b/server/cmd/multica/cmd_workspace_mcp_test.go index 424eee30f77..4bd5b441106 100644 --- a/server/cmd/multica/cmd_workspace_mcp_test.go +++ b/server/cmd/multica/cmd_workspace_mcp_test.go @@ -75,7 +75,7 @@ var workspaceMcpLeakMarkers = []string{ func mcpTestServer(t *testing.T, handler http.HandlerFunc) { t.Helper() - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", testWorkspaceMcpID) srv := httptest.NewServer(handler) diff --git a/server/cmd/multica/cmd_workspace_test.go b/server/cmd/multica/cmd_workspace_test.go index 824ed3ef5e0..85996cd4d0c 100644 --- a/server/cmd/multica/cmd_workspace_test.go +++ b/server/cmd/multica/cmd_workspace_test.go @@ -88,7 +88,7 @@ func TestRunWorkspaceCreatePostsWorkspaceAndDoesNotSwitchDefault(t *testing.T) { })) defer srv.Close() - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", srv.URL) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "existing-workspace") @@ -243,7 +243,7 @@ func TestRunWorkspaceCreatePrintsTable(t *testing.T) { })) defer srv.Close() - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", srv.URL) t.Setenv("MULTICA_TOKEN", "test-token") @@ -280,7 +280,7 @@ func TestRunWorkspaceSwitch(t *testing.T) { defer srv.Close() // Isolate HOME so the test never touches the developer's ~/.multica. - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_SERVER_URL", srv.URL) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "") @@ -348,7 +348,7 @@ func TestRunWorkspaceSwitch(t *testing.T) { } func TestRunWorkspaceSwitchFailsClosedInTaskContext(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_AGENT_ID", "agent-test") t.Setenv("MULTICA_TASK_ID", "task-test") t.Setenv("MULTICA_TOKEN", "mat_task_sentinel") @@ -364,7 +364,7 @@ func TestRunWorkspaceSwitchFailsClosedInTaskContext(t *testing.T) { func TestFetchWorkspacesExplainsPortOnlyFailClosedContext(t *testing.T) { t.Chdir(t.TempDir()) - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_AGENT_ID", "") t.Setenv("MULTICA_TASK_ID", "") t.Setenv(cli.TaskConfigRootEnv, "") @@ -689,7 +689,7 @@ func newWorkspaceMemberInviteTestCmd() *cobra.Command { } func TestRunWorkspaceMemberInvitePostsInvitation(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123") @@ -734,7 +734,7 @@ func TestRunWorkspaceMemberInvitePostsInvitation(t *testing.T) { } func TestRunWorkspaceMemberInviteUsesWorkspaceArgAndRoleFlag(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") const wsUUID = "11111111-1111-1111-1111-111111111111" @@ -766,7 +766,7 @@ func TestRunWorkspaceMemberInviteUsesWorkspaceArgAndRoleFlag(t *testing.T) { } func TestRunWorkspaceMemberInviteRejectsOwnerRole(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123") @@ -788,7 +788,7 @@ func TestRunWorkspaceMemberInviteRejectsOwnerRole(t *testing.T) { } func TestRunWorkspaceMemberInviteRejectsUnknownRole(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + redirectTestHome(t, t.TempDir()) t.Setenv("MULTICA_TOKEN", "test-token") t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123") diff --git a/server/internal/cli/stable_exe.go b/server/internal/cli/stable_exe.go new file mode 100644 index 00000000000..f4471d1654e --- /dev/null +++ b/server/internal/cli/stable_exe.go @@ -0,0 +1,93 @@ +package cli + +import ( + "path/filepath" + "strings" +) + +// StableExecutablePath maps a self-executable path to the path that should be +// recorded wherever a multica invocation has to outlive a brew upgrade — the +// daemon's binary-update restart target and a boot-autostart entry's +// ExecStart/ProgramArguments. +// +// The two consumers must never disagree: `os.Executable()` reports the +// versioned keg path on brew installs (`/proc/self/exe` on Linux, realpath on +// macOS), and `brew upgrade` deletes that path when it cleans the old keg. +// A unit recorded with the keg path survives neither the restart it would +// trigger (systemd fails ExecStart with 203/EXEC and gives up after +// StartLimitBurst) nor the next login (a LaunchAgent pointing at a deleted +// file), while the spawn-based restart handoff has always resolved through +// the same brew facts — which is exactly why this mapping lives in one +// function both callers use. +// +// brewInstall and brewPrefix are the caller's detection facts: +// +// brewInstall=true, prefix!="" -> /bin/multica (stable brew symlink) +// brewInstall=true, prefix=="" -> path unchanged; the caller decides how to +// report a brew install whose prefix it +// could not resolve +// brewInstall=false -> path with symlinks resolved, or path +// unchanged when resolution fails +// +// Detection itself stays with the caller because the daemon caches it per +// process behind its seams (see Daemon.restartTargetBinary) while one-shot +// callers use StableSelfExecutable. Both detect in the SAME order — the +// path's own shape (a known Cellar) first, then `brew --prefix` — so a keg +// path always resolves to ITS prefix and the two can never drift. +func StableExecutablePath(path, brewPrefix string, brewInstall bool) string { + if brewInstall { + if brewPrefix != "" { + // filepath.Join, as the daemon has always done: brew only exists + // on unix where the separators coincide anyway, and matching the + // historical mapping keeps every existing expectation intact. + return filepath.Join(brewPrefix, "bin", "multica") + } + return path + } + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return path +} + +// StableSelfExecutable is the one-stop form of StableExecutablePath for +// callers that hold a self-executable path and no brew-fact cache of their +// own (boot-autostart registration). Detection order mirrors the daemon's — +// MatchKnownBrewPrefix first, GetBrewPrefix as fallback — so both consumers +// resolve the same path for the same install. +func StableSelfExecutable(path string) string { + resolved := evalSymlinkOrSelf(path) + // The path's own shape is the stronger evidence: a known Cellar path + // resolves to THAT Cellar's prefix even if `brew --prefix` reports a + // different root, so fixtures and moved installs stay per-path correct. + if prefix := MatchKnownBrewPrefix(resolved); prefix != "" { + return StableExecutablePath(path, prefix, true) + } + if prefix := GetBrewPrefix(); prefix != "" && strings.HasPrefix(resolved, prefix) { + return StableExecutablePath(path, prefix, true) + } + return StableExecutablePath(path, "", false) +} + +// brewInstallForPath reports whether path belongs to a Homebrew install, +// using the same checks and order as StableSelfExecutable but answering a +// yes/no question — IsBrewInstall uses it against the running binary, and +// StableSelfExecutable's fallback containment check mirrors it. +func brewInstallForPath(path string) bool { + resolved := evalSymlinkOrSelf(path) + if MatchKnownBrewPrefix(resolved) != "" { + return true + } + prefix := GetBrewPrefix() + return prefix != "" && strings.HasPrefix(resolved, prefix) +} + +// evalSymlinkOrSelf resolves symlinks when it can and returns the input +// unchanged otherwise — a path that does not exist (a stale keg, a fixture) +// must still be shape-matchable against the known prefixes. +func evalSymlinkOrSelf(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return path +} diff --git a/server/internal/cli/stable_exe_test.go b/server/internal/cli/stable_exe_test.go new file mode 100644 index 00000000000..a7e2828c96d --- /dev/null +++ b/server/internal/cli/stable_exe_test.go @@ -0,0 +1,95 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +// TestStableExecutablePath pins the mapping both the restart target and +// autostart registration share. The brew branch is the one that matters: +// the keg path os.Executable() reports is deleted by the next +// `brew upgrade`, so anything recorded against it (a systemd ExecStart, a +// LaunchAgent) breaks after the first upgrade. +func TestStableExecutablePath(t *testing.T) { + t.Parallel() + + keg := "/home/linuxbrew/.linuxbrew/Cellar/multica/0.4.33/bin/multica" + + t.Run("brew install maps to the stable prefix path", func(t *testing.T) { + got := StableExecutablePath(keg, "/home/linuxbrew/.linuxbrew", true) + // filepath.Join semantics: identical strings on unix (where brew + // exists), OS-native separators wherever the test runs. + if want := filepath.Join("/home/linuxbrew/.linuxbrew", "bin", "multica"); got != want { + t.Errorf("StableExecutablePath = %q, want %q", got, want) + } + }) + + t.Run("brew install with unresolved prefix passes through", func(t *testing.T) { + // The caller decides how to warn; silently inventing a prefix would + // be worse than recording what we actually ran. + if got := StableExecutablePath(keg, "", true); got != keg { + t.Errorf("StableExecutablePath = %q, want the input unchanged", got) + } + }) + + t.Run("non-brew install resolves symlinks", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "multica") + if err := os.WriteFile(file, []byte("x"), 0o755); err != nil { + t.Fatalf("write fixture: %v", err) + } + want, err := filepath.EvalSymlinks(file) + if err != nil { + t.Fatalf("EvalSymlinks fixture: %v", err) + } + if got := StableExecutablePath(file, "", false); got != want { + t.Errorf("StableExecutablePath = %q, want %q", got, want) + } + }) + + t.Run("non-brew install keeps an unresolvable path", func(t *testing.T) { + gone := filepath.Join(t.TempDir(), "gone", "multica") + if got := StableExecutablePath(gone, "", false); got != gone { + t.Errorf("StableExecutablePath = %q, want the input unchanged", got) + } + }) +} + +// TestStableSelfExecutableResolvesBrewKegPaths feeds brew-style keg paths — +// the exact shape os.Executable() reports under Homebrew — through the +// one-stop resolver and asserts the stable prefix path comes back. Detection +// has to work from the path alone (offline known-Cellar match), because the +// point of the test is the same situation as a CI host or a login entry +// without brew on PATH: the keg path is the only evidence available. +func TestStableSelfExecutableResolvesBrewKegPaths(t *testing.T) { + t.Parallel() + + cases := []struct { + keg string + prefix string + }{ + {"/opt/homebrew/Cellar/multica/0.4.33/bin/multica", "/opt/homebrew"}, + {"/home/linuxbrew/.linuxbrew/Cellar/multica/0.4.33/bin/multica", "/home/linuxbrew/.linuxbrew"}, + {"/usr/local/Cellar/multica/0.4.33/bin/multica", "/usr/local"}, + } + for _, c := range cases { + want := filepath.Join(c.prefix, "bin", "multica") + if got := StableSelfExecutable(c.keg); got != want { + t.Errorf("StableSelfExecutable(%q) = %q, want %q", c.keg, got, want) + } + } + + t.Run("non-brew path passes through", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "multica") + if err := os.WriteFile(file, []byte("x"), 0o755); err != nil { + t.Fatalf("write fixture: %v", err) + } + want, err := filepath.EvalSymlinks(file) + if err != nil { + t.Fatalf("EvalSymlinks fixture: %v", err) + } + if got := StableSelfExecutable(file); got != want { + t.Errorf("StableSelfExecutable = %q, want %q", got, want) + } + }) +} diff --git a/server/internal/cli/update.go b/server/internal/cli/update.go index b9be7155ba3..ce00a600b38 100644 --- a/server/internal/cli/update.go +++ b/server/internal/cli/update.go @@ -321,22 +321,14 @@ func MatchKnownBrewPrefix(path string) string { } // IsBrewInstall checks whether the running multica binary was installed via Homebrew. +// The check itself lives in brewInstallForPath (stable_exe.go) so it stays +// one implementation with the stable-path resolver that depends on it. func IsBrewInstall() bool { exePath, err := selfexec.Resolve() if err != nil { return false } - resolved, err := filepath.EvalSymlinks(exePath) - if err != nil { - resolved = exePath - } - - brewPrefix := GetBrewPrefix() - if brewPrefix != "" && strings.HasPrefix(resolved, brewPrefix) { - return true - } - - return MatchKnownBrewPrefix(resolved) != "" + return brewInstallForPath(exePath) } // GetBrewPrefix returns the Homebrew prefix by running `brew --prefix`, or empty string. diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 6cea16d50e7..9c4a0ca6251 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -596,7 +596,7 @@ type Daemon struct { // the cache that is up to two uncached `brew --prefix` forks per tick. brewTargetOnce sync.Once brewInstall bool // resolved once: was this binary installed via brew? - brewTarget string // "/bin/multica" when brewInstall and the prefix resolved + brewPrefix string // Homebrew prefix when brewInstall and it resolved; the stable path is derived per call by cli.StableExecutablePath updating atomic.Bool // prevents concurrent update attempts // activeTasks is the ownership-safe count of tasks currently in handleTask. // It deliberately includes preparation and local-directory waiters because @@ -5296,6 +5296,11 @@ func (d *Daemon) triggerRestart() bool { // after an upgrade. For non-brew installs it resolves to the absolute path of // the replaced binary. // +// The mapping itself is cli.StableExecutablePath, shared with boot-autostart +// registration: a restart target and a recorded ExecStart must never disagree +// about which path survives brew cleanup, or the systemd handoff would +// restart a binary `brew upgrade` already deleted. +// // Shared with trySelfReload, which must version-probe the same binary the // restart would run — probing os.Executable() directly would read the old // Cellar path under brew and miss the upgrade entirely. @@ -5306,30 +5311,26 @@ func (d *Daemon) restartTargetBinary() (string, error) { } // The install method and brew prefix are fixed for the process lifetime; // resolve them once so the per-tick reload probe doesn't fork - // `brew --prefix` every 5 minutes. + // `brew --prefix` every 5 minutes. Detection order matches + // cli.StableSelfExecutable — the path's own Cellar shape first, then + // `brew --prefix` — so the restart target and a recorded autostart + // ExecStart always agree. d.brewTargetOnce.Do(func() { d.brewInstall = isBrewInstall() if !d.brewInstall { return } - if brewPrefix := getBrewPrefix(); brewPrefix != "" { - d.brewTarget = filepath.Join(brewPrefix, "bin", "multica") - } else if prefix := matchKnownBrewPrefix(newBin); prefix != "" { - d.brewTarget = filepath.Join(prefix, "bin", "multica") + if prefix := matchKnownBrewPrefix(newBin); prefix != "" { + d.brewPrefix = prefix + } else { + d.brewPrefix = getBrewPrefix() } }) - if d.brewInstall { - if d.brewTarget != "" { - return d.brewTarget, nil - } + if d.brewInstall && d.brewPrefix == "" { d.logger.Warn("brew install detected but prefix could not be resolved; restart may fail", "executable", newBin) - return newBin, nil - } - if resolved, err := filepath.EvalSymlinks(newBin); err == nil { - newBin = resolved } - return newBin, nil + return cli.StableExecutablePath(newBin, d.brewPrefix, d.brewInstall), nil } // pollLoop runs the machine-level batch claim poller (MUL-4257): a single diff --git a/server/internal/util/proc_windows.go b/server/internal/util/proc_windows.go index 607cbb03715..b7281749ac8 100644 --- a/server/internal/util/proc_windows.go +++ b/server/internal/util/proc_windows.go @@ -2,14 +2,18 @@ package util -import "syscall" +import ( + "syscall" + "unsafe" +) var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - user32 = syscall.NewLazyDLL("user32.dll") - procAllocConsole = kernel32.NewProc("AllocConsole") - procGetConsoleWnd = kernel32.NewProc("GetConsoleWindow") - procShowWindow = user32.NewProc("ShowWindow") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + user32 = syscall.NewLazyDLL("user32.dll") + procAllocConsole = kernel32.NewProc("AllocConsole") + procGetConsoleWnd = kernel32.NewProc("GetConsoleWindow") + procGetConsoleProcessList = kernel32.NewProc("GetConsoleProcessList") + procShowWindow = user32.NewProc("ShowWindow") ) const swHide = 0 @@ -25,7 +29,19 @@ const swHide = 0 // window and reintroduce the popup-flash regression from #2357. func EnsureHiddenConsole() { if hwnd, _, _ := procGetConsoleWnd.Call(); hwnd != 0 { - return // already have a console + // A console already exists. When another process is attached — the + // cmd/PowerShell that the user typed into, Windows Terminal's shell + // — it is the hosting terminal and hiding it would hide the user's + // own window. When we are the ONLY process on it, Windows created + // it for this launch (registry Run key, Task Scheduler, + // double-click), and leaving it visible would park a console window + // on the desktop for the daemon's entire lifetime — exactly the + // login-time popup the boot-autostart registration would otherwise + // introduce. + if consoleProcessCount() == 1 { + procShowWindow.Call(hwnd, swHide) + } + return } if r, _, _ := procAllocConsole.Call(); r == 0 { return // AllocConsole failed @@ -34,3 +50,16 @@ func EnsureHiddenConsole() { procShowWindow.Call(hwnd, swHide) } } + +// consoleProcessCount reports how many processes are attached to this +// process's console, or 0 when there is no console or the query fails. +// Anything above 1 means a hosting shell is present; a failed query reading +// 0 leaves the window alone, which is the conservative outcome. +func consoleProcessCount() uint32 { + var buf [2]uint32 + n, _, _ := procGetConsoleProcessList.Call( + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + ) + return uint32(n) +}