mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* fix(server): recognize official cloud by frontend host in daemon setup config The 'Add a computer' dialog builds its command from /api/config's daemon_server_url/daemon_app_url, falling back to 'multica setup' when both are empty. The official cloud is meant to omit them, but the omission only fired when MULTICA_PUBLIC_URL=https://api.multica.ai. When that env is unset the server URL defaults to the frontend origin and the old guard (which required serverURL host == api.multica.ai) didn't match, so the dialog emitted 'multica setup self-host --server-url https://multica.ai' — pointing the daemon backend at the frontend (no /health, no WebSocket proxy). Identify the official cloud by its frontend host alone (multica.ai / app.multica.ai) so a missing or misconfigured MULTICA_PUBLIC_URL can no longer leak the broken self-host command. Regression from #3474. * fix(cli): probe before persisting self-host config to preserve auth on failure setup self-host wrote a fresh CLIConfig{ServerURL, AppURL} (a full overwrite that drops the saved token) and only then probed the server, returning early on failure. A failed probe therefore logged the user out and left them unconnected, with no recovery in the same command. Probe first via persistSelfHostConfigIfReachable: an unreachable server leaves the existing config — and its token — untouched (failed setup = no-op). The prober is injected so both branches are unit-tested. * fix(daemon): serve health before preflight so daemon start readiness is accurate The CLI's 'daemon start' polls the health endpoint for 15s expecting status=running, but the daemon only began serving health after preflightAuth, whose initial workspace sync detects every configured agent's version by exec'ing it (~20s cold with 8 agents). Health served too late, so a perfectly healthy daemon printed 'may not have started successfully'. Start the health server right after resolveAuth (which still fails fast on a missing token) and before the slow preflight, so readiness reflects the daemon core being up rather than agent-version detection finishing. * fix(daemon): gate /health readiness so daemon start can't report a false start Serving health before preflightAuth fixed the false-negative (a healthy daemon printed "may not have started"), but health still returned status:"running" unconditionally — before preflight (PAT renew + workspace sync + runtime registration) had completed. `daemon start` and the desktop treat "running" as ready, so a slow or *failing* preflight could be misreported as a started daemon: setup prints "connected", then the process exits or hangs in agent-version detection with no runtime registered. That is harder to diagnose than the original false-negative. Split liveness from readiness: bind/serve the health port early (so callers see a live "starting" daemon instead of connection-refused), but report status:"starting" until d.ready is set after preflight, then "running". - daemon.go: add d.ready (atomic.Bool); set it true after the background loops launch, before pollLoop. - health.go: healthHandler reports "starting" until ready, else "running". - cmd_daemon.go: `daemon start` waits for "running" with a deadline raised to 45s (covers cold-start agent detection) and a clearer "still starting" message; new daemonAlive() helper treats both "running" and "starting" as a live daemon, so the already-running guard, restart, and stop act on a starting daemon and don't double-spawn or race its listener; `daemon status` shows "starting" distinctly. Older CLIs/desktop that only know "running" safely treat "starting" as not-ready (status != "running"), so no boundary break. Tests: health reports starting-then-running; daemonAlive truth table. Co-authored-by: multica-agent <github@multica.ai> * fix(desktop): handle daemon "starting" health status in lifecycle The daemon now reports /health status:"starting" until preflight completes (liveness/readiness split). That made "starting" a new external contract of /health, but the Desktop daemon-manager only knew "running", so the readiness fix would have moved the CLI's false-negative into a Desktop start regression: - `daemon start` now blocks up to 45s waiting for readiness, but the Desktop spawned it via execFile({ timeout: 20_000 }). On a cold start (the ~20s agent detection this PR targets) Electron killed the CLI supervisor at 20s and reported a start failure, even though the detached daemon child kept booting — the UI flashed "stopped" then "running". Raise the timeout to 60s (must exceed the CLI's 45s startupTimeout). - The Desktop treated only raw status === "running" as a live daemon, so a daemon that was still "starting" (booting on its own or started via the CLI) showed as "stopped", and startDaemon() would spawn a second one — which the new CLI rejects as "already running", surfacing as a start error. Add daemonStatusAlive() (shared, pure, unit-tested) mirroring the Go daemonAlive() and use it for liveness: fetchHealth() surfaces a daemon-reported "starting" as state "starting" regardless of our own currentState; startDaemon()'s already-running guard and the restart-on-user-switch guard treat "starting" as an existing daemon. version-decision stays gated on "running" (readiness, not liveness) — unchanged. Verified: desktop typecheck, eslint, full vitest suite (193 tests) all pass. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
244 lines
8.3 KiB
Go
244 lines
8.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestGetConfigIncludesRuntimeAuthConfig(t *testing.T) {
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = &mockStorage{}
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
t.Setenv("ALLOW_SIGNUP", "false")
|
|
t.Setenv("GOOGLE_CLIENT_ID", "google-client-id")
|
|
t.Setenv("POSTHOG_API_KEY", "phc_test")
|
|
t.Setenv("POSTHOG_HOST", "https://eu.i.posthog.com")
|
|
t.Setenv("MULTICA_PUBLIC_URL", "https://api.example.com/")
|
|
t.Setenv("MULTICA_APP_URL", "https://app.example.com/")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
testHandler.GetConfig(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cfg AppConfig
|
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
|
t.Fatalf("decode config: %v", err)
|
|
}
|
|
|
|
if cfg.CdnDomain != "cdn.example.com" {
|
|
t.Fatalf("cdn_domain: want cdn.example.com, got %q", cfg.CdnDomain)
|
|
}
|
|
if cfg.AllowSignup {
|
|
t.Fatalf("allow_signup: want false, got true")
|
|
}
|
|
if cfg.GoogleClientID != "google-client-id" {
|
|
t.Fatalf("google_client_id: want google-client-id, got %q", cfg.GoogleClientID)
|
|
}
|
|
if cfg.PosthogKey != "phc_test" {
|
|
t.Fatalf("posthog_key: want phc_test, got %q", cfg.PosthogKey)
|
|
}
|
|
if cfg.PosthogHost != "https://eu.i.posthog.com" {
|
|
t.Fatalf("posthog_host: want https://eu.i.posthog.com, got %q", cfg.PosthogHost)
|
|
}
|
|
if cfg.AnalyticsEnvironment != "dev" {
|
|
t.Fatalf("analytics_environment: want dev, got %q", cfg.AnalyticsEnvironment)
|
|
}
|
|
if cfg.WorkspaceCreationDisabled {
|
|
t.Fatalf("workspace_creation_disabled: want false by default, got true")
|
|
}
|
|
if cfg.DaemonServerURL != "https://api.example.com" {
|
|
t.Fatalf("daemon_server_url: want https://api.example.com, got %q", cfg.DaemonServerURL)
|
|
}
|
|
if cfg.DaemonAppURL != "https://app.example.com" {
|
|
t.Fatalf("daemon_app_url: want https://app.example.com, got %q", cfg.DaemonAppURL)
|
|
}
|
|
}
|
|
|
|
func TestGetConfigUsesAppURLForSameOriginDaemonSetup(t *testing.T) {
|
|
t.Setenv("MULTICA_APP_URL", "https://multica.internal.example/")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
testHandler.GetConfig(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cfg AppConfig
|
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
|
t.Fatalf("decode config: %v", err)
|
|
}
|
|
if cfg.DaemonServerURL != "https://multica.internal.example" {
|
|
t.Fatalf("daemon_server_url: want same-origin URL, got %q", cfg.DaemonServerURL)
|
|
}
|
|
if cfg.DaemonAppURL != "https://multica.internal.example" {
|
|
t.Fatalf("daemon_app_url: want app URL, got %q", cfg.DaemonAppURL)
|
|
}
|
|
}
|
|
|
|
func TestGetConfigUsesFrontendOriginForSameOriginDaemonSetup(t *testing.T) {
|
|
t.Setenv("FRONTEND_ORIGIN", "https://multica.internal.example/")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
testHandler.GetConfig(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cfg AppConfig
|
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
|
t.Fatalf("decode config: %v", err)
|
|
}
|
|
if cfg.DaemonServerURL != "https://multica.internal.example" {
|
|
t.Fatalf("daemon_server_url: want same-origin URL, got %q", cfg.DaemonServerURL)
|
|
}
|
|
if cfg.DaemonAppURL != "https://multica.internal.example" {
|
|
t.Fatalf("daemon_app_url: want frontend origin, got %q", cfg.DaemonAppURL)
|
|
}
|
|
}
|
|
|
|
func TestGetConfigOmitsOfficialCloudDaemonSetup(t *testing.T) {
|
|
t.Setenv("MULTICA_PUBLIC_URL", "https://api.multica.ai")
|
|
t.Setenv("FRONTEND_ORIGIN", "https://multica.ai")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
testHandler.GetConfig(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cfg AppConfig
|
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
|
t.Fatalf("decode config: %v", err)
|
|
}
|
|
if cfg.DaemonServerURL != "" {
|
|
t.Fatalf("daemon_server_url: want omitted for cloud, got %q", cfg.DaemonServerURL)
|
|
}
|
|
if cfg.DaemonAppURL != "" {
|
|
t.Fatalf("daemon_app_url: want omitted for cloud, got %q", cfg.DaemonAppURL)
|
|
}
|
|
}
|
|
|
|
// TestGetConfigOmitsCloudDaemonSetupWithoutPublicURL reproduces the production
|
|
// regression behind the broken "Add a computer" command: the official cloud
|
|
// frontend is multica.ai, but the deployment does not set MULTICA_PUBLIC_URL to
|
|
// the api host. Previously this fell through to the same-origin branch and
|
|
// emitted daemon_server_url=https://multica.ai, which the dialog turned into
|
|
// `multica setup self-host --server-url https://multica.ai` — pointing the
|
|
// daemon's backend at the frontend (no /health, no WebSocket proxy). The
|
|
// official cloud must be recognised by its frontend host alone so the daemon
|
|
// setup URLs are omitted and the dialog falls back to `multica setup`.
|
|
func TestGetConfigOmitsCloudDaemonSetupWithoutPublicURL(t *testing.T) {
|
|
t.Setenv("MULTICA_PUBLIC_URL", "")
|
|
t.Setenv("MULTICA_APP_URL", "")
|
|
t.Setenv("FRONTEND_ORIGIN", "https://multica.ai")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
testHandler.GetConfig(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cfg AppConfig
|
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
|
t.Fatalf("decode config: %v", err)
|
|
}
|
|
if cfg.DaemonServerURL != "" {
|
|
t.Fatalf("daemon_server_url: want omitted for official cloud, got %q", cfg.DaemonServerURL)
|
|
}
|
|
if cfg.DaemonAppURL != "" {
|
|
t.Fatalf("daemon_app_url: want omitted for official cloud, got %q", cfg.DaemonAppURL)
|
|
}
|
|
}
|
|
|
|
// TestGetConfigOmitsCloudDaemonSetupForAppSubdomain covers the app.multica.ai
|
|
// frontend variant of the official cloud.
|
|
func TestGetConfigOmitsCloudDaemonSetupForAppSubdomain(t *testing.T) {
|
|
t.Setenv("MULTICA_PUBLIC_URL", "")
|
|
t.Setenv("MULTICA_APP_URL", "https://app.multica.ai")
|
|
t.Setenv("FRONTEND_ORIGIN", "")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
testHandler.GetConfig(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cfg AppConfig
|
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
|
t.Fatalf("decode config: %v", err)
|
|
}
|
|
if cfg.DaemonServerURL != "" {
|
|
t.Fatalf("daemon_server_url: want omitted for official cloud, got %q", cfg.DaemonServerURL)
|
|
}
|
|
if cfg.DaemonAppURL != "" {
|
|
t.Fatalf("daemon_app_url: want omitted for official cloud, got %q", cfg.DaemonAppURL)
|
|
}
|
|
}
|
|
|
|
func TestURLHostEqualsCanonicalizesCommonHostForms(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
want bool
|
|
}{
|
|
{name: "full URL", raw: "https://api.multica.ai", want: true},
|
|
{name: "bare host", raw: "api.multica.ai", want: true},
|
|
{name: "host port", raw: "api.multica.ai:8080", want: true},
|
|
{name: "trailing dot", raw: "https://api.multica.ai.", want: true},
|
|
{name: "different host", raw: "https://evil.example", want: false},
|
|
{name: "empty", raw: "", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := urlHostEquals(tt.raw, "api.multica.ai"); got != tt.want {
|
|
t.Fatalf("urlHostEquals(%q): want %v, got %v", tt.raw, tt.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetConfigExposesWorkspaceCreationDisabled verifies that the self-host
|
|
// gate added by #3433 surfaces to the frontend through /api/config so the UI
|
|
// can hide every "Create workspace" affordance.
|
|
func TestGetConfigExposesWorkspaceCreationDisabled(t *testing.T) {
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = &mockStorage{}
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
t.Setenv("DISABLE_WORKSPACE_CREATION", "true")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
testHandler.GetConfig(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cfg AppConfig
|
|
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
|
t.Fatalf("decode config: %v", err)
|
|
}
|
|
if !cfg.WorkspaceCreationDisabled {
|
|
t.Fatalf("workspace_creation_disabled: want true with env on, got false (body=%s)", w.Body.String())
|
|
}
|
|
}
|