Files
multica/server/internal/cli/client.go
T
Bohan JiangandCatCatUncle 3ac53a68a6 MUL-7108 fix(autopilot): judge every autopilot write as the human it acts for (#8107)
Autopilot write endpoints judged the request's authenticated user, which under
a mat_ task token is the RUNTIME OWNER. Any member could therefore @ an agent
running on someone else's machine and spend that machine owner's autopilot
rights — pause a rule, re-point a webhook, rotate a token, or grant themselves
a collaborator row.

Every write on this surface now resolves the human it acts FOR (a member acts
for themselves, an agent for its run's originator), through one seam, and
stamps that same human into created_by / published_by / granted_by and the
rule versions. The gate and the row can no longer name two different people.

Two bypasses of that gate are closed with it:

- The webhook credential was broadcast to the whole workspace on
  autopilot:updated, handing every member the token GetAutopilot had just
  redacted for them. All five trigger fan-outs now carry a redacted copy; the
  HTTP caller still gets the live value.
- Agent identity itself was forgeable: Auth stripped only X-Actor-Source, so a
  member could replay an observable (agent_id, task_id) pair — both returned by
  GET /api/issues/{id}/task-runs — and be resolved as that agent. Auth and
  DaemonAuth now strip both headers, leaving the mat_ branch as the only writer
  of agent identity.

That last part is MUL-3428, reported and fixed by @CatCatUncle in #4313; it is
carried here because this change cannot be correct on top of a forgeable actor.

Behavior tightening, no migration: the only path it breaks is a member changing
an autopilot they hold no grant on. A run with no originator can no longer
write here.

Co-authored-by: CatCatUncle <[email protected]>
2026-09-07 16:46:07 +08:00

815 lines
26 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"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 / TaskID travel as X-Agent-ID / X-Task-ID execution context.
// They are NOT what makes the server treat a request as the agent's: the
// server strips both headers on entry and re-stamps them from the bound
// mat_ task token, so attribution follows the token, not these fields
// (MUL-3428). The CLI only sets them inside a daemon-managed task, where
// MULTICA_TOKEN is that mat_ token.
AgentID string
TaskID string
HTTPClient *http.Client
// Identity overrides. Empty values fall back to the package-level
// ClientPlatform / ClientVersion / ClientOS.
Platform string
Version string
OS string
}
// TaskTokenPrefix marks a task-scoped agent token, as minted by
// internal/auth.NewAgentTaskToken. A 401 on one is not the "your login
// expired" that a 401 on a member credential is: the token belongs to a single
// run and there is no sign-in that brings it back. Why it was rejected is not
// something the CLI can see — a terminal task is the usual cause, but a
// malformed token, one sent to the wrong server, and one dropped by an
// unrelated cleanup all look identical from here.
const TaskTokenPrefix = "mat_"
type HTTPError struct {
Method string
Path string
StatusCode int
Body string
// TaskScoped records that the failing request actually carried a
// task-scoped `mat_` token. It changes nothing about the request; it only
// lets FormatError tell a 401 worth signing in again for from one where
// the right move is to stop (GH #7522).
TaskScoped bool
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, strings.TrimSpace(e.Body))
}
// newHTTPError builds a *HTTPError from an error response (status >= 400),
// reading a capped slice of the body. Every Multica API helper funnels its
// >= 400 responses through this so the top-level FormatError / ExitCodeFor can
// classify the failure via errors.As(err, **HTTPError) regardless of which
// HTTP verb the command used.
func newHTTPError(method, path string, resp *http.Response) *HTTPError {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return &HTTPError{
Method: method,
Path: path,
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
TaskScoped: requestUsedTaskToken(resp),
}
}
// requestUsedTaskToken reports whether the request that produced resp actually
// sent a task token.
//
// Reading the client's own Token field would be wrong twice over. DownloadFile
// deliberately sends no Authorization header for an absolute signed URL, so a
// 401 from object storage would be reported as a rejected task token purely
// because the client happened to hold a `mat_` token. And Go strips Authorization
// across a cross-host redirect, so the request that was sent is not always the
// one the caller built. resp.Request is the request that actually went out,
// after redirects, which is the only thing this claim can honestly rest on.
func requestUsedTaskToken(resp *http.Response) bool {
if resp == nil || resp.Request == nil {
return false
}
return strings.HasPrefix(resp.Request.Header.Get("Authorization"), "Bearer "+TaskTokenPrefix)
}
// defaultHTTPTimeout is the per-request timeout for the CLI's HTTP client.
// It can be overridden with the MULTICA_HTTP_TIMEOUT environment variable
// (see httpTimeout). 30s is chosen over the historical 15s because complex
// networks (notably in mainland China) routinely need more than 15s to
// complete the TLS handshake plus request round-trip, which surfaced as an
// opaque "context deadline exceeded" to users.
const defaultHTTPTimeout = 30 * time.Second
// httpTimeout returns the HTTP client timeout, honoring MULTICA_HTTP_TIMEOUT.
// The value may be a Go duration string ("45s", "2m") or a plain integer
// number of seconds ("45"). Invalid or non-positive values fall back to the
// default.
func httpTimeout() time.Duration {
v := strings.TrimSpace(os.Getenv("MULTICA_HTTP_TIMEOUT"))
if v == "" {
return defaultHTTPTimeout
}
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return defaultHTTPTimeout
}
// apiContextGrace is added on top of the HTTP transport timeout when deriving
// a command-level context deadline, so the transport timeout (which produces a
// clean, classifiable "request timed out" error) is the one that fires rather
// than the outer context being canceled first.
const apiContextGrace = 5 * time.Second
// APITimeout returns the deadline budget for a single CLI API command. It is
// always at least the configured HTTP transport timeout (see httpTimeout,
// which honors MULTICA_HTTP_TIMEOUT) plus a small grace margin, so a
// command-level context never truncates an in-flight request below the timeout
// the user configured. This is the fix for command contexts that previously
// hardcoded a 15s deadline shorter than the 30s/env transport timeout.
func APITimeout() time.Duration {
return AtLeastAPITimeout(0)
}
// AtLeastAPITimeout returns max(min, APITimeout()). Use it for commands that
// need a larger floor than usual (for example file uploads, which historically
// used a 60s budget).
func AtLeastAPITimeout(min time.Duration) time.Duration {
budget := httpTimeout() + apiContextGrace
if min > budget {
return min
}
return budget
}
// APIContext derives a command-scoped context whose deadline is APITimeout().
// The returned cancel func must be called (typically via defer) to release
// resources. Commands should use this instead of context.WithTimeout with a
// hardcoded duration so the deadline always respects MULTICA_HTTP_TIMEOUT.
func APIContext(parent context.Context) (context.Context, context.CancelFunc) {
if parent == nil {
parent = context.Background()
}
return context.WithTimeout(parent, APITimeout())
}
// 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: httpTimeout()},
}
}
// clientCapabilities is the X-Client-Capabilities value every CLI request
// advertises. It is a protocol detail, not a user-visible flag: the CLI always
// knows how to handle these response shapes, so there is nothing for a caller
// to opt into.
//
// stable_attachment_urls asks bulk responses to return the stable
// /api/attachments/{id}/download path instead of a ~800-char CloudFront
// signature that is re-minted on every request (MUL-5372 / GitHub #5999). The
// CLI never hands an attachment URL to a native loader — `multica attachment
// download <id>` fetches a fresh signature from the single-attachment endpoint,
// which keeps signing regardless of this capability — so the signature in list
// payloads was pure cost: raw bytes, a per-attachment RSA sign, and bytes that
// differ on every read and therefore defeat agent prompt caching.
const clientCapabilities = "stable_attachment_urls"
func (c *APIClient) setHeaders(req *http.Request) {
req.Header.Set("X-Client-Capabilities", clientCapabilities)
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.
//
// On an HTTP error response (status >= 400) the returned error is a
// *HTTPError so callers can use errors.As to inspect the status code
// (for example to recognize a 404 from a server that does not expose a
// given endpoint and degrade gracefully). The error string format
// ("GET <path> returned <code>: <body>") is preserved by HTTPError.Error().
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)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodGet, path, resp)
}
if out == nil {
return nil
}
return wrapBodyRead(req, 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)
err = wrapTransport(req, err)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, newHTTPError(http.MethodGet, path, resp)
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return resp.Header, wrapBodyRead(req, err)
}
}
return resp.Header, nil
}
// DeleteJSON performs a DELETE request.
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
return c.DeleteJSONResponse(ctx, path, nil)
}
// DeleteJSONResponse performs a DELETE request and optionally decodes the JSON response.
func (c *APIClient) DeleteJSONResponse(ctx context.Context, path string, out any) 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)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodDelete, path, resp)
}
if out != nil {
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
}
return nil
}
// DeleteJSONWithBody performs a DELETE request with a JSON body.
func (c *APIClient) DeleteJSONWithBody(ctx context.Context, path string, body any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, 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)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodDelete, path, resp)
}
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)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPost, path, resp)
}
if out == nil {
return nil
}
return wrapBodyRead(req, 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)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPut, path, resp)
}
if out == nil {
return nil
}
return wrapBodyRead(req, 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)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPatch, path, resp)
}
if out == nil {
return nil
}
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
}
// AttachmentResponse mirrors the server's upload-file response.
type AttachmentResponse struct {
ID string `json:"id"`
URL string `json:"url"`
DownloadURL string `json:"download_url"`
// MarkdownURL is the durable, persistable URL to embed in markdown bodies
// (chat replies, comments). Unlike DownloadURL it never carries a TTL.
MarkdownURL string `json:"markdown_url"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
}
// 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)
err = wrapTransport(req, err)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", newHTTPError(http.MethodPost, "/api/upload-file", resp)
}
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
}
// UploadChatAttachment uploads a file via multipart form to /api/upload-file
// tagged with a chat task (task_id). The server binds the row to the assistant
// reply that task produces on completion. Returns the full AttachmentResponse
// (id + markdown_url) so the agent can embed the image inline in its reply.
func (c *APIClient) UploadChatAttachment(ctx context.Context, fileData []byte, filename, taskID string) (AttachmentResponse, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filepath.Base(filename))
if err != nil {
return AttachmentResponse{}, fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(fileData); err != nil {
return AttachmentResponse{}, fmt.Errorf("write file data: %w", err)
}
if taskID != "" {
if err := writer.WriteField("task_id", taskID); err != nil {
return AttachmentResponse{}, fmt.Errorf("write task_id field: %w", err)
}
}
if err := writer.Close(); err != nil {
return AttachmentResponse{}, fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
if err != nil {
return AttachmentResponse{}, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
// Honor a longer context deadline for large images, same as UploadFileWithURL.
httpClient := c.HTTPClient
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > httpClient.Timeout {
clientCopy := *httpClient
clientCopy.Timeout = remaining
httpClient = &clientCopy
}
}
resp, err := httpClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return AttachmentResponse{}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return AttachmentResponse{}, newHTTPError(http.MethodPost, "/api/upload-file", resp)
}
var result AttachmentResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return AttachmentResponse{}, fmt.Errorf("decode upload response: %w", err)
}
if result.ID == "" {
return AttachmentResponse{}, fmt.Errorf("upload response missing attachment id")
}
return result, nil
}
// UploadFileWithURL uploads a file via multipart form to /api/upload-file
// without associating it with an issue or comment. It decodes the full
// AttachmentResponse and returns the attachment ID and URL.
func (c *APIClient) UploadFileWithURL(ctx context.Context, fileData []byte, filename string) (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 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)
// Use a client that respects the context deadline for slow uploads
// (e.g. avatar uploads with 5MB files). The default HTTP client timeout
// shadows any longer context deadline.
httpClient := c.HTTPClient
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > httpClient.Timeout {
clientCopy := *httpClient
clientCopy.Timeout = remaining
httpClient = &clientCopy
}
}
resp, err := httpClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", "", newHTTPError(http.MethodPost, "/api/upload-file", resp)
}
var result AttachmentResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", fmt.Errorf("decode upload response: %w", err)
}
if result.URL == "" {
return "", "", fmt.Errorf("upload response missing attachment url")
}
// Allow empty ID: the server returns id="" in the fallback path where
// S3 upload succeeded but the attachment DB record failed. The file
// is still usable via its URL.
return result.ID, result.URL, nil
}
// ImportSkillFile imports a skill from a local archive (.skill / .zip) by
// POSTing it as multipart/form-data to /api/skills/import, alongside the
// on_conflict strategy. The structured import result is decoded into out.
func (c *APIClient) ImportSkillFile(ctx context.Context, fileData []byte, filename, onConflict string, out any) 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 onConflict != "" {
if err := writer.WriteField("on_conflict", onConflict); err != nil {
return fmt.Errorf("write on_conflict 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/skills/import", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
// Respect a longer context deadline for slow uploads, mirroring
// UploadFileWithURL: the default client timeout would otherwise shadow it.
httpClient := c.HTTPClient
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > httpClient.Timeout {
clientCopy := *httpClient
clientCopy.Timeout = remaining
httpClient = &clientCopy
}
}
resp, err := httpClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPost, "/api/skills/import", resp)
}
if out == nil {
return nil
}
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
}
// UploadPrivatePlugin installs workspace-private Plugin archive bytes. The
// filename is transport metadata only; the Server derives source identity from
// validated manifest content and digests.
func (c *APIClient) UploadPrivatePlugin(ctx context.Context, path string, archive []byte, filename string, out any) error {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("artifact", filepath.Base(filename))
if err != nil {
return fmt.Errorf("create Plugin artifact form file: %w", err)
}
if _, err := part.Write(archive); err != nil {
return fmt.Errorf("write Plugin artifact: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("close Plugin artifact upload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return newHTTPError(http.MethodPost, path, resp)
}
if out == nil {
return nil
}
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
}
// 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.
//
// The URL may be absolute (a signed CloudFront/S3 URL) or relative
// (a server-relative path like "/api/attachments/{id}/download" or
// "/uploads/...") depending on how the
// server is configured. Relative URLs are resolved against the client's
// BaseURL and sent with the standard auth headers; absolute URLs are
// used as-is so that their query-string signatures are not disturbed.
func (c *APIClient) DownloadFile(ctx context.Context, downloadURL string) ([]byte, error) {
isRelative := !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://")
if isRelative {
if c.BaseURL == "" {
return nil, fmt.Errorf("download URL %q is relative but client has no BaseURL", downloadURL)
}
downloadURL = c.BaseURL + downloadURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, err
}
if isRelative {
c.setHeaders(req)
}
resp, err := c.HTTPClient.Do(req)
err = wrapTransport(req, err)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, newHTTPError(http.MethodGet, downloadURL, resp)
}
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)
err = wrapTransport(req, err)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 400 {
return "", &HTTPError{
Method: http.MethodGet,
Path: "/health",
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
TaskScoped: requestUsedTaskToken(resp),
}
}
return strings.TrimSpace(string(data)), nil
}