mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
* fix(cli): tell a revoked task token the task is over, not to sign in again The CLI's generic 401 copy reads "Your session has expired or you are not signed in. Run `multica login` to sign in again. On a self-hosted or non-OAuth setup, ask your administrator for valid credentials." That is correct advice for a person and actively harmful for an agent. A task's mat_ token is revoked the moment the task reaches a terminal state, so every call an orphaned agent makes after cancellation gets that message — and it reads as an instruction to go find a working credential. In GH #7522 one did exactly that: it read the daemon owner's profile PAT out of ~/.multica and kept working under the member's identity. A 401 on a task-scoped token now says the token was revoked because the task ended, and not to retry with another credential. The status code alone cannot distinguish the two cases, so HTTPError records which kind of credential the request carried; nothing else about the client is read, and exit classification is unchanged (still ExitAuth). This is defence in depth, not the fix for #7522 — cancellation not terminating the process tree is (PR #7531). It removes the motive. Co-authored-by: multica-agent <[email protected]> * fix(cli): base the task-token claim on the request, not the client Review found three problems with the first pass. TaskScoped read the client's own Token field, which answers "what does this client hold", not "what did the failing request send". DownloadFile deliberately sends no Authorization header for an absolute signed URL, so a 401 from object storage would have been reported to an agent as a finished task purely because the client happened to hold a mat_ token. It now reads resp.Request, which is the request that actually went out — also correct across a redirect that strips the header, which the caller's own request object would not be. newHTTPError goes back to a plain function. A test drives a real client through both paths instead of hand-building the error, and fails if the claim moves back to client state. The copy asserted a cause the CLI cannot verify: 401 also covers a malformed token, one sent to the wrong server, and one dropped by an unrelated cleanup. It now says the token was rejected and to stop, without naming a reason. "Stop, and do not fall back to a profile or member credential" is true in every one of those cases; "the task finished" is not. The kind is renamed to KindTaskTokenRejected to match. The Chinese copy used 任务 for an agent execution. Per the glossary, 任务 is the product entity a user files (an issue) and an agent execution stays lowercase `task`; the old wording said the user's issue was rejected. A test now fails if 任务 reappears in this message. Co-authored-by: multica-agent <[email protected]> * docs(cli): stop asserting the cause in the comments too The runtime copy no longer claims why a task token was rejected, but three comments still did: TaskTokenPrefix called a 401 "a normal end-of-task signal", TaskScoped described the token as "finished", and the wiring test quoted the old wording. A reader following the comments would have reintroduced exactly the claim this PR removed. They now say what the CLI can actually see — a task token was rejected, there is no sign-in that brings it back, and the reason is not visible from here. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
809 lines
25 KiB
Go
809 lines
25 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 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
|
|
}
|
|
|
|
// 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 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, 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 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 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 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 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 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 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
|
|
}
|