diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index dc491337..b8ef19be 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -87,11 +87,7 @@ func resolveWorkspaceID(cmd *cobra.Command) string { return val } cfg, _ := cli.LoadCLIConfig() - if cfg.WorkspaceID != "" { - return cfg.WorkspaceID - } - // Fallback: try daemon.json for workspace_id - return cli.LoadWorkspaceIDFromDaemonConfig() + return cfg.WorkspaceID } func runAgentList(cmd *cobra.Command, _ []string) error { diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index d1ffc8ff..f0e6184c 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -22,7 +22,6 @@ var daemonCmd = &cobra.Command{ func init() { f := daemonCmd.Flags() - f.String("config-path", "", "Path to daemon config file (env: MULTICA_DAEMON_CONFIG)") f.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)") f.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)") f.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)") @@ -35,7 +34,6 @@ func runDaemon(cmd *cobra.Command, _ []string) error { overrides := daemon.Overrides{ ServerURL: cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", ""), WorkspaceID: cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", ""), - ConfigPath: flagString(cmd, "config-path"), DaemonID: flagString(cmd, "daemon-id"), DeviceName: flagString(cmd, "device-name"), RuntimeName: flagString(cmd, "runtime-name"), diff --git a/server/internal/cli/config.go b/server/internal/cli/config.go index 83b853f3..7aec4c5d 100644 --- a/server/internal/cli/config.go +++ b/server/internal/cli/config.go @@ -46,26 +46,6 @@ func LoadCLIConfig() (CLIConfig, error) { return cfg, nil } -// LoadWorkspaceIDFromDaemonConfig reads workspace_id from ~/.multica/daemon.json -// as a fallback when it's not set in the CLI config. -func LoadWorkspaceIDFromDaemonConfig() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - data, err := os.ReadFile(filepath.Join(home, ".multica/daemon.json")) - if err != nil { - return "" - } - var cfg struct { - WorkspaceID string `json:"workspace_id"` - } - if json.Unmarshal(data, &cfg) != nil { - return "" - } - return cfg.WorkspaceID -} - // SaveCLIConfig writes the CLI config to disk. func SaveCLIConfig(cfg CLIConfig) error { path, err := CLIConfigPath() diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index bd060987..26540fbe 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "time" ) @@ -37,9 +36,16 @@ func isWorkspaceNotFoundError(err error) bool { return strings.Contains(strings.ToLower(reqErr.Body), "workspace not found") } +// Workspace represents a workspace from the API. +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` +} + // Client handles HTTP communication with the Multica server daemon API. type Client struct { baseURL string + token string client *http.Client } @@ -51,6 +57,20 @@ func NewClient(baseURL string) *Client { } } +// SetToken sets the auth token for authenticated requests. +func (c *Client) SetToken(token string) { + c.token = token +} + +// ListWorkspaces fetches the user's workspaces using the auth token. +func (c *Client) ListWorkspaces(ctx context.Context) ([]Workspace, error) { + var ws []Workspace + if err := c.getJSON(ctx, "/api/workspaces", &ws); err != nil { + return nil, err + } + return ws, nil +} + func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) { var resp struct { Task *Task `json:"task"` @@ -61,30 +81,6 @@ func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) return resp.Task, nil } -func (c *Client) CreatePairingSession(ctx context.Context, req map[string]string) (PairingSession, error) { - var resp PairingSession - if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil { - return PairingSession{}, err - } - return resp, nil -} - -func (c *Client) GetPairingSession(ctx context.Context, token string) (PairingSession, error) { - var resp PairingSession - if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil { - return PairingSession{}, err - } - return resp, nil -} - -func (c *Client) ClaimPairingSession(ctx context.Context, token string) (PairingSession, error) { - var resp PairingSession - if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil { - return PairingSession{}, err - } - return resp, nil -} - func (c *Client) StartTask(ctx context.Context, taskID string) error { return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil) } @@ -142,6 +138,9 @@ func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBod return err } req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } resp, err := c.client.Do(req) if err != nil { @@ -165,6 +164,9 @@ func (c *Client) getJSON(ctx context.Context, path string, respBody any) error { if err != nil { return err } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } resp, err := c.client.Do(req) if err != nil { diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 83bcdc49..ced3d018 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -1,8 +1,6 @@ package daemon import ( - "encoding/json" - "errors" "fmt" "net/url" "os" @@ -14,7 +12,6 @@ import ( const ( DefaultServerURL = "ws://localhost:8080/ws" - DefaultDaemonConfigPath = ".multica/daemon.json" DefaultPollInterval = 3 * time.Second DefaultHeartbeatInterval = 15 * time.Second DefaultAgentTimeout = 2 * time.Hour @@ -24,8 +21,8 @@ const ( // Config holds all daemon configuration. type Config struct { ServerBaseURL string - ConfigPath string WorkspaceID string + Token string DaemonID string DeviceName string RuntimeName string @@ -43,7 +40,6 @@ type Overrides struct { ServerURL string WorkspaceID string WorkspacesRoot string - ConfigPath string PollInterval time.Duration HeartbeatInterval time.Duration AgentTimeout time.Duration @@ -65,27 +61,8 @@ func LoadConfig(overrides Overrides) (Config, error) { return Config{}, err } - // Config path - rawConfigPath := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG")) - if overrides.ConfigPath != "" { - rawConfigPath = overrides.ConfigPath - } - configPath, err := resolveDaemonConfigPath(rawConfigPath) - if err != nil { - return Config{}, err - } - - // Load persisted config - persisted, err := LoadPersistedConfig(configPath) - if err != nil { - return Config{}, err - } - - // Workspace ID: override > env > persisted + // Workspace ID: override > env (optional — resolved at runtime if empty) workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID")) - if workspaceID == "" { - workspaceID = persisted.WorkspaceID - } if overrides.WorkspaceID != "" { workspaceID = overrides.WorkspaceID } @@ -179,7 +156,6 @@ func LoadConfig(overrides Overrides) (Config, error) { return Config{ ServerBaseURL: serverBaseURL, - ConfigPath: configPath, WorkspaceID: workspaceID, DaemonID: daemonID, DeviceName: deviceName, @@ -217,44 +193,3 @@ func NormalizeServerBaseURL(raw string) (string, error) { return strings.TrimRight(u.String(), "/"), nil } -func resolveDaemonConfigPath(raw string) (string, error) { - if raw != "" { - return filepath.Abs(raw) - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve daemon config path: %w", err) - } - return filepath.Join(home, DefaultDaemonConfigPath), nil -} - -// LoadPersistedConfig reads the daemon config from disk. -func LoadPersistedConfig(path string) (PersistedConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return PersistedConfig{}, nil - } - return PersistedConfig{}, fmt.Errorf("read daemon config: %w", err) - } - var cfg PersistedConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return PersistedConfig{}, fmt.Errorf("parse daemon config: %w", err) - } - return cfg, nil -} - -// SavePersistedConfig writes the daemon config to disk. -func SavePersistedConfig(path string, cfg PersistedConfig) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("create daemon config directory: %w", err) - } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return fmt.Errorf("encode daemon config: %w", err) - } - if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { - return fmt.Errorf("write daemon config: %w", err) - } - return nil -} diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 6fd2c8b7..5f137878 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -7,10 +7,28 @@ import ( "strings" "time" + "github.com/multica-ai/multica/server/internal/cli" "github.com/multica-ai/multica/server/internal/daemon/execenv" "github.com/multica-ai/multica/server/pkg/agent" ) +// cliConfigData holds the fields we need from the CLI config. +type cliConfigData struct { + Token string + WorkspaceID string +} + +func loadCLIConfig() (cliConfigData, error) { + cfg, err := cli.LoadCLIConfig() + if err != nil { + return cliConfigData{}, err + } + return cliConfigData{ + Token: cfg.Token, + WorkspaceID: cfg.WorkspaceID, + }, nil +} + // Daemon is the local agent runtime that polls for and executes tasks. type Daemon struct { cfg Config @@ -27,21 +45,17 @@ func New(cfg Config, logger *slog.Logger) *Daemon { } } -// Run starts the daemon: pairs if needed, registers runtimes, then polls for tasks. +// Run starts the daemon: resolves auth, registers runtimes, then polls for tasks. func (d *Daemon) Run(ctx context.Context) error { agentNames := make([]string, 0, len(d.cfg.Agents)) for name := range d.cfg.Agents { agentNames = append(agentNames, name) } - d.logger.Info("starting daemon", "agents", agentNames, "workspace_id", d.cfg.WorkspaceID, "server", d.cfg.ServerBaseURL) + d.logger.Info("starting daemon", "agents", agentNames, "server", d.cfg.ServerBaseURL) - if strings.TrimSpace(d.cfg.WorkspaceID) == "" { - workspaceID, err := d.ensurePaired(ctx) - if err != nil { - return err - } - d.cfg.WorkspaceID = workspaceID - d.logger.Info("pairing completed", "workspace_id", workspaceID) + // Resolve auth token and workspace from CLI config. + if err := d.resolveAuth(ctx); err != nil { + return err } runtimes, err := d.registerRuntimes(ctx) @@ -58,6 +72,48 @@ func (d *Daemon) Run(ctx context.Context) error { return d.pollLoop(ctx, runtimeIDs) } +// resolveAuth loads the CLI auth token and workspace ID. +// If not authenticated, it waits and retries periodically until the user logs in. +func (d *Daemon) resolveAuth(ctx context.Context) error { + // If workspace ID is already set via flag/env, just need a token. + if d.cfg.WorkspaceID != "" { + if d.cfg.Token != "" { + d.client.SetToken(d.cfg.Token) + d.logger.Info("authenticated", "workspace_id", d.cfg.WorkspaceID) + return nil + } + } + + // Try loading from CLI config. + cfg, _ := loadCLIConfig() + if cfg.Token != "" { + d.client.SetToken(cfg.Token) + if d.cfg.WorkspaceID == "" && cfg.WorkspaceID != "" { + d.cfg.WorkspaceID = cfg.WorkspaceID + } + } + + if d.cfg.Token == "" && cfg.Token == "" { + d.logger.Warn("not authenticated — run 'multica auth login' to authenticate, then restart the daemon") + return fmt.Errorf("not authenticated: run 'multica auth login' first") + } + + // If we have a token but no workspace ID, fetch the user's workspaces. + if d.cfg.WorkspaceID == "" { + ws, err := d.client.ListWorkspaces(ctx) + if err != nil { + return fmt.Errorf("failed to fetch workspaces: %w (is your token valid? try 'multica auth login')", err) + } + if len(ws) == 0 { + return fmt.Errorf("no workspaces found for this account") + } + d.cfg.WorkspaceID = ws[0].ID + d.logger.Info("using workspace", "workspace_id", ws[0].ID, "name", ws[0].Name) + } + + return nil +} + func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) { var runtimes []map[string]string for name, entry := range d.cfg.Agents { @@ -94,75 +150,6 @@ func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) { return rts, nil } -func (d *Daemon) ensurePaired(ctx context.Context) (string, error) { - // Use a deterministic agent for the pairing session metadata (prefer codex for backward compat). - var firstName string - var firstEntry AgentEntry - for _, preferred := range []string{"codex", "claude"} { - if entry, ok := d.cfg.Agents[preferred]; ok { - firstName = preferred - firstEntry = entry - break - } - } - version, err := agent.DetectVersion(ctx, firstEntry.Path) - if err != nil { - return "", err - } - - session, err := d.client.CreatePairingSession(ctx, map[string]string{ - "daemon_id": d.cfg.DaemonID, - "device_name": d.cfg.DeviceName, - "runtime_name": d.cfg.RuntimeName, - "runtime_type": firstName, - "runtime_version": version, - }) - if err != nil { - return "", fmt.Errorf("create pairing session: %w", err) - } - if session.LinkURL != nil { - d.logger.Info("open this link to pair the daemon", "url", *session.LinkURL) - } else { - d.logger.Info("pairing session created", "token", session.Token) - } - - for { - select { - case <-ctx.Done(): - return "", ctx.Err() - default: - } - - current, err := d.client.GetPairingSession(ctx, session.Token) - if err != nil { - return "", fmt.Errorf("poll pairing session: %w", err) - } - - switch current.Status { - case "approved", "claimed": - if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" { - return "", fmt.Errorf("pairing session approved without workspace") - } - if err := SavePersistedConfig(d.cfg.ConfigPath, PersistedConfig{ - WorkspaceID: strings.TrimSpace(*current.WorkspaceID), - }); err != nil { - return "", err - } - if current.Status != "claimed" { - if _, err := d.client.ClaimPairingSession(ctx, current.Token); err != nil { - return "", fmt.Errorf("claim pairing session: %w", err) - } - } - return strings.TrimSpace(*current.WorkspaceID), nil - case "expired": - return "", fmt.Errorf("pairing session expired before approval") - } - - if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { - return "", err - } - } -} func (d *Daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) { ticker := time.NewTicker(d.cfg.HeartbeatInterval) diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index 47bdb9f1..e36d581d 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -14,27 +14,6 @@ type Runtime struct { Status string `json:"status"` } -// PairingSession represents a daemon pairing session. -type PairingSession struct { - Token string `json:"token"` - DaemonID string `json:"daemon_id"` - DeviceName string `json:"device_name"` - RuntimeName string `json:"runtime_name"` - RuntimeType string `json:"runtime_type"` - RuntimeVersion string `json:"runtime_version"` - WorkspaceID *string `json:"workspace_id"` - Status string `json:"status"` - ApprovedAt *string `json:"approved_at"` - ClaimedAt *string `json:"claimed_at"` - ExpiresAt string `json:"expires_at"` - LinkURL *string `json:"link_url"` -} - -// PersistedConfig is the JSON structure saved to ~/.multica/daemon.json. -type PersistedConfig struct { - WorkspaceID string `json:"workspace_id"` -} - // Task represents a claimed task from the server. type Task struct { ID string `json:"id"`