Files
multica/server/internal/cli/errors.go
60408ef252 MUL-7090 fix(autopilot): let an agent trigger a manual run for the human it acts for (#8099)
* fix(autopilot): let an agent trigger a manual run for the human it acts for

MUL-6951 replaced canCreatorInvokeAgent, which judged automatic dispatch by the
autopilot CREATOR and needed no trigger, with ResolveAutopilotTriggerPrincipal,
which resolves the FIRING TRIGGER's creator. The manual entry point passes no
trigger by construction, so the resolver returned nothing on its first line and
every manual trigger from a non-member actor was refused. Scheduled runs kept
working — they have a trigger row — which is what made this read as a
configuration problem rather than a regression. Reported as #8078.

The refusal was also self-contradicting: requireAutopilotWrite had already
resolved a real human from the request (for a task token, the runtime owner the
middleware binds) and confirmed they may trigger this autopilot. Three lines
later memberActorUserID discarded that human for not being a "member" actor, and
the empty identity fell into the automation branch.

TriggerAutopilot now resolves the human the caller acts FOR:

  - a member acts for themselves, judged exactly as before;
  - an agent acts for its run's ORIGINATOR, which is how every other invoke
    surface judges an agent actor (MUL-3963 / canInvokeAgent), including this
    file's own private-leader gate on save.

Reading the runtime owner instead would have been a smaller diff and the wrong
grant: it makes every agent a standing proxy for its machine's owner, so anyone
who can talk to the agent inherits that owner's autopilot rights. The originator
must ALSO hold write access, so an agent can only press a "Run now" that the
human ordering it could press themselves.

No resolvable human is refused outright (403). The alternative was the
workspace-broad exception invokeAgentDecision grants unattributed agent/system
principals on a public_to-workspace agent, which would have admitted an
originator-less chain here too. Rejected per Bohan: a manual trigger is by
definition somebody's decision, so a chain arriving with no human at its top
means something upstream dropped it, and that should surface.

Two things this does NOT change, both deliberate:

  - requireAutopilotWrite still judges the request's authenticated user. That it
    resolves the runtime owner for an agent is pre-existing and shared by all
    eight autopilot write endpoints; narrowing it is a separate change with its
    own blast radius, filed separately.
  - The run's attribution stays direct_human to the resolved human rather than
    delegation. attributionForIssueTask treats a valid actor as direct_human by
    contract, shared with assign/promote/rerun, so labelling this one path
    delegation means threading a new parameter through TaskService's enqueue
    chain. The human on the hook is correct either way; only the label is
    coarser. Filed as follow-up rather than half-applied to one execution mode.

Two independent defects the same report exposed:

  - The skip reason blamed "the trigger records no owner" on a path that has no
    trigger, sending #8078's reporter hunting a broken trigger owner for a day.
    It now branches on whether a trigger exists.
  - `multica autopilot trigger` printed "Autopilot triggered" and exited 0 for a
    skipped run, so the operator — and any agent running it for them — read
    success. It now mirrors the web client's runNowToastKind whitelist: only
    issue_created and running are success, everything else prints failure_reason
    and exits non-zero. --output json still writes the run to stdout first.
    This is the half that made the bug silent, and it covers every skip reason,
    not just this one.

The MUL-4525 admission test asserted that (no actor, no trigger) must be refused
and called it "automation dispatch" — but that is exactly the argument pair a
manual trigger produced, so the test pinned the regression and would have
blocked the fix. Rewritten to assert the no-principal phrasing and, explicitly,
that the reason does not blame a trigger row that does not exist.

Verification (real PostgreSQL 17): new handler suite covers agent-with-
originator (dispatches), agent-without-originator (403, no run row), originator
lacking write (403, no run row) and the untouched member path. The acceptance
test was confirmed to fail against the pre-fix handler with exactly the reported
symptom — status skipped, invocation_not_allowed. internal/service and
internal/handler pass; the one internal/handler failure (TestPluginRoutes-
RequireWorkspaceAdmin, a duplicate user email) and the cmd/multica failures
reproduce identically on a clean tree.

Refs #8078

Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): judge a manual trigger by the ordering human alone

