refactor(daemon): remove pairing flow, use CLI auth token instead
The daemon now reads the auth token from ~/.multica/config.json (set by `multica auth login`) instead of requiring a browser-based pairing flow. If not authenticated, it logs a message and exits. Workspace ID is auto-resolved from the user's workspaces when not explicitly set via flag/env. Removed: daemon.json, pairing session flow, --config-path flag, PairingSession type, PersistedConfig, LoadWorkspaceIDFromDaemonConfig. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
669b18e1c9
commit
b9d2b88852
7 changed files with 95 additions and 218 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue