mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat: identify clients via X-Client-Platform/Version/OS
Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.
- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}
Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).
Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.
CORS allowlist extended for the new headers.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: address client-identity PR nits
- Memoize the CoreProvider identity object on Web and Desktop, and key
WSProvider's effect on identity primitives instead of the object
reference, so unrelated parent re-renders no longer tear down and
reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
and asserts the "websocket connected" log entry surfaces them as
structured attributes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
368 lines
10 KiB
Go
368 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ClientVersion is the CLI version sent on every request as X-Client-Version.
|
|
// Set by the multica binary at init() so the package doesn't depend on the
|
|
// concrete cmd package. Defaults to "dev" when running unset (e.g. tests).
|
|
var ClientVersion = "dev"
|
|
|
|
// ClientPlatform identifies this client to the server. Override for tests
|
|
// or alternative entry points; defaults to "cli".
|
|
var ClientPlatform = "cli"
|
|
|
|
// ClientOS is the normalized operating system string sent as X-Client-OS.
|
|
// Computed once from runtime.GOOS so the server doesn't need to reverse-map
|
|
// Go's os names ("darwin"/"windows"/"linux") into the protocol vocabulary.
|
|
var ClientOS = normalizeGOOS(runtime.GOOS)
|
|
|
|
func normalizeGOOS(goos string) string {
|
|
switch goos {
|
|
case "darwin":
|
|
return "macos"
|
|
case "windows":
|
|
return "windows"
|
|
case "linux":
|
|
return "linux"
|
|
default:
|
|
return goos
|
|
}
|
|
}
|
|
|
|
// APIClient is a REST client for the Multica server API.
|
|
// Used by ctrl subcommands (agent, runtime, status, etc.). Requests
|
|
// automatically include auth and execution context headers when configured.
|
|
type APIClient struct {
|
|
BaseURL string
|
|
WorkspaceID string
|
|
Token string
|
|
AgentID string // When set, requests are attributed to this agent instead of the user.
|
|
TaskID string // When set, sent as X-Task-ID for agent-task validation.
|
|
HTTPClient *http.Client
|
|
|
|
// Identity overrides. Empty values fall back to the package-level
|
|
// ClientPlatform / ClientVersion / ClientOS.
|
|
Platform string
|
|
Version string
|
|
OS string
|
|
}
|
|
|
|
// NewAPIClient creates a new API client for ctrl commands.
|
|
func NewAPIClient(baseURL, workspaceID, token string) *APIClient {
|
|
return &APIClient{
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
WorkspaceID: workspaceID,
|
|
Token: token,
|
|
HTTPClient: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (c *APIClient) setHeaders(req *http.Request) {
|
|
if c.Token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
}
|
|
if c.WorkspaceID != "" {
|
|
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
|
}
|
|
if c.AgentID != "" {
|
|
req.Header.Set("X-Agent-ID", c.AgentID)
|
|
}
|
|
if c.TaskID != "" {
|
|
req.Header.Set("X-Task-ID", c.TaskID)
|
|
}
|
|
|
|
platform := c.Platform
|
|
if platform == "" {
|
|
platform = ClientPlatform
|
|
}
|
|
if platform != "" {
|
|
req.Header.Set("X-Client-Platform", platform)
|
|
}
|
|
version := c.Version
|
|
if version == "" {
|
|
version = ClientVersion
|
|
}
|
|
if version != "" {
|
|
req.Header.Set("X-Client-Version", version)
|
|
}
|
|
osName := c.OS
|
|
if osName == "" {
|
|
osName = ClientOS
|
|
}
|
|
if osName != "" {
|
|
req.Header.Set("X-Client-OS", osName)
|
|
}
|
|
}
|
|
|
|
// GetJSON performs a GET request and decodes the JSON response.
|
|
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// GetJSONWithHeaders performs a GET request, decodes the JSON response, and
|
|
// returns the response headers. Useful when callers need header values like
|
|
// X-Total-Count for pagination.
|
|
func (c *APIClient) GetJSONWithHeaders(ctx context.Context, path string, out any) (http.Header, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return nil, fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
|
|
}
|
|
if out != nil {
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return resp.Header, err
|
|
}
|
|
}
|
|
return resp.Header, nil
|
|
}
|
|
|
|
// DeleteJSON performs a DELETE request.
|
|
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("DELETE %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PostJSON performs a POST request with a JSON body.
|
|
func (c *APIClient) PostJSON(ctx context.Context, path string, body any, out any) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("POST %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// PutJSON performs a PUT request with a JSON body.
|
|
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("PUT %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// PatchJSON performs a PATCH request with a JSON body.
|
|
func (c *APIClient) PatchJSON(ctx context.Context, path string, body any, out any) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("PATCH %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// UploadFile uploads a file via multipart form to /api/upload-file.
|
|
// It returns the attachment ID from the server response.
|
|
func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
|
|
part, err := writer.CreateFormFile("file", filepath.Base(filename))
|
|
if err != nil {
|
|
return "", fmt.Errorf("create form file: %w", err)
|
|
}
|
|
if _, err := part.Write(fileData); err != nil {
|
|
return "", fmt.Errorf("write file data: %w", err)
|
|
}
|
|
|
|
if issueID != "" {
|
|
if err := writer.WriteField("issue_id", issueID); err != nil {
|
|
return "", fmt.Errorf("write issue_id field: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := writer.Close(); err != nil {
|
|
return "", fmt.Errorf("close multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return "", fmt.Errorf("upload file returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respData)))
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("decode upload response: %w", err)
|
|
}
|
|
|
|
id, _ := result["id"].(string)
|
|
if id == "" {
|
|
return "", fmt.Errorf("upload response missing attachment id")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// DownloadFile downloads a file from the given URL and returns the response body.
|
|
// This is used for downloading attachments via their signed download_url.
|
|
// Downloads are limited to 100 MB to match the upload size limit.
|
|
func (c *APIClient) DownloadFile(ctx context.Context, downloadURL string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return nil, fmt.Errorf("download returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
const maxDownloadSize = 100 << 20 // 100 MB
|
|
return io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize))
|
|
}
|
|
|
|
// HealthCheck hits the /health endpoint and returns the response body.
|
|
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode >= 400 {
|
|
return "", fmt.Errorf("health check returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
|
}
|
|
return strings.TrimSpace(string(data)), nil
|
|
}
|