Addresses Elon's review of #8099. Two must-fixes, both confirmed against the
code before changing it.

Must-fix 1 — the endpoint still required TWO humans to hold the grant.
requireAutopilotWrite ran in front of the originator check, and under a task
token it judges the RUNTIME OWNER. So the effective rule was "runtime owner AND
originator", not "the human who ordered it" — the opposite failure from the one
#8078 reported, and it broke a case a member is plainly entitled to: U created
the autopilot and may invoke an agent running on O's machine, O holds no grant,
so U could press Run now themselves but could not ask the agent to. It also
contradicted the docs this PR shipped, which already promised originator-based
authorization.

requireAutopilotTriggerInvoker is now this endpoint's only write gate.
requireAutopilotWrite is not called alongside it. Workspace tenancy for the
authenticated caller is unaffected: /api/autopilots sits inside the router's
RequireWorkspaceMember group, so dropping the second check loses no isolation —
it only stops a third party's machine ownership from vetoing the ordering
human's own rights. The other seven autopilot write endpoints are untouched and
stay with MUL-7108.

Authorization also moved ahead of the status check, so an unauthorized caller
cannot tell an active autopilot from a paused one.

The positive test could not have caught this: it let testUserID be runtime
owner, autopilot creator and originator at once. TestTriggerAutopilot_
RuntimeOwnerNeedsNoGrant now separates all three — the ordering human creates
the autopilot, the runtime owner is a plain member with no grant — and was
verified to fail against the previous commit with exactly the 403 Elon
described.

Must-fix 2 — the actionable refusal never reached the person running the
command. The handler named the cause, but FormatError collapses every 403 into
generic "no access" copy, so `calling run records no originator` died in the
response body. That is the same silence #8078 was about, one layer up.

Both refusals now carry a stable code (autopilot_trigger_no_originator,
autopilot_trigger_forbidden) via writeErrorCode, and cli.ServerErrorCode reads
it. The trigger command opts those two out of the generic copy and prints
guidance. The branch is on the code, never on the English sentence: copy edits
and translation must not silently disable it. The opt-out is per-code, so every
403 this command has not named keeps the deliberately vague copy that stops a
refusal confirming a resource exists — pinned by a test.

The two 403s deliberately share one sentence and one code whether the ordering
human is a non-member or merely ungranted, so the endpoint cannot be used to
probe workspace membership.

Verification (real PostgreSQL 17):
- TestTriggerAutopilot_RuntimeOwnerNeedsNoGrant fails against the previous
  commit's handler and passes now; the other four trigger tests still pass.
- Both 403 tests assert the code, not just the status — the CLI's output
  depends on it.
- New cmd/multica test runs the real cli.FormatError over both refusals, since
  the defect being guarded lives in FormatError's collapse, and asserts an
  un-opted-in 403 still gets the generic copy.
- New internal/cli TestServerErrorCode covers missing code, non-JSON, malformed
  JSON, prose in `code`, and a non-HTTP error.
- internal/service passes. Pre-existing unrelated failures reproduce identically
  on a clean tree: TestPluginRoutesRequireWorkspaceAdmin and
  TestCommentSourceContextLifecycle in internal/handler, four TestCLIConfig_*
  in internal/cli.
- gofmt, go vet, git diff --check clean.

Docs corrected at both sites to state the contract explicitly: the machine
owner's grants are not consulted, only workspace membership.

Refs #8078

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-07 14:57:37 +08:00

659 lines
23 KiB
Go

package cli
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"syscall"
)
// ErrorKind is a coarse, user-facing classification of an error. The CLI's
// many internal error strings ("resolve issue: ...", raw net/http messages,
// JSON bodies) are not meaningful to end users; FormatError collapses them
// into one of these kinds and renders a friendly, localized message.
//
// The zero value is intentionally KindNetworkTimeout-adjacent only by index;
// always classify explicitly rather than relying on the zero value.
type ErrorKind int
const (
// Network / transport layer (errors returned by http.Client.Do).
KindNetworkTimeout ErrorKind = iota // context deadline exceeded / i/o timeout
KindNetworkDNS // no such host
KindNetworkRefused // connection refused
KindNetworkTLS // x509 / tls handshake failures
KindNetworkOffline // catch-all: host unreachable, reset, etc.
// KindNetworkStalled is a transfer that stopped producing bytes (see
// StallError). Distinct from KindNetworkTimeout because the remedy is
// different: a timeout says "this took too long", a stall says "this went
// quiet", and only the latter is unaffected by raising a time limit.
KindNetworkStalled
// HTTP status layer.
KindAuthRequired // 401
// KindTaskTokenRejected is a 401 on a task-scoped token. HTTPError.Kind()
// never returns it — the status code alone cannot tell the two apart — so
// it is selected in userMessage, where the credential the request actually
// sent is known. Exit classification is unchanged: still an auth failure.
KindTaskTokenRejected
KindForbidden // 403
KindNotFound // 404
KindConflict // 409
KindValidation // 400 / 422
KindRateLimited // 429
KindServerError // 5xx
// Anything we could not classify.
KindUnknown
)
// Tiered process exit codes. Stable so users can branch on them in scripts.
const (
ExitGeneric = 1 // anything not covered below
ExitNetwork = 2 // any KindNetwork*
ExitAuth = 3 // 401 / 403
ExitNotFound = 4 // 404
ExitValidation = 5 // 400 / 422
)
// IsNetwork reports whether the kind is a transport-layer failure.
func (k ErrorKind) IsNetwork() bool {
switch k {
case KindNetworkTimeout, KindNetworkDNS, KindNetworkRefused, KindNetworkTLS, KindNetworkOffline, KindNetworkStalled:
return true
default:
return false
}
}
// String returns a stable, snake_case identifier for the kind. It is used in
// --debug output and is safe to log or branch on; it is not user-facing copy
// (see kindMessages / messageFor for that).
func (k ErrorKind) String() string {
switch k {
case KindNetworkTimeout:
return "network_timeout"
case KindNetworkDNS:
return "network_dns"
case KindNetworkRefused:
return "network_refused"
case KindNetworkTLS:
return "network_tls"
case KindNetworkOffline:
return "network_offline"
case KindNetworkStalled:
return "network_stalled"
case KindAuthRequired:
return "auth_required"
case KindTaskTokenRejected:
return "task_token_rejected"
case KindForbidden:
return "forbidden"
case KindNotFound:
return "not_found"
case KindConflict:
return "conflict"
case KindValidation:
return "validation"
case KindRateLimited:
return "rate_limited"
case KindServerError:
return "server_error"
case KindUnknown:
return "unknown"
default:
return fmt.Sprintf("ErrorKind(%d)", int(k))
}
}
// NetworkError wraps a transport-layer error (the error returned by
// http.Client.Do, before any HTTP status is available). It strips the raw
// URL out of the user-facing message while preserving the original error for
// --debug output and errors.Is/As inspection.
type NetworkError struct {
Kind ErrorKind
Op string // e.g. "GET /api/issues/abc" — shown only in --debug
Err error // the original net/http error
}
func (e *NetworkError) Error() string {
if e.Op != "" {
return fmt.Sprintf("%s: %s", e.Op, e.Err.Error())
}
return e.Err.Error()
}
func (e *NetworkError) Unwrap() error { return e.Err }
// UserMessageError attaches a command-specific, user-facing message to an
// underlying error. FormatError shows Msg verbatim (in preference to the
// generic kind-based copy it would otherwise derive from a wrapped
// *NetworkError / *HTTPError), so command-level guidance — e.g. a `multica
// login` failure that is more helpful than the generic 401/timeout line — is
// visible in the default (non-debug) output.
//
// It preserves Unwrap(), so ExitCodeFor still classifies by the underlying
// typed error and --debug still prints the full original chain.
type UserMessageError struct {
Msg string
Err error
}
func (e *UserMessageError) Error() string {
if e.Err != nil {
return e.Msg + ": " + e.Err.Error()
}
return e.Msg
}
func (e *UserMessageError) Unwrap() error { return e.Err }
// WithUserMessage wraps err with a user-facing message that FormatError will
// surface by default. It returns nil when err is nil so it can be used inline
// in a `return` without an extra check.
func WithUserMessage(msg string, err error) error {
if err == nil {
return nil
}
return &UserMessageError{Msg: msg, Err: err}
}
// Kind maps an HTTPError's status code onto an ErrorKind.
func (e *HTTPError) Kind() ErrorKind {
switch e.StatusCode {
case 401:
return KindAuthRequired
case 403:
return KindForbidden
case 404:
return KindNotFound
case 409:
return KindConflict
case 400, 422:
return KindValidation
case 429:
return KindRateLimited
default:
if e.StatusCode >= 500 {
return KindServerError
}
return KindUnknown
}
}
// classifyNetworkError inspects a transport-layer error and returns the
// matching network ErrorKind. It prefers typed inspection (errors.As /
// errors.Is) and falls back to string matching for cases the standard library
// does not expose as distinct types.
func classifyNetworkError(err error) ErrorKind {
if err == nil {
return KindUnknown
}
// A stalled transfer is checked first: the guard implements it by
// canceling the request context, so the underlying error would otherwise
// read as a generic cancellation and lose the reason.
var stalled *StallError
if errors.As(err, &stalled) {
return KindNetworkStalled
}
// Timeouts (context deadline or socket i/o timeout).
if errors.Is(err, context.DeadlineExceeded) {
return KindNetworkTimeout
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return KindNetworkTimeout
}
// DNS resolution failures.
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return KindNetworkDNS
}
// TLS / certificate failures.
var certVerifyErr *tls.CertificateVerificationError
if errors.As(err, &certVerifyErr) {
return KindNetworkTLS
}
var unknownAuthorityErr x509.UnknownAuthorityError
if errors.As(err, &unknownAuthorityErr) {
return KindNetworkTLS
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
return KindNetworkTLS
}
var certInvalidErr x509.CertificateInvalidError
if errors.As(err, &certInvalidErr) {
return KindNetworkTLS
}
// Connection refused.
if errors.Is(err, syscall.ECONNREFUSED) {
return KindNetworkRefused
}
// String fallbacks for anything not surfaced as a typed error.
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "context deadline exceeded"), strings.Contains(msg, "timeout"), strings.Contains(msg, "timed out"):
return KindNetworkTimeout
case strings.Contains(msg, "no such host"), strings.Contains(msg, "server misbehaving"), strings.Contains(msg, "name resolution"):
return KindNetworkDNS
case strings.Contains(msg, "connection refused"):
return KindNetworkRefused
case strings.Contains(msg, "x509"), strings.Contains(msg, "certificate"), strings.Contains(msg, "tls"):
return KindNetworkTLS
}
return KindNetworkOffline
}
// wrapTransport converts a raw transport error returned by http.Client.Do
// into a *NetworkError. It returns nil when err is nil so call sites can
// reassign unconditionally:
//
// resp, err := c.HTTPClient.Do(req)
// err = wrapTransport(req, err)
// if err != nil { return err }
func wrapTransport(req *http.Request, err error) error {
if err == nil {
return nil
}
op := ""
if req != nil && req.URL != nil {
op = req.Method + " " + req.URL.Path
}
return &NetworkError{Kind: classifyNetworkError(err), Op: op, Err: err}
}
// wrapBodyRead classifies an error raised while reading or decoding a
// response body.
//
// wrapTransport only sees errors from http.Client.Do, which returns as soon as
// the response headers arrive. Everything that goes wrong afterwards — the
// case #7498 actually reports, a body that never finishes arriving — surfaces
// out of the JSON decoder instead, and used to reach the user as a raw
// "context deadline exceeded ... while reading body". A transport failure is
// still a transport failure when the decoder is the one that notices it; a
// genuine malformed-JSON error is returned unchanged.
//
// io.ErrUnexpectedEOF is deliberately on the network side of that line. It is
// what the decoder reports for a body that simply stops — a dropped
// connection, a proxy cutting the response — which is vastly the more common
// cause than a server that emits syntactically truncated JSON. Well-formed
// nonsense (the ordinary server bug) raises *json.SyntaxError and is left
// alone.
func wrapBodyRead(req *http.Request, err error) error {
if err == nil {
return nil
}
var stalled *StallError
if errors.As(err, &stalled) {
return wrapTransport(req, err)
}
var netErr net.Error
if errors.As(err, &netErr) ||
errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, context.Canceled) ||
errors.Is(err, io.ErrUnexpectedEOF) {
return wrapTransport(req, err)
}
return err
}
// Language is the language FormatError renders messages in.
type Language int
const (
LangEN Language = iota
LangZH
)
// DetectLanguage chooses the output language from the environment. English is
// the default (matching the CLI's help output); a Chinese locale in LC_ALL,
// LC_MESSAGES, or LANG (in that precedence order) switches to Chinese.
func DetectLanguage() Language {
for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
if v == "" {
continue
}
if strings.HasPrefix(v, "zh") {
return LangZH
}
// First locale variable that is set wins; if it is not Chinese we
// fall through to English without consulting lower-precedence vars.
return LangEN
}
return LangEN
}
// kindMessages holds the {English, Chinese} user-facing message for each kind.
var kindMessages = map[ErrorKind][2]string{
KindNetworkTimeout: {
"Request timed out: the server did not respond in time. Check your network connection or try again later. You can raise the limit with MULTICA_HTTP_TIMEOUT.",
"请求超时:服务器未在规定时间内响应。请检查网络连接或稍后重试。可通过 MULTICA_HTTP_TIMEOUT 调高超时时间。",
},
KindNetworkStalled: {
"Transfer stalled: the connection stopped sending data before the response was complete. Check your network connection or try again. You can raise the no-progress budget with MULTICA_HTTP_STALL_TIMEOUT.",
"传输中断:响应尚未接收完毕,连接就停止发送数据。请检查网络连接或重试。可通过 MULTICA_HTTP_STALL_TIMEOUT 调高无进展等待时间。",
},
KindNetworkDNS: {
"Could not resolve the Multica server address. Check your network connection or the --server-url setting.",
"无法解析 Multica 服务器地址。请检查网络连接或 --server-url 配置。",
},
KindNetworkRefused: {
"Could not connect to the Multica server. Make sure the server address is correct and reachable.",
"无法连接到 Multica 服务器。请确认服务器地址正确且网络可达。",
},
KindNetworkTLS: {
"Could not establish a secure connection to the Multica server (TLS/certificate error). Check your system clock and CA certificates.",
"无法与 Multica 服务器建立安全连接(TLS/证书错误)。请检查系统时间和 CA 证书。",
},
KindNetworkOffline: {
"Could not reach the Multica server. Check your network connection.",
"无法访问 Multica 服务器。请检查网络连接。",
},
KindAuthRequired: {
"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.",
"登录已过期或尚未登录。请运行 `multica login` 重新登录。自托管或非 OAuth 场景请联系管理员获取有效凭证。",
},
KindTaskTokenRejected: {
"This task token was rejected and is no longer usable. Stop here: do not retry, and do not fall back to a profile or member credential, because anything done with one would run as that person rather than as this task. Only the runtime that started this task can supply a valid task token.",
"这个 task token 已被拒绝,不再可用。请到此为止:不要重试,也不要改用 profile 或成员凭证 —— 用它们执行的任何操作都会以那个成员的身份运行,而不是以这次 task 的身份运行。只有启动这次 task 的运行时才能提供有效的 task token。",
},
KindForbidden: {
"You do not have permission to access this resource. Check that you are in the right workspace, or ask an administrator to grant access.",
"无权访问该资源。请确认当前 workspace 是否正确,或联系管理员授予权限。",
},
KindNotFound: {
"The requested resource was not found. Check the ID, or run the matching `list` command to see what exists in this workspace.",
"未找到请求的资源。请核对 ID,或运行对应的 list 命令查看当前 workspace 中已有的内容。",
},
KindConflict: {
"The request conflicts with the current state of the resource (it may already exist or have changed since you last fetched it). Re-fetch the latest state and try again.",
"请求与资源的当前状态冲突(可能已存在,或自上次获取后已被修改)。请重新获取最新状态后再试。",
},
KindValidation: {
"The request was invalid. Check the values you provided; run the command with --help to see the expected format.",
"请求无效。请检查所填写的参数;可用 --help 查看期望的格式。",
},
KindRateLimited: {
"Too many requests. Please wait a moment and try again; if this keeps happening, reduce how frequently you call the API.",
"请求过于频繁。请稍候重试;若持续出现,请降低 API 调用频率。",
},
KindServerError: {
"The Multica service is temporarily unavailable (server error). Please try again later; if it persists, contact support. Re-run with --debug to see the raw server response.",
"Multica 服务暂时不可用(服务器错误)。请稍后重试;若持续出现请联系支持。可加 --debug 查看服务器原始响应。",
},
KindUnknown: {
"An unexpected error occurred.",
"发生未知错误。",
},
}
// serverMessagePrefixes lists the kinds whose response body carries a
// hand-written, actionable message worth showing instead of the generic
// template, with the {English, Chinese} lead-in used to introduce it.
//
// 409 belongs here because the generic conflict copy is not merely vague, it
// points the wrong way: it reads as a transient race ("re-fetch the latest
// state and try again") while every conflict this API returns is a
// deterministic refusal that names its own fix ("a skill with this name
// already exists", "set parent_id (--parent) to <id>"). Agents took the retry
// hint literally and burned hours re-sending an unchanged request (GH #6264,
// GH #5948), and the server-side wording added in MUL-4417 never reached them.
var serverMessagePrefixes = map[ErrorKind][2]string{
KindValidation: {"Invalid request: ", "请求无效:"},
KindConflict: {"Request conflict: ", "请求冲突:"},
}
// messageFor returns the localized message for a kind.
func messageFor(kind ErrorKind, lang Language) string {
m, ok := kindMessages[kind]
if !ok {
m = kindMessages[KindUnknown]
}
if lang == LangZH {
return m[1]
}
return m[0]
}
// FormatError translates an error into a single user-facing line (or a
// detailed multi-line block when debug is set). It is the only user-facing
// translation entry point and is meant to be called once, at the top level
// (main.go), on the error bubbling up from a command.
//
// When debug is false it skips the internal verb chain ("resolve issue: ...")
// and the raw URL/JSON body, showing only the friendly message. When debug is
// true (or MULTICA_DEBUG is set) it additionally prints the full original
// error chain for troubleshooting.
func FormatError(err error, debug bool) string {
if err == nil {
return ""
}
lang := DetectLanguage()
base := userMessage(err, lang)
if debug || debugEnabled() {
return base + "\n\n" + debugDetail(err)
}
return base
}
// userMessage produces the friendly message for the root cause of err.
func userMessage(err error, lang Language) string {
// A command-supplied user-facing message takes precedence over the generic
// kind-based copy, so command-specific guidance (e.g. sign-in failures) is
// visible by default. Unwrap() is preserved, so ExitCodeFor and --debug
// still see the underlying typed error.
var um *UserMessageError
if errors.As(err, &um) {
return um.Msg
}
// Transport-layer failure.
var netErr *NetworkError
if errors.As(err, &netErr) {
return messageFor(netErr.Kind, lang)
}
// HTTP status failure.
var httpErr *HTTPError
if errors.As(err, &httpErr) {
kind := httpErr.Kind()
// A 401 on a task token is not a login problem, and the generic copy
// below is the wrong instruction for whoever reads it: it says to sign
// in again or ask an administrator for valid credentials. An
// autonomous agent can act on that, and one did — after its task token
// stopped working mid-run it read the daemon owner's profile PAT and
// kept going under the member's identity (GH #7522).
//
// What the copy must not do is guess *why*. The usual cause is the
// task reaching a terminal state, but the same 401 covers a malformed
// token, one sent to the wrong server, and one dropped by an unrelated
// cleanup. "Stop" is true in every one of those cases; "the task
// finished" is not.
if kind == KindAuthRequired && httpErr.TaskScoped {
return messageFor(KindTaskTokenRejected, lang)
}
// Validation and conflict errors carry a useful server-provided
// message; surface it instead of the generic line. A body we cannot
// recognize still falls back to the template, so this never dumps a
// raw response at the user.
if prefix, ok := serverMessagePrefixes[kind]; ok {
if serverMsg := extractServerMessage(httpErr.Body); serverMsg != "" {
if lang == LangZH {
return prefix[1] + serverMsg
}
return prefix[0] + serverMsg
}
}
return messageFor(kind, lang)
}
// Not a recognized typed error: this is typically a local/business error
// whose message is already meant for the user (e.g. a missing argument or
// a validation message constructed in a command). Show it as-is.
return strings.TrimSpace(err.Error())
}
// extractServerMessage tries to pull a human-readable message out of a JSON
// error body like {"error":"..."} or {"message":"..."}. Returns "" if the
// body is not JSON or has no recognizable message field.
//
// A few endpoints put a stable machine code in "error" and the prose in
// "message" (the issue-table cursor responses do this). Prose always wins;
// a bare code is kept only as a last resort so the user still gets something
// greppable when no sentence is on offer.
func extractServerMessage(body string) string {
body = strings.TrimSpace(body)
if body == "" || body[0] != '{' {
return ""
}
var parsed map[string]any
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
return ""
}
var code string
for _, key := range []string{"error", "message", "detail", "title"} {
v, ok := parsed[key]
if !ok {
continue
}
s, ok := v.(string)
if !ok {
continue
}
if s = strings.TrimSpace(s); s == "" {
continue
}
if looksLikeMachineCode(s) {
if code == "" {
code = s
}
continue
}
return s
}
return code
}
// ServerErrorCode returns the stable `code` a server error body carries, or ""
// when err is not an HTTP failure or the body has no code.
//
// It exists so a command can recognize ONE specific refusal and print guidance
// for it without matching on the English sentence, which changes with copy edits
// and disappears under translation. Statuses whose generic copy is deliberately
// vague — 403 above all, where naming the cause could confirm a resource exists —
// keep that copy for every code a command has not explicitly opted into.
func ServerErrorCode(err error) string {
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
return ""
}
body := strings.TrimSpace(httpErr.Body)
if body == "" || body[0] != '{' {
return ""
}
var parsed struct {
Code string `json:"code"`
}
if jsonErr := json.Unmarshal([]byte(body), &parsed); jsonErr != nil {
return ""
}
if !looksLikeMachineCode(parsed.Code) {
return ""
}
return parsed.Code
}
// looksLikeMachineCode reports whether s is a bare identifier such as
// "cursor_query_mismatch" rather than a sentence meant for a person. The test
// is deliberately narrow — lowercase word characters only — so that ordinary
// prose in any language, including Chinese without ASCII spaces, is never
// mistaken for a code.
func looksLikeMachineCode(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-', r == '.':
default:
return false
}
}
return true
}
// debugDetail renders the full original error chain plus any structured
// details from typed errors, for --debug / MULTICA_DEBUG output.
func debugDetail(err error) string {
var sb strings.Builder
sb.WriteString("[debug] ")
sb.WriteString(err.Error())
var netErr *NetworkError
if errors.As(err, &netErr) {
fmt.Fprintf(&sb, "\n[debug] network: op=%q kind=%s cause=%v", netErr.Op, netErr.Kind, netErr.Err)
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
fmt.Fprintf(&sb, "\n[debug] http: %s %s status=%d body=%s",
httpErr.Method, httpErr.Path, httpErr.StatusCode, strings.TrimSpace(httpErr.Body))
}
return sb.String()
}
// debugEnabled reports whether MULTICA_DEBUG requests debug output.
func debugEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("MULTICA_DEBUG"))) {
case "", "0", "false", "no", "off":
return false
default:
return true
}
}
// ExitCodeFor maps an error onto a tiered process exit code so callers can
// branch in scripts: network=2, auth(401/403)=3, not-found(404)=4,
// validation(400/422)=5, everything else=1.
func ExitCodeFor(err error) int {
if err == nil {
return 0
}
var netErr *NetworkError
if errors.As(err, &netErr) {
return ExitNetwork
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
switch httpErr.Kind() {
case KindAuthRequired, KindForbidden:
return ExitAuth
case KindNotFound:
return ExitNotFound
case KindValidation:
return ExitValidation
default:
return ExitGeneric
}
}
return ExitGeneric
}