package mcp import ( "context" "encoding/json" "errors" "fmt" "net/http" "regexp" "strings" ) // ───────────────────────────────────────────────────────────────────── // Structured MCP error envelopes (TASK-973) // // Replaces raw CLI stderr passthrough with a closed taxonomy of error // codes the model can branch on. The agent receives a JSON envelope // like: // // { // "error": { // "code": "no_workspace", // "message": "No workspace context — pass workspace= or call pad_set_workspace first.", // "hint": "Available workspaces: docapp, pad-web. Or run pad workspace init.", // "available_workspaces": [{"slug": "docapp", "default": true}, ...] // } // } // // instead of an opaque string like "no workspace linked. Run 'pad // workspace init'". Closed code set means the model can implement // recovery logic per-code rather than parsing free-form text. // // Both ExecDispatcher (stderr classification) and HTTPHandlerDispatcher // (HTTP status mapping) feed into the same taxonomy. Mirror impl on // the remote side is TASK-977 — it inherits the type definitions from // here and adds a privacy-preserving available_workspaces filter. // ───────────────────────────────────────────────────────────────────── // ErrorCode is one of the closed-set MCP error codes. Enumerated // constants below; do not introduce a new code without updating the // docs (server instructions block + getpad.dev/mcp/local). type ErrorCode string const ( // ErrNoWorkspace fires when no workspace context resolves // (no explicit param, no session default, no CWD .pad.toml). // Populates available_workspaces from `pad workspace list`. ErrNoWorkspace ErrorCode = "no_workspace" // ErrUnknownWorkspace fires when a slug is supplied but doesn't // match any workspace the user can read. Same available_workspaces // hint as ErrNoWorkspace. ErrUnknownWorkspace ErrorCode = "unknown_workspace" // ErrAuthRequired fires when no valid credentials are present — // CLI: ~/.pad/credentials.json missing or expired; HTTP: 401. ErrAuthRequired ErrorCode = "auth_required" // ErrPermissionDenied fires when authentication succeeds but the // operation is refused. HTTP: 403. details.reason names the gate when the // server said which (BUG-3864): role, token_scope, no_access or // workspace_not_authorized. With no reason, the message is the only // account of the cause, and the hint claims none. ErrPermissionDenied ErrorCode = "permission_denied" // ErrEmailNotVerified fires when a Cloud account whose email address is // unverified attempts a write (BUG-3864). The server's own code on every // door that enforces it. Not permission_denied: no role, token or grant // fixes it, and retrying never helps until the address is verified. ErrEmailNotVerified ErrorCode = "email_not_verified" // ErrItemNotFound fires when an item ref / slug doesn't resolve. // Distinct from ErrNotFound: this code is reserved for the // specific case where a tool was looking up an item by ref // (e.g. pad_item show TASK-7 against a missing ref). The hint // then references pad_item search / list as recovery paths. ErrItemNotFound ErrorCode = "item_not_found" // ErrNotFound fires for resource-shaped 404s that AREN'T item // lookups — workspace doesn't exist, collection list returns // 404, dashboard route 404s, etc. Pre-TASK-1078 these all // collapsed to ErrItemNotFound, which misled agents into // thinking an item was the missing thing. Recovery is // "verify the slug / path you passed", not "search for items." ErrNotFound ErrorCode = "not_found" // ErrValidationFailed fires on bad input — required field missing, // enum value out of range, malformed JSON. HTTP: 422. ErrValidationFailed ErrorCode = "validation_failed" // ErrConflict fires when an operation collides with concurrent // state (e.g. version mismatch on update). HTTP: 409. ErrConflict ErrorCode = "conflict" // ErrWorkspaceRequired fires when a tool needs a workspace // context but the OAuth token's allow-list contains zero or // multiple workspaces (no unambiguous auto-default available // per TASK-1076). The hint points at pad_workspace list + // passing workspace= explicitly. Distinct from ErrNoWorkspace // (which is the local-CLI "no .pad.toml found" case). ErrWorkspaceRequired ErrorCode = "workspace_required" // ErrBackendUnreachable fires when the dispatcher's synthesized // HTTP request never reaches a meaningful response — connection // refused, DNS failure, upstream service down. Pre-TASK-1078 // these collapsed into ErrServerError with bare status text; // distinguishing them lets agents decide "retry" vs "tell the // user the backend is down" vs "fix the request." ErrBackendUnreachable ErrorCode = "backend_unreachable" // ErrUpstreamError fires on 5xx responses from the backend // where a structured body was returned. Distinct from // ErrBackendUnreachable (transport never completed) and from // ErrServerError (catch-all for anything we don't have a code // for). Hint includes a body excerpt so the agent has a chance // of recognizing transient vs persistent failures. ErrUpstreamError ErrorCode = "upstream_error" // ErrServerError is the catch-all for unexpected failures — // dispatcher internal errors (build request failed, parse // response failed, marshal failed), unknown stderr patterns // from exec, etc. The wrapped message preserves the underlying // detail for debugging without promising any structured shape. ErrServerError ErrorCode = "server_error" // ErrOpenChildren fires when handleUpdateItem rejects a // non-terminal → terminal done-field transition because the // item still has non-terminal children (IDEA-1494). The upstream // 409 body carries a `details.open_children` array (refs + // titles + statuses + collection_slug) plus a // `details.hidden_blocker_count` for blockers the caller can't // see. We surface the upstream `code` and `details` verbatim // rather than collapsing to ErrConflict so MCP-driven agents // can self-recover (ship the listed children, then retry — or // pass `force: true` if the children should be intentionally // orphaned). The hint mentions the same escape hatch. ErrOpenChildren ErrorCode = "open_children" // ErrRateLimited fires on HTTP 429 responses — either the // general API limiter (per-user, 600/min, burst 60) or the // MCP per-token limiter (60/min/token, burst 60 post-BUG-1430) // rejecting a synthesized request. Distinct from // ErrServerError so agents implementing exponential backoff // can switch on code without parsing free-form text. Pre- // BUG-1430 this collapsed into ErrServerError, which led the // triggering agent to describe a 429 burst as "backend 500s // on parallel writes" — see BUG-1409. The hint includes the // upstream message so agents have a chance of recognizing // transient vs persistent throttling. ErrRateLimited ErrorCode = "rate_limited" // ErrTooLarge fires on HTTP 413: the request, or what it would cause, is // over a cap the server enforces deliberately (BUG-2829). Three producers // are reachable from the catalog today: a title rename whose wiki-link // cascade would rewrite too many items (rename_cascade_too_large), a write // whose outbox event row would be oversized (event_payload_too_large, any // mutating action), and an oversized artifact import (too_large). The // upstream code rides in details.reason. Not validation_failed: nothing in // the input is WRONG, and the way out is to shrink or split the change, not // to fix a value. Not server_error: retrying the same call fails the same // way, and that code reads as transient. ErrTooLarge ErrorCode = "too_large" // ErrPlanLimitExceeded fires on HTTP 403 responses that carry // error.code="plan_limit_exceeded" — a free-tier plan enforcement // gate (e.g. items_per_workspace, members_per_workspace, workspaces, // api_tokens). Distinct from ErrPermissionDenied (role/RBAC) so // MCP-driven agents can surface an "upgrade to Pro" signal rather // than reporting a permission error. Details carries feature/limit/ // current/plan/upgrade_url from the server (TASK-788). ErrPlanLimitExceeded ErrorCode = "plan_limit_exceeded" // ErrWorkspaceMemberLimit fires on HTTP 403 responses that carry // error.code="workspace_member_limit": an invitation accept refused // because the workspace is at members_per_workspace (BUG-3098). It is // addressed to the INVITEE, who cannot change the owner's plan, so it is // deliberately not ErrPlanLimitExceeded and its hint does not say // "upgrade". Details carries feature/limit/current, without upgrade_url. ErrWorkspaceMemberLimit ErrorCode = "workspace_member_limit" // ErrUpdateConflict fires on HTTP 409 responses that carry // error.code="update_conflict" — an optimistic-concurrency guard // rejecting an item update because its `expected_updated_at` no longer // matched the item's current updated_at (another writer won the race). // Distinct from ErrConflict (generic uniqueness/duplicate) so a // coordinating agent can re-read + retry rather than treating it as a // hard duplicate. Details carries ref/expected_updated_at/ // actual_updated_at from the server (TASK-2022). ErrUpdateConflict ErrorCode = "update_conflict" // ErrContentPendingFlush fires on HTTP 409 responses that carry // error.code="content_pending_flush" (BUG-3133): a content update carrying // expected_seq / expected_updated_at, refused because the item's // collaborative document holds edits not yet flushed to the stored body. // Unlike ErrUpdateConflict a re-read does not clear it — the row is // unchanged — so an agent should wait for the open editor to flush, or // resend with overwrite_pending_edits=true. Details carries ref/pending_rows. ErrContentPendingFlush ErrorCode = "content_pending_flush" // ErrCommentHasReplies fires on HTTP 409 responses that carry // error.code="comment_has_replies" (BUG-3252): delete-comment refused // because the comment still has replies. Nothing was deleted; a retry // refuses identically until the replies are deleted. Details carries // comment_id/reply_count. ErrCommentHasReplies ErrorCode = "comment_has_replies" // ErrCommentDeleted fires on HTTP 409 responses that carry // error.code="comment_deleted" (BUG-3252): an edit, reply or reaction // addressed to a tombstone, the placeholder a deleted comment with // replies leaves behind. Nothing changes on a retry. Details carries // comment_id. Since the tombstone landed, a delete no longer answers // comment_has_replies; that code stays for servers that predate it. ErrCommentDeleted ErrorCode = "comment_deleted" // ErrLeaseHeld fires on HTTP 409 responses that carry // error.code="lease_held" (BUG-3496): a claim on an item another holder // leases. Details carries ref, holder, acquired_at and expires_at, so the // caller can see who has it and until when. A retry before expires_at // refuses identically. ErrLeaseHeld ErrorCode = "lease_held" // Patch refusals (PLAN-3578): nothing was written. Anchor codes mean the // quoted text no longer occurs exactly once; room_open means the item is // open in an editor, which a patch does not support yet. ErrPatchAnchorMissing ErrorCode = "patch_anchor_missing" ErrPatchAnchorAmbiguous ErrorCode = "patch_anchor_ambiguous" ErrPatchRoomOpen ErrorCode = "patch_room_open" // ErrPatchUnstableRoundtrip: the open editor would change content // outside the edit to apply it (PLAN-3578 step 2). ErrPatchUnstableRoundtrip ErrorCode = "patch_unstable_roundtrip" // ErrPatchNotFound: an undo named a patch this item does not have // (PLAN-3578 step 3). ErrPatchNotFound ErrorCode = "patch_not_found" // TASK-3585: idempotency keys. ErrPatchOutcomeUnknown ErrorCode = "patch_outcome_unknown" ErrIdempotencyInFlight ErrorCode = "idempotency_in_flight" ErrIdempotencyKeyReused ErrorCode = "idempotency_key_reused" // ErrIdempotencyTargetGone: a replayed create whose item has since been // deleted (410). ErrIdempotencyTargetGone ErrorCode = "idempotency_target_gone" // ErrStoredStateUnreadable fires when an operation is refused because // the ITEM'S STORED STATE cannot be decoded — today, an append to an // implementation_notes / decision_log field whose value is not a list // of entries (BUG-2627 part 3's guard, surfaced by BUG-2675). // // The load-bearing property is that it is RETRY-HOSTILE. The failure is // deterministic: the stored value has been undecodable since it was // written, possibly for months, and every retry refuses identically. // An agent seeing this should surface the item and STOP, not back off // and try again. // // Distinct from the three codes it would otherwise collapse into, each // of which misleads in its own direction: // - ErrValidationFailed says the INPUT was bad. It wasn't — a valid // note against a valid item. What's wrong is state the caller never // supplied and cannot see. // - ErrConflict says something is racing. Nothing is. // - ErrServerError says the fault is ours and reads as transient, // which is the classification this code replaces and the reason an // agent would retry a refusal that can never succeed. ErrStoredStateUnreadable ErrorCode = "stored_state_unreadable" ) // ErrorEnvelope is the wire shape returned to MCP clients on tool // failures. The outer key is `error` so the JSON unambiguously signals // "this is the error path"; clients that want to switch on code can do // so without inspecting IsError separately. type ErrorEnvelope struct { Error ErrorPayload `json:"error"` } // ErrorPayload is the structured error body. Optional fields use // pointers / `omitempty` so they only appear when populated, keeping // success-case-shaped clients happy. Per-code fields documented inline. type ErrorPayload struct { // Code is one of the ErrorCode constants. Stable across versions. Code ErrorCode `json:"code"` // Message is a short, human-readable summary suitable for direct // display. Avoid PII / token values here — the message may end up // in logs. Message string `json:"message"` // Hint is a longer suggestion for self-recovery. May reference // commands, alternate values, or follow-up reads. Optional. Hint string `json:"hint,omitempty"` // AvailableWorkspaces is populated for ErrNoWorkspace / // ErrUnknownWorkspace so the agent can pick a valid slug without // a human round-trip. Empty array means lookup failed (e.g. no // auth) — agents should treat this as "no workspace listing // available" rather than "no workspaces exist." AvailableWorkspaces []WorkspaceHint `json:"available_workspaces,omitempty"` // Field / Expected / Got populate ErrValidationFailed when the // underlying error pinpoints a specific input. Field string `json:"field,omitempty"` Expected string `json:"expected,omitempty"` Got string `json:"got,omitempty"` // RequiredRole / CurrentRole populate ErrPermissionDenied so // agents see why the call was rejected. RequiredRole string `json:"required_role,omitempty"` CurrentRole string `json:"current_role,omitempty"` // Details carries the upstream error body's `details` object // verbatim when the upstream sets one. Used by ErrOpenChildren // (IDEA-1494) so MCP-driven agents get the same machine-readable // payload — open_children list, hidden_blocker_count, done_field, // attempted_value — that the HTTP API surfaces. json.RawMessage // because the shape is code-specific; clients re-parse against // the known schema for their code branch. Details json.RawMessage `json:"details,omitempty"` } // WorkspaceHint is a minimal workspace summary surfaced in the // no_workspace / unknown_workspace envelopes. type WorkspaceHint struct { Slug string `json:"slug"` Name string `json:"name,omitempty"` Default bool `json:"default,omitempty"` } // NewErrorResult packages an ErrorPayload as an MCP CallToolResult // with IsError=true. Both the JSON envelope and a human-readable // summary are returned: the envelope as structured content for clients // that parse it (Claude Desktop, Cursor), the summary as text fallback. func NewErrorResult(p ErrorPayload) *CallToolResult { envelope := ErrorEnvelope{Error: p} body, err := json.Marshal(envelope) if err != nil { // Marshal of a struct with only string + bool + slice fields // can't realistically fail; defensive fallback returns a plain // errorf so the agent at least sees something. return errorResultf("%s: %s", p.Code, p.Message) } // NewToolResultStructured returns a result with content blocks // PLUS structured content. The IsError flag has to be set after // because the structured constructor doesn't accept it as a // parameter — set it here so MCP clients see both. res := structuredResult(envelope, string(body)) res.IsError = true return res } // errWorkspaceRequired is what a remote route mapper or resolver returns // when the call needs a workspace and none resolved. Dispatch turns it into // noWorkspaceResult, the structured no_workspace envelope with // available_workspaces that stdio already gives (TASK-2314); before it, the // remote transport answered a bare validation_failed "workspace is required". var errWorkspaceRequired = errors.New("workspace is required — pass workspace= explicitly") // noWorkspaceResult builds the standard ErrNoWorkspace envelope with // available_workspaces populated by the supplied lookup. Lookup is // best-effort: failures (e.g. no auth) yield an envelope with empty // AvailableWorkspaces rather than dropping the whole error. func noWorkspaceResult(ctx context.Context, lookup WorkspaceLister) *CallToolResult { hints := bestEffortWorkspaceHints(ctx, lookup) hint := workspaceHintLine(hints) if hint == "" { // No list resolved (no lister, a lookup error, or a caller in no // workspace): still say how to recover (TASK-1079, TASK-2314). hint = "Pass workspace=; pad_workspace action=list shows the workspaces you can use." } return NewErrorResult(ErrorPayload{ Code: ErrNoWorkspace, Message: "No workspace context. Pass `workspace` explicitly, call pad_set_workspace first, or run from a directory with .pad.toml.", Hint: hint, AvailableWorkspaces: hints, }) } // unknownWorkspaceResult wraps a "workspace X doesn't exist" failure. // Same available_workspaces enrichment as no_workspace. Empty slug // emits a generic message rather than a misleading `Workspace ""` // — happens when the source error doesn't explicitly name the slug. func unknownWorkspaceResult(ctx context.Context, slug string, lookup WorkspaceLister) *CallToolResult { hints := bestEffortWorkspaceHints(ctx, lookup) message := "Workspace not visible to this session." if slug != "" { message = fmt.Sprintf("Workspace %q is not visible to this session.", slug) } return NewErrorResult(ErrorPayload{ Code: ErrUnknownWorkspace, Message: message, Hint: workspaceHintLine(hints), AvailableWorkspaces: hints, }) } // workspaceHintLine returns a concise comma-joined slug list, or an // empty string when no hints were resolved (avoids "Available // workspaces: " trailing nothing). func workspaceHintLine(hints []WorkspaceHint) string { if len(hints) == 0 { return "" } slugs := make([]string, 0, len(hints)) for _, h := range hints { slugs = append(slugs, h.Slug) } return "Available workspaces: " + strings.Join(slugs, ", ") } // WorkspaceLister is the side-channel a dispatcher exposes so error // helpers can populate available_workspaces hints. Returning an empty // slice (rather than an error) when lookup fails is fine — callers // already treat empty as "no listing available." type WorkspaceLister interface { ListWorkspaces(ctx context.Context) ([]WorkspaceHint, error) } // bestEffortWorkspaceHints calls lookup.ListWorkspaces and swallows // errors. The error envelope is more valuable than nothing even when // the listing failed. func bestEffortWorkspaceHints(ctx context.Context, lookup WorkspaceLister) []WorkspaceHint { if lookup == nil { return nil } hints, err := lookup.ListWorkspaces(ctx) if err != nil { return nil } return hints } // envelopeFrom reads the ErrorEnvelope back out of an MCP error // result's structured content. Used by classifyHTTPStatus's 404 // workspace path so we can layer additional Hint detail on top of the // envelope unknownWorkspaceResult already built. Returns a zero // envelope when the structured content is missing or malformed — // callers fall back gracefully. func envelopeFrom(res *CallToolResult) ErrorEnvelope { if res == nil { return ErrorEnvelope{} } if env, ok := res.StructuredContent.(ErrorEnvelope); ok { return env } return ErrorEnvelope{} } // ───────────────────────────────────────────────────────────────────── // ExecDispatcher classification // // The local subprocess emits stderr strings that we pattern-match // against known cases. Unmatched output falls through to ErrServerError // with the raw stderr preserved in Message. // ───────────────────────────────────────────────────────────────────── // structuredErrorMarker mirrors internal/cli.StructuredErrorMarker — // the versioned line prefix the CLI writes to stderr when it surfaces // a structured error. Duplicated (rather than imported) so the mcp // classifier doesn't pull the cli package's dependency graph in for // one string. Codex round-3 P3 introduced the versioned shape. // // IMPORTANT: keep in lockstep with cli.StructuredErrorMarker AND with // allowedStructuredErrorCodes below. A wire-shape change requires // bumping both packages and reviewing the allow-list. const structuredErrorMarker = "pad-structured-error/v1: " // storedStateUnreadableHint mirrors cli.StoredStateUnreadableHint — duplicated // for the same reason structuredErrorMarker is, and pinned equal to it by // TestCLIAndMCPAgreeOnTheCodeString. Both transports hand the agent the same // guidance: HTTP builds the envelope from this constant, stdio lifts the CLI's // copy off the marker line. const storedStateUnreadableHint = "Retrying will not help — the item's stored value has been undecodable since it was written, " + "so every attempt refuses identically, and the append is refused precisely because completing it " + "would overwrite that value. Do not route around it by writing the field another way. What you can " + "SEE depends on which is broken: if the whole fields blob fails to parse, `pad_item` action=get shows " + "it to you as a raw string, but if one structured key is at fault MCP hides it — the fields blob is " + "normalized on this surface and a value that does not decode is dropped from the top-level arrays. " + "Either way, report the item to a human, who can read the raw value with `pad item show --format json` " + "and repair it." // allowedStructuredErrorCodes is the whitelist of upstream codes the // MCP layer will surface verbatim. Two transports consult it: // // - Stdio: extractStructuredCLIError gates the `pad-structured-error/v1:` // marker on this set (Codex round-3 P3). // - HTTP: classifyHTTPStatus's 409 branch gates the upstream // `error.code` pass-through on this set (Codex round-4 P2). // // Routing BOTH transports through the same whitelist keeps the // ErrorCode enum's closed-contract honest — agents see the same set // of structured codes regardless of which dispatcher delivered the // response. Pre-round-4 the HTTP path forwarded any non-"conflict" // upstream code, which silently widened the enum and let the two // transports diverge. // // Adding a new structured code is a TWO-WAY change: the pad handler // must emit it (and emit `details` with a stable, agent-safe shape) // AND it must be added here. The matching ErrorCode constant in the // declarations block at the top of this file should also be added // for type safety in test assertions. var allowedStructuredErrorCodes = map[string]struct{}{ "open_children": {}, // IDEA-1494 "plan_limit_exceeded": {}, // TASK-788 // BUG-3098. No catalog action reaches an invitation accept today; the // entry keeps a future one from collapsing it to permission_denied. "workspace_member_limit": {}, "update_conflict": {}, // TASK-2022 (optimistic concurrency) // BUG-3133: a token-guarded content write refused while a tab holds // unflushed edits. Distinct from update_conflict because re-reading does // not clear it; the way out is overwrite_pending_edits. "content_pending_flush": {}, // BUG-3252: a delete of a comment that still has replies, refused // instead of failing the parent_id FK as a 500. details.reply_count. "comment_has_replies": {}, // BUG-3252 tombstone: a write addressed to a deleted comment that is // kept only to hold its replies. "comment_deleted": {}, // BUG-3496: a claim on an item someone else leases. details.holder and // details.expires_at say who and until when. "lease_held": {}, // PLAN-3578: patch refusals. "patch_anchor_missing": {}, "patch_anchor_ambiguous": {}, "patch_room_open": {}, "patch_unstable_roundtrip": {}, "patch_not_found": {}, "patch_outcome_unknown": {}, "idempotency_in_flight": {}, "idempotency_key_reused": {}, "idempotency_target_gone": {}, // BUG-2675. The only entry whose marker is written for a LOCALLY // generated refusal rather than an upstream APIError — the CLI's // append helpers refuse before any request is made (see // cli.WriteStoredStateUnreadableError). The HTTP transport reaches the // same code by its own route (dispatchItemNote / dispatchItemDecide // classify models.ErrStructuredFieldUnreadable directly), so this entry // is what keeps stdio from being the transport that still says // server_error. "stored_state_unreadable": {}, // BUG-3147. Written at the CLI's root for a 429 on any command // (cli.WriteRateLimitedError), with the server's Retry-After in // details.retry_after_seconds. Before this the stdio transport matched no // pattern for the limiter's message and said server_error, while the // remote transport, which sees the status, said rate_limited. "rate_limited": {}, // BUG-2829. Written at the CLI's root for a 413 on any command // (cli.WriteTooLargeError), with the server's own code in details.reason. // Before this both transports said server_error for a deliberate cap. "too_large": {}, // BUG-3864. Written at the CLI's root for a 403 whose code is forbidden or // permission_denied (cli.WritePermissionDeniedError), with the server's // details.reason, and for email_not_verified. Before this stdio said // server_error for any refusal whose message lacked "forbidden", which // included the token-scope and unverified-email refusals. Both lifts // rebuild the hint from the reason (permissionDeniedPayload), so the two // transports cannot word the same refusal differently. "permission_denied": {}, "email_not_verified": {}, } // extractStructuredCLIError scans stderr for the // `pad-structured-error/v1: {json}\n` marker line and lifts the // structured error envelope into an MCP result. Returns nil when no // marker is found OR the payload fails any of the hardening checks // (caller falls back to the regex-based classifiers). // // Hardening (Codex round-3 P3): // - Versioned marker. Unknown versions return nil. // - Whitelisted codes. Unknown codes return nil. // - Last-marker-wins. If multiple markers appear in stderr (a // pathological case today, but possible if a future caller emits // several), we use the LAST one — that's the most recent // classification the CLI emitted, and a malicious earlier line // can't pre-empt it. // - Marker must start the line after trimming whitespace; markers // embedded mid-line (e.g. in a quoted log message) are ignored. func extractStructuredCLIError(stderr string) *CallToolResult { if !strings.Contains(stderr, structuredErrorMarker) { return nil } var lastPayload string for _, line := range strings.Split(stderr, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, structuredErrorMarker) { continue } lastPayload = strings.TrimPrefix(line, structuredErrorMarker) } if lastPayload == "" { return nil } var env struct { Error struct { Code string `json:"code"` Message string `json:"message"` Hint string `json:"hint,omitempty"` Details json.RawMessage `json:"details,omitempty"` } `json:"error"` } if err := json.Unmarshal([]byte(lastPayload), &env); err != nil { // Malformed marker payload — fall through to regex // classification rather than blowing up the agent. return nil } if env.Error.Code == "" { return nil } if _, ok := allowedStructuredErrorCodes[env.Error.Code]; !ok { // Unknown code. Don't forward — regex classifier handles it. return nil } switch ErrorCode(env.Error.Code) { case ErrPermissionDenied: return NewErrorResult(permissionDeniedPayload(env.Error.Message, permissionReasonIn(env.Error.Details))) case ErrEmailNotVerified: return NewErrorResult(emailNotVerifiedPayload(env.Error.Message)) } // Hint is forwarded when the writer set one (BUG-2675, Codex round 2). // Without this the two transports delivered the same CODE and different // guidance: remote MCP told the agent retrying is pointless and how to // inspect the item, stdio got an empty hint. The existing marker writers // set no hint, so they are unaffected. return NewErrorResult(ErrorPayload{ Code: ErrorCode(env.Error.Code), Message: env.Error.Message, Hint: env.Error.Hint, Details: env.Error.Details, }) } // classifyExecError turns an exec.Cmd failure (err + stderr) into a // structured envelope. lookup is optional — when supplied, no_workspace // errors get available_workspaces enrichment. func classifyExecError(ctx context.Context, cmdPath []string, runErr error, stderr string, lookup WorkspaceLister) *CallToolResult { // IDEA-1494 R2: the CLI emits a single `pad-error: {json}` line // on stderr when surfacing the open-children rejection (see // internal/cli/client.go::WriteOpenChildrenError). Detect it // before regex classification so we lift the structured `code` // + `details` straight through to the MCP envelope instead of // pattern-matching free-form text and downgrading to // validation_failed. Matched on a literal prefix to keep the // classifier cheap and the contract obvious. if structured := extractStructuredCLIError(stderr); structured != nil { return structured } // BUG-987 bug 11: cobra automatically appends a "Usage: ..." block // to stderr when a command fails with a runtime error. That help // text uses the OLD CLI verb names (e.g. `pad item block`) which // confuses agents using the v0.2 catalog (`pad_item action=link // link_type=blocks`) and bloats error messages. Strip the Usage // block before classification so neither matchers nor envelope // content carry it. stderr = stripCobraUsageBlock(stderr) stderr = strings.TrimSpace(stderr) lower := strings.ToLower(stderr) switch { case execStderrMatchesNoWorkspace(lower): return noWorkspaceResult(ctx, lookup) case execStderrMatchesUnknownWorkspace(lower): // Stderr typically embeds the slug — try to extract it. If // extraction fails, the envelope still carries the message; // the slug just won't appear in the hint line. slug := extractUnknownWorkspaceSlug(stderr) return unknownWorkspaceResult(ctx, slug, lookup) case execStderrMatchesAuthRequired(lower): return NewErrorResult(ErrorPayload{ Code: ErrAuthRequired, Message: "Authentication required. Run `pad auth login` to sign in.", Hint: stripErrorPrefix(stderr), }) case execStderrMatchesPermissionDenied(lower): return NewErrorResult(ErrorPayload{ Code: ErrPermissionDenied, Message: "Permission denied for this operation.", Hint: stripErrorPrefix(stderr), }) case execStderrMatchesItemNotFound(lower): return NewErrorResult(ErrorPayload{ Code: ErrItemNotFound, Message: "Item not found.", Hint: stripErrorPrefix(stderr), }) case execStderrMatchesValidation(lower): return NewErrorResult(ErrorPayload{ Code: ErrValidationFailed, Message: "Validation failed.", Hint: stripErrorPrefix(stderr), }) } // Fallback: unstructured server error. Surface the cleaned stderr // directly without the legacy "pad failed:" prefix — that // prefix referenced OLD CLI verb names (e.g. `pad item block`) // that don't match the v0.2 catalog action names agents see, and // the cmdPath is already implicit from which tool was called // (BUG-987 bug 11 round 2). msg := stderr if msg == "" && runErr != nil { msg = runErr.Error() } if msg == "" { msg = "unknown error" } return NewErrorResult(ErrorPayload{ Code: ErrServerError, Message: stripErrorPrefix(msg), }) } // stripErrorPrefix removes the leading "Error:" prefix that the CLI // (and many of its dependencies) emit on stderr lines. Cosmetic — the // envelope's error.code already carries the "this is an error" signal // so the prefix is redundant noise. func stripErrorPrefix(msg string) string { msg = strings.TrimSpace(msg) for _, prefix := range []string{"Error: ", "error: ", "ERROR: "} { if strings.HasPrefix(msg, prefix) { return strings.TrimSpace(msg[len(prefix):]) } } return msg } // Stderr-pattern matchers. Compiled at init for cost-free // classification. Patterns are case-insensitive against `lower` // (the caller pre-lowercases for performance). var ( reNoWorkspace = regexp.MustCompile(`no workspace.*(linked|configured)`) reUnknownWorkspaceA = regexp.MustCompile(`workspace .* (does not exist|not found)`) reUnknownWorkspaceB = regexp.MustCompile(`unknown workspace`) reAuthRequired = regexp.MustCompile(`(not authenticated|authentication required|please log in|run pad auth login|invalid token|expired token)`) rePermissionDenied = regexp.MustCompile(`(permission denied|forbidden|insufficient (permissions|role))`) reItemNotFound = regexp.MustCompile(`(item.*not found|no such item|unknown ref)`) // reValidationFailed catches generic validation phrasings. Includes // "cannot ..." (e.g. "cannot link an item to itself", "cannot // modify archived item") which are validation-shaped server // rejections previously falling through to server_error // (BUG-987 bug 11 round 2). // // Two additions cover the reserved-key / override refusal family, which // this bump documents as behaving the same on both transports: // // - "not settable" — the move/copy reserved-key refusal (BUG-2674): // "Field(s) reserved for system metadata and not settable here: ..." // - "has no field" — the copy's undeclared-override refusal: // "Destination collection has no field(s): ..." // // Neither matched, so stdio reported a deterministic 400 as server_error // while remote HTTP called it validation_failed — the same refusal, two // codes, and the transient-looking one invites a retry that always fails. // (The family's third message, "Invalid override value(s)", was already // covered by `invalid`.) // // Matching prose is a stopgap and reads like one: the structural fix is // the `pad-structured-error/v1:` marker, which carries the code instead of // inferring it. Until a refusal emits that, TestReservedKeyRefusalsAgree- // AcrossTransports is where a new one has to be added — it drives both // real classifiers with the real server text, so a reworded message fails // there rather than in an agent's retry loop (Codex rounds 7-8). reValidationFailed = regexp.MustCompile(`(invalid|missing required|must be one of|validation|not settable|has no field|cannot )`) // The argv-parse refusals cobra and pflag raise before a command runs // (BUG-3142). Each is deterministic — the same argv fails the same way — // and none matched the pattern above, so every one reached an agent as a // retryable server_error. Enumerated from the vendored sources (cobra // args.go/command.go, pflag errors.go), not from memory; the one pair // already covered by `invalid` ("invalid argument …" from both) is left // to that pattern. Anchored at a line start, optionally after the CLI's // "Error: " prefix, so a server message merely CONTAINING one of these // words cannot be pulled in. reCobraUsage = regexp.MustCompile(`(?m)^(?:error: )?(?:unknown (?:shorthand )?flag: |flag needs an argument: |unknown command "|requires at least \d+ arg\(s\)|accepts (?:at most |between )?\d+ (?:and \d+ )?arg\(s\)|required flag\(s\) ")`) // Only match QUOTED slugs to avoid capturing stop-words like "not" // in generic "Workspace not found" / "workspace not visible" // messages. Quoted forms come from CLI stderr ("workspace 'foo' // does not exist") and from JSON error bodies that explicitly name // the slug. Unquoted phrasings yield an empty slug — the // unknownWorkspaceResult message handles that gracefully without // emitting a misleading `Workspace "not"` line. reUnknownWorkspaceID = regexp.MustCompile(`workspace ['"]([a-z0-9][a-z0-9-]*)['"]`) ) func execStderrMatchesNoWorkspace(lower string) bool { return reNoWorkspace.MatchString(lower) } func execStderrMatchesUnknownWorkspace(lower string) bool { return reUnknownWorkspaceA.MatchString(lower) || reUnknownWorkspaceB.MatchString(lower) } func execStderrMatchesAuthRequired(lower string) bool { return reAuthRequired.MatchString(lower) } func execStderrMatchesPermissionDenied(lower string) bool { return rePermissionDenied.MatchString(lower) } func execStderrMatchesItemNotFound(lower string) bool { return reItemNotFound.MatchString(lower) } func execStderrMatchesValidation(lower string) bool { return reValidationFailed.MatchString(lower) || reCobraUsage.MatchString(lower) } // extractUnknownWorkspaceSlug pulls the slug from a CLI stderr like // "workspace 'foo' does not exist" so the envelope can name it. // Returns empty string if nothing matches; callers fall back to a // generic "this slug" phrasing. func extractUnknownWorkspaceSlug(stderr string) string { m := reUnknownWorkspaceID.FindStringSubmatch(strings.ToLower(stderr)) if len(m) < 2 { return "" } return m[1] } // validationFailedFromBuildErr wraps a BuildCLIArgs error (typically // "missing required argument %q" or "flag %q: ") as a // structured validation_failed envelope. Best-effort extraction of // the offending field name out of Go's error wrapping conventions — // when the regex misses, the message itself still carries the // underlying text. // // BUG-987 bug 12: previously BuildCLIArgs failures came out of // env.Dispatch as bare mcp.NewToolResultErrorf strings, breaking the // structured-envelope invariant that every other error path follows. func validationFailedFromBuildErr(cmdPath string, err error) *CallToolResult { msg := err.Error() field := extractValidationField(msg) payload := ErrorPayload{ Code: ErrValidationFailed, Message: fmt.Sprintf("validation failed for `%s`: %s", cmdPath, msg), Field: field, } return NewErrorResult(payload) } // reValidationField matches the field-name token in BuildCLIArgs's // error strings. Both `argument "x"` and `flag "x"` formats cover // ~all of its err paths (see internal/mcp/dispatch.go's BuildCLIArgs). var reValidationField = regexp.MustCompile(`(?:argument|flag)\s+"([^"]+)"`) // extractValidationField pulls the field name out of a BuildCLIArgs // error message, returning empty string when no match is found. func extractValidationField(msg string) string { m := reValidationField.FindStringSubmatch(msg) if len(m) < 2 { return "" } return m[1] } // stripCobraUsageBlock removes cobra's auto-appended "Usage: ..." // help block from a stderr string. cobra emits this block on any // runtime error from a RunE handler — useful for human users running // the CLI directly, but noise in MCP error envelopes (and worse, // references CLI verb names like `pad item block` that aren't part // of the v0.2 MCP catalog at all, BUG-987 bug 11). // // The block is recognizable: a line containing exactly "Usage:" // (with optional surrounding whitespace) followed by the help text. // It is removed up to the end of the input or to the first line that // starts with "Error:", whichever comes first. If no Usage block is // present (the typical no-cobra-help error path), the input is returned // unchanged. // // WHY THE "Error:" STOP (BUG-3142). The root command's flag-error hook // prints the usage block FIRST and cobra's "Error: …" line AFTER it, so // truncating at "Usage:" deleted the only line naming what went wrong: a // flag-parse refusal reached the classifier as an empty string and was // reported as server_error with no message. A runtime error prints the // other way round (or no usage at all), which the stop leaves unchanged. func stripCobraUsageBlock(stderr string) string { idx := indexOfUsageLine(stderr) if idx < 0 { return stderr } head := strings.TrimRight(stderr[:idx], " \t\r\n") rel := strings.Index(stderr[idx:], "\nError:") if rel < 0 { return head } errLine := strings.TrimSpace(stderr[idx+rel+1:]) if head == "" { return errLine } return head + "\n" + errLine } // indexOfUsageLine returns the byte offset of the line containing // "Usage:" (cobra's help-block prefix), or -1 when absent. The match // is anchored to a line start (preceded by '\n' or string start) so // in-message mentions of the word "Usage:" don't accidentally // truncate. func indexOfUsageLine(s string) int { const marker = "Usage:" idx := 0 for { rel := strings.Index(s[idx:], marker) if rel < 0 { return -1 } abs := idx + rel // Anchor: must be at the start of a line. A leading newline // (with optional whitespace before the marker) qualifies. if abs == 0 || isLineStart(s, abs) { return abs } idx = abs + len(marker) } } // isLineStart returns true when the character at byte position i is // preceded by a newline (with arbitrary leading whitespace allowed // between the newline and i). func isLineStart(s string, i int) bool { for j := i - 1; j >= 0; j-- { c := s[j] if c == ' ' || c == '\t' { continue } return c == '\n' } return true } // ───────────────────────────────────────────────────────────────────── // HTTPHandlerDispatcher classification // // HTTP status codes map cleanly to the taxonomy. Body parsing is // best-effort: when the handler returns a structured payload we // surface its message; otherwise the status text serves as the // envelope's Message. // ───────────────────────────────────────────────────────────────────── // ResourceKind hints what shape of resource a dispatcher was reading // when it hit an error. Drives 404 classification (item_not_found vs // not_found) and the contextual hint text per code (TASK-1078 + // TASK-1079). // // The dispatcher knows exactly what it was reading (an item, a // workspace, a collection list, etc.); the classifier doesn't need // to guess from URL paths or body strings. type ResourceKind string const ( // ResourceUnknown is the legacy "we don't know what was being // read" sentinel — call sites that haven't been retrofitted yet // pass this and get the pre-TASK-1078 behaviour (404 → // item_not_found by default unless body says workspace). ResourceUnknown ResourceKind = "" // ResourceItem signals an item-by-ref lookup (pad_item show / // update / delete / star / etc.). 404 → item_not_found with // pad_item search / list as the recovery hint. ResourceItem ResourceKind = "item" // ResourceWorkspace signals a workspace-level read or write // (pad_workspace * actions, pad_project dashboard, etc.). // 404 → unknown_workspace with available_workspaces enrichment. ResourceWorkspace ResourceKind = "workspace" // ResourceCollection signals a collection-shaped read // (pad_collection list / show, item-list-by-collection). // 404 → not_found with the collection-name in the hint. ResourceCollection ResourceKind = "collection" // ResourceListing signals a generic listing endpoint where // 404 means the parent route doesn't exist (NOT "no rows" — // list endpoints return 200 with [] for that case). Used for // pad_workspace list, pad_role list, pad_project dashboard. ResourceListing ResourceKind = "listing" // ResourceLink signals a link create/delete operation. 404 → // not_found with the link-target ref in the hint. ResourceLink ResourceKind = "link" // ResourceAttachment signals an attachment metadata read. // 404 → not_found with the attachment_id in the hint. ResourceAttachment ResourceKind = "attachment" ) // classifyHTTPStatus is the legacy entry point preserved for callers // that don't know their resource kind. New callers should use // classifyHTTPStatusKind directly. func classifyHTTPStatus(ctx context.Context, cmdKey string, status int, body []byte, lookup WorkspaceLister) *CallToolResult { return classifyHTTPStatusKind(ctx, cmdKey, "", status, body, lookup, ResourceUnknown, "") } // classifyHTTPStatusKind turns an HTTP error response into a structured // envelope, using the dispatcher's known resource context to emit the // right error code and an actionable hint (TASK-1078 / TASK-1079). // // Parameters: // // - ctx, cmdKey, lookup — same as classifyHTTPStatus. // - route — the URL path the dispatcher hit, e.g. // "/api/v1/workspaces/foo/items/TASK-7". Used in hint text so // agents know which path failed without parsing the message. // May be empty when the caller doesn't have a route handy // (legacy classifyHTTPStatus path). // - status, body — same as classifyHTTPStatus. // - kind — what shape of resource was being read. Drives both // the code selection (404 splits item_not_found / unknown_workspace // / not_found) and the hint text per code. // - refOrSlug — the specific identifier the dispatcher was looking // up (a TASK-N ref for items, a workspace slug for workspaces, // etc.). Used in the hint so an agent reading "Item ref TASK-7 // not found in workspace foo" knows exactly what to fix. May be // empty. func classifyHTTPStatusKind( ctx context.Context, cmdKey, route string, status int, body []byte, lookup WorkspaceLister, kind ResourceKind, refOrSlug string, ) *CallToolResult { bodyText := strings.TrimSpace(string(body)) bodyMessage := extractUpstreamMessage(bodyText) if bodyText == "" { bodyText = http.StatusText(status) } switch status { case http.StatusUnauthorized: return NewErrorResult(ErrorPayload{ Code: ErrAuthRequired, Message: "Authentication required.", Hint: authHintFor(bodyMessage, route), }) case http.StatusForbidden: // TASK-788: when the handler emitted a plan_limit_exceeded structured body, // surface it with the dedicated code + details so MCP-driven agents can // present an upgrade-to-Pro signal instead of a generic permission error. // Whitelisted through allowedStructuredErrorCodes (same pattern as // open_children on 409) so unknown 403 codes still collapse to // ErrPermissionDenied. upstream403 := extractUpstreamErrorEnvelope(bodyText) switch ErrorCode(upstream403.Code) { case ErrEmailNotVerified: return NewErrorResult(emailNotVerifiedPayload(upstream403.Message)) case ErrPermissionDenied: return NewErrorResult(permissionDeniedPayload(bodyMessage, permissionReasonIn(upstream403.Details))) } if _, allowed := allowedStructuredErrorCodes[upstream403.Code]; allowed { return NewErrorResult(ErrorPayload{ Code: ErrorCode(upstream403.Code), Message: upstream403.Message, Hint: hint403For(upstream403.Code, upstream403.Message, route), Details: upstream403.Details, }) } return NewErrorResult(permissionDeniedPayload(bodyMessage, permissionReasonIn(upstream403.Details))) case http.StatusNotFound: // PLAN-3578 step 3: an undo naming a patch this item does not have is // a 404 about the PATCH, not the item; the generic classifier would // tell the agent the item is missing. if upstream := extractUpstreamErrorEnvelope(bodyText); upstream.Code == string(ErrPatchNotFound) { return NewErrorResult(ErrorPayload{Code: ErrPatchNotFound, Message: bodyMessage, Hint: PatchNotFoundHint, Details: upstream.Details}) } return classify404(ctx, cmdKey, route, bodyText, bodyMessage, lookup, kind, refOrSlug) case http.StatusConflict: // IDEA-1494 R2/R4 P2: preserve the upstream `code` + `details` // when the handler emitted a structured rejection — but ONLY // for codes that appear in the shared allow-list // (allowedStructuredErrorCodes, defined below). The stdio // classifier already gates on the same set; routing both // transports through the same whitelist keeps the // ErrorCode enum's closed contract honest (round-4 P2: // HTTP was forwarding any non-"conflict" code, diverging // from stdio). // // New structured codes get added to allowedStructuredErrorCodes // in one place; both transports adopt them in lockstep. // Unknown codes fall through to generic ErrConflict here — // matching what stdio does for an unknown-code marker. upstream := extractUpstreamErrorEnvelope(bodyText) if _, allowed := allowedStructuredErrorCodes[upstream.Code]; allowed { hint := conflictHintFor(bodyMessage, route) if upstream.Code == string(ErrContentPendingFlush) { // "Re-read and retry" is the one move that cannot work here: // the row is unchanged, so the re-read returns the same seq // and the retry is refused again (BUG-3133). hint = ContentPendingFlushHint if setAsideRowsIn(upstream.Details) > 0 { hint = ContentSetAsideHint } } if upstream.Code == string(ErrUpdateConflict) && contentWriteIn(upstream.Details) { hint = UpdateConflictContentHint } if upstream.Code == string(ErrCommentHasReplies) { // Retrying refuses identically until the replies are gone. hint = CommentHasRepliesHint } if upstream.Code == string(ErrCommentDeleted) { // The tombstone stays a tombstone; no retry changes it. hint = CommentDeletedHint } if upstream.Code == string(ErrLeaseHeld) { // The lease stands until it expires or its holder releases it. hint = LeaseHeldHint } if upstream.Code == string(ErrPatchAnchorMissing) || upstream.Code == string(ErrPatchAnchorAmbiguous) { hint = PatchAnchorHint } if upstream.Code == string(ErrPatchRoomOpen) { hint = PatchRoomOpenHint } if upstream.Code == string(ErrPatchUnstableRoundtrip) { hint = PatchUnstableHint } if upstream.Code == string(ErrPatchNotFound) { hint = PatchNotFoundHint } switch upstream.Code { case string(ErrPatchOutcomeUnknown): hint = PatchOutcomeUnknownHint case string(ErrIdempotencyInFlight): hint = IdempotencyInFlightHint } if upstream.Code == string(ErrStoredStateUnreadable) { // Same reason: the stored value stays undecodable, so every // retry refuses identically. Since BUG-3056 the note/decide // append runs server-side and reaches the agent by this arm. hint = storedStateUnreadableHint } return NewErrorResult(ErrorPayload{ Code: ErrorCode(upstream.Code), Message: upstream.Message, Hint: hint, Details: upstream.Details, }) } return NewErrorResult(ErrorPayload{ Code: ErrConflict, Message: "Conflict — current state changed beneath this update.", Hint: conflictHintFor(bodyMessage, route), }) case http.StatusUnprocessableEntity, http.StatusBadRequest: // TASK-3585: a reused idempotency key is not a validation failure of // the request; it has its own code and remedy. if upstream := extractUpstreamErrorEnvelope(bodyText); upstream.Code == string(ErrIdempotencyKeyReused) { return NewErrorResult(ErrorPayload{Code: ErrIdempotencyKeyReused, Message: bodyMessage, Hint: IdempotencyKeyReusedHint, Details: upstream.Details}) } hint := validationHintFor(bodyMessage, route) if h := moveNeedsValueHintFor(extractUpstreamErrorEnvelope(bodyText), bodyMessage); h != "" { hint = h } return NewErrorResult(ErrorPayload{ Code: ErrValidationFailed, Message: "Validation failed.", Hint: hint, }) case http.StatusGone: // TASK-3585: a replayed create whose item was deleted since. Without // this arm a 410 fell through to the generic other-4xx bucket. if upstream := extractUpstreamErrorEnvelope(bodyText); upstream.Code == string(ErrIdempotencyTargetGone) { return NewErrorResult(ErrorPayload{Code: ErrIdempotencyTargetGone, Message: bodyMessage, Hint: IdempotencyTargetGoneHint, Details: upstream.Details}) } case http.StatusTooManyRequests: // BUG-1430: pre-existing behavior collapsed 429 into the // generic ErrServerError "other 4xx" bucket, which led // the triggering agent to report a parallel-write burst // as "backend 500s." Surface 429 as a first-class // ErrRateLimited so agents can implement code-based // backoff instead of parsing free-form text. The hint // mentions the Retry-After header (set on the response // by writeRateLimitResponse / writeMCPRateLimit) since // the dispatcher's body-only signature loses the headers // — a future enhancement could surface the parsed value // directly in the envelope. return NewErrorResult(ErrorPayload{ Code: ErrRateLimited, Message: fmt.Sprintf("pad %s rate-limited (HTTP 429)", cmdKey), Hint: rateLimitHintFor(bodyMessage, route), }) } if status == http.StatusRequestEntityTooLarge { // BUG-2829. A deliberate cap, never a fault: see ErrTooLarge. The // server's message is used as-is because it names what was measured // and the limit; the upstream code distinguishes the three producers. upstream := extractUpstreamErrorEnvelope(bodyText) msg := upstream.Message if msg == "" { msg = fmt.Sprintf("pad %s refused: request too large (HTTP 413)", cmdKey) } var details json.RawMessage if upstream.Code != "" { details, _ = json.Marshal(map[string]string{"reason": upstream.Code}) } return NewErrorResult(ErrorPayload{ Code: ErrTooLarge, Message: msg, Hint: tooLargeHintFor(route), Details: details, }) } if status >= 500 { return NewErrorResult(ErrorPayload{ Code: ErrUpstreamError, Message: fmt.Sprintf("pad %s failed: backend returned %d", cmdKey, status), Hint: upstreamHintFor(bodyMessage, route, status), }) } // Other 4xx without a specific mapping — surface as server_error // with the raw body so debugging is still possible. Avoid silently // promoting them to validation_failed; that would mislead callers. return NewErrorResult(ErrorPayload{ Code: ErrServerError, Message: fmt.Sprintf("pad %s failed (HTTP %d)", cmdKey, status), Hint: serverHintFor(bodyMessage, route, status), }) } // classify404 splits HTTP 404 by ResourceKind into the right code + // hint. Called from classifyHTTPStatusKind only — extracted so the // 404-shaped logic doesn't dominate the parent switch statement. func classify404( ctx context.Context, cmdKey, route, bodyText, bodyMessage string, lookup WorkspaceLister, kind ResourceKind, refOrSlug string, ) *CallToolResult { // Workspace-shaped 404s: route through the existing // unknown_workspace path so available_workspaces enrichment fires. // Body-string sniffing is the legacy fallback for callers that // pass ResourceUnknown. if kind == ResourceWorkspace || (kind == ResourceUnknown && strings.Contains(strings.ToLower(bodyText), "workspace")) { slug := refOrSlug if slug == "" { slug = extractUnknownWorkspaceSlug(bodyText) } res := unknownWorkspaceResult(ctx, slug, lookup) env := envelopeFrom(res) // Layer in a route-aware hint suffix so the agent sees the // path that 404'd in addition to the workspace-list hint. extra := workspaceMissingHint(slug, route, bodyMessage) if env.Error.Hint == "" { env.Error.Hint = extra } else if extra != "" { env.Error.Hint = extra + " — " + env.Error.Hint } return NewErrorResult(env.Error) } switch kind { case ResourceItem: return NewErrorResult(ErrorPayload{ Code: ErrItemNotFound, Message: "Item not found.", Hint: itemMissingHint(refOrSlug, route, bodyMessage), }) case ResourceUnknown: // Legacy fallback — preserved so call sites that haven't // been retrofitted yet keep the pre-TASK-1078 code mapping. // Use itemMissingHint (NOT raw bodyText) so the safe-extracted // bodyMessage is the only upstream content forwarded, matching // the privacy contract extractUpstreamMessage establishes // (Codex review #387 round 2 caught the residual leak from // the previous bare-`bodyText` Hint). return NewErrorResult(ErrorPayload{ Code: ErrItemNotFound, Message: "Item not found.", Hint: itemMissingHint(refOrSlug, route, bodyMessage), }) default: // All other resource kinds (collection, listing, link, // attachment) get the generic not_found code with a // kind-aware hint. return NewErrorResult(ErrorPayload{ Code: ErrNotFound, Message: notFoundMessageFor(kind, refOrSlug), Hint: notFoundHintFor(kind, refOrSlug, route, bodyMessage), }) } } // extractUpstreamMessage pulls a human-readable message out of an // upstream error body. Three return shapes: // // - Pad's structured envelope ({error:{message:...}}) → return the // inner message verbatim. This is the common case for any 4xx / // 5xx pad emits internally (every handler uses writeError which // produces this shape). // - chi's default 404 body ("404 page not found") → return "" so // the hint omits the upstream-message clause entirely. The // literal text has zero diagnostic value (Bug 17) and forwarding // it doesn't help anyone. // - Anything else → return "". Per Codex review #387 round 1, the // pre-fix fallback returned the raw body — which would forward // non-envelope upstream JSON / HTML / debug dumps, including any // tokens / passwords / internal-only fields the backend might // surface in a 5xx. Refusing to forward unstructured bodies is // the safer default; operators debugging a non-envelope upstream // can read the pad container logs, agents don't need the raw // body to recover. // // Trailing whitespace is trimmed before classification because chi's // "404 page not found" body ships with a trailing newline and the // EqualFold check needs to match it byte-for-byte. func extractUpstreamMessage(body string) string { body = strings.TrimSpace(body) if body == "" { return "" } if strings.EqualFold(body, "404 page not found") { return "" } var env struct { Error struct { Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal([]byte(body), &env); err == nil && env.Error.Message != "" { return env.Error.Message } return "" } // upstreamErrorEnvelope is the broader extractor used when the // dispatcher needs to pass through the upstream `code` / `details` // fields (not just the message). The pad backend uses the same // `{"error":{"code":..., "message":..., "details":{...}}}` shape // across every handler that returns writeError-style bodies; this // parser lifts those fields when present and reports zero-valued // strings / nil RawMessage when not. Single source of truth for the // pass-through cases in classifyHTTPStatusKind. type upstreamErrorEnvelope struct { Code string `json:"code"` Message string `json:"message"` Details json.RawMessage `json:"details,omitempty"` } func extractUpstreamErrorEnvelope(body string) upstreamErrorEnvelope { body = strings.TrimSpace(body) if body == "" { return upstreamErrorEnvelope{} } if strings.EqualFold(body, "404 page not found") { return upstreamErrorEnvelope{} } var env struct { Error upstreamErrorEnvelope `json:"error"` } if err := json.Unmarshal([]byte(body), &env); err == nil { return env.Error } return upstreamErrorEnvelope{} } // itemMissingHint is the per-error-code hint generator for // ErrItemNotFound. References the actual ref + route so the agent // can pin the failure without re-parsing the message, plus points // at the next-step recovery tools. func itemMissingHint(ref, route, bodyMsg string) string { parts := []string{} if ref != "" { parts = append(parts, fmt.Sprintf("Item %q not found.", ref)) } else { parts = append(parts, "Item not found at the requested ref.") } if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } parts = append(parts, "Try `pad_item search` or `pad_item list` to find the right ref.") if bodyMsg != "" && !strings.EqualFold(bodyMsg, "404 page not found") { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } // workspaceMissingHint generates the route-aware suffix appended to // the standard unknown_workspace envelope's available_workspaces line. func workspaceMissingHint(slug, route, bodyMsg string) string { parts := []string{} if slug != "" { parts = append(parts, fmt.Sprintf("Workspace %q not visible.", slug)) } if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } if bodyMsg != "" && !strings.EqualFold(bodyMsg, "404 page not found") { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } // notFoundMessageFor returns a kind-aware short message for the // not_found code. Falls back to a generic "Resource not found" when // the kind doesn't match a specialized case. func notFoundMessageFor(kind ResourceKind, refOrSlug string) string { switch kind { case ResourceCollection: if refOrSlug != "" { return fmt.Sprintf("Collection %q not found.", refOrSlug) } return "Collection not found." case ResourceLink: return "Link target not found." case ResourceAttachment: if refOrSlug != "" { return fmt.Sprintf("Attachment %q not found.", refOrSlug) } return "Attachment not found." case ResourceListing: return "Listing endpoint did not respond." } return "Resource not found." } // notFoundHintFor generates the actionable hint for ErrNotFound. // References the route + ref/slug + suggests verification of the // path the caller passed. func notFoundHintFor(kind ResourceKind, refOrSlug, route, bodyMsg string) string { parts := []string{} switch kind { case ResourceCollection: parts = append(parts, "Verify the collection slug exists; use `pad_collection list` to enumerate.") case ResourceLink: parts = append(parts, fmt.Sprintf("Verify the target ref %q exists.", refOrSlug)) case ResourceAttachment: parts = append(parts, "Verify the attachment_id from the parent item.") case ResourceListing: parts = append(parts, "Verify the route matches the server's API surface (build version may be stale).") default: parts = append(parts, "Verify the path you passed.") } if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } if bodyMsg != "" && !strings.EqualFold(bodyMsg, "404 page not found") { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } // authHintFor generates the actionable hint for ErrAuthRequired. // Distinct from the upstream body — agents see "fix your auth", not // the literal upstream JSON envelope (Bug 19). func authHintFor(bodyMsg, route string) string { parts := []string{"Re-authenticate (Claude Desktop: reconnect the connector; CLI: `pad auth login`)."} if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } if bodyMsg != "" { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } // The permission_denied hints, by the server's details.reason (BUG-3864). // Each says what the caller does next, in the words the web app uses. A // reason outside this table, or none, gets permissionUnknownHintPrefix and // the server's message instead: the old hint led with "Insufficient role" // for every 403, which sent a bot with a read-only token to ask for a role // it already had. const ( PermissionRoleHint = "Your role in this workspace does not allow this. Ask a workspace owner to change your role." PermissionTokenScopeHint = "This API token's access does not include this action: a 'Read only' token cannot make changes. " + "Create a token with 'Read and write' access under Account settings → API Tokens, and use it instead." PermissionNoAccessHint = "You do not have access to this workspace or collection. Ask a workspace owner for access." PermissionWorkspaceNotAuthorizedHint = "This connection was not granted access to this workspace. " + "Reconnect and include it, or use a workspace it was granted." permissionUnknownHintPrefix = "The server refused this operation" EmailNotVerifiedHint = "Verify your account's email address (the link is in your inbox, and the web app can send a new one). " + "Changes are refused until then." ) var permissionReasonHints = map[string]string{ "role": PermissionRoleHint, "token_scope": PermissionTokenScopeHint, "no_access": PermissionNoAccessHint, "workspace_not_authorized": PermissionWorkspaceNotAuthorizedHint, } // permissionReasonIn reads details.reason from an upstream error's details. func permissionReasonIn(details json.RawMessage) string { var d struct { Reason string `json:"reason"` } if len(details) == 0 || json.Unmarshal(details, &d) != nil { return "" } return d.Reason } // permissionDeniedPayload is the one permission_denied envelope, for every // door that produces one: the remote 403, the remote dispatcher's own scope // gate, and the stdio marker. details.reason is forwarded only when it is a // reason this build knows, so the field stays a closed vocabulary. No route in // the hint: stdio has none, and the two transports must say the same thing. func permissionDeniedPayload(message, reason string) ErrorPayload { p := ErrorPayload{Code: ErrPermissionDenied, Message: message} if p.Message == "" { p.Message = "Permission denied for this operation." } hint, known := permissionReasonHints[reason] if known { p.Details, _ = json.Marshal(map[string]string{"reason": reason}) } else { hint = permissionUnknownHintPrefix if message != "" { hint += ": " + strings.TrimRight(message, ".") } hint += "." } p.Hint = hint return p } func emailNotVerifiedPayload(message string) ErrorPayload { if message == "" { message = "Verify your email address to perform this action." } return ErrorPayload{Code: ErrEmailNotVerified, Message: message, Hint: EmailNotVerifiedHint} } // CommentHasRepliesHint is the recovery guidance for ErrCommentHasReplies on // the remote transport. Duplicated in internal/cli (CommentHasRepliesHint), // whose marker carries it on stdio; keep the two identical. const CommentHasRepliesHint = "Nothing was deleted. Delete the replies first (the comments whose parent_id is this comment's id), " + "or edit the comment instead of deleting it." // CommentDeletedHint is the recovery guidance for ErrCommentDeleted on the // remote transport. Duplicated in internal/cli (CommentDeletedHint), whose // marker carries it on stdio; keep the two identical. const CommentDeletedHint = "The comment was deleted and is kept only as a placeholder for its replies, so it cannot be edited, " + "replied to or reacted to. Comment on the item, or reply to one of its replies, instead." // LeaseHeldHint is the recovery guidance for ErrLeaseHeld on the remote // transport. Duplicated in internal/cli (LeaseHeldHint), whose marker carries // it on stdio; keep the two identical. const LeaseHeldHint = "Another holder (details.holder) has claimed this item until details.expires_at. " + "Pick other work, or wait until the lease expires; claiming again before then is refused the same way." // ContentPendingFlushHint is the recovery guidance for ErrContentPendingFlush, // on both transports. Duplicated in internal/cli (ContentPendingFlushHint) for // the same dependency-graph reason StructuredErrorMarker is; a test asserts the // two match, because the same code with different guidance per transport is // the gap the shared allow-list exists to close. const ContentPendingFlushHint = "Re-reading will not clear this: the stored item is unchanged until the open editor saves its edits. " + "Wait for that and re-read, or resend with overwrite_pending_edits=true to replace them." // PatchAnchorHint is the recovery guidance for a patch anchor refusal // (PLAN-3578). Duplicated in internal/cli; a test asserts the two match. const PatchAnchorHint = "Nothing was written. Re-read the item and quote, as old, text that occurs exactly once in the CURRENT body " + "(add surrounding lines to make it unique, or pass line_hint to choose among exact matches)." // PatchRoomOpenHint is the recovery guidance for patch_room_open (PLAN-3578). // Duplicated in internal/cli; a test asserts the two match. const PatchRoomOpenHint = "Nothing was written. The item is open in an editor that cannot apply patches (an older tab). " + "Retry after that tab reloads or closes, or send a whole-body update with expected_seq." // PatchUnstableHint is the recovery guidance for patch_unstable_roundtrip // (PLAN-3578 step 2). Duplicated in internal/cli; a test asserts the two // match. const PatchUnstableHint = "Nothing was written. Applying this patch in the open editor would also change content you did not edit. " + "Re-read the item and send a whole-body update with expected_seq instead." // PatchNotFoundHint is the recovery guidance for patch_not_found (PLAN-3578 // step 3). Duplicated in internal/cli; a test asserts the two match. const PatchNotFoundHint = "Nothing was written. That patch id is not a patch on this item: use the patch_id a patch answer returned, on the item it was made on." // TASK-3585's hints. Duplicated in internal/cli; a test asserts they match. const ( PatchOutcomeUnknownHint = "Nothing new was written. An earlier attempt reached an editor that never confirmed it and is gone, so it is not re-applied: read details.diff (the change since the patch was sent); if your edit is there, it landed. Otherwise send it again with a NEW key." IdempotencyInFlightHint = "A request with this key is still running. Wait a moment and retry with the SAME key: you will get its answer, not a second write." IdempotencyKeyReusedHint = "This key was already used for a different request. Use a new key for a new request; reuse a key only to retry the identical request." IdempotencyTargetGoneHint = "The request this key belongs to already ran and created an item that has since been deleted. Nothing was written now. To create it again, send the request with a NEW key." ) // UpdateConflictContentHint replaces the generic re-read-and-retry hint on an // update_conflict whose details carry content_write (PLAN-3555): a resent body // would undo the change the caller missed. Duplicated in internal/cli, whose // marker carries it on stdio; a test asserts the two match. const UpdateConflictContentHint = "Do not resend your body: it would undo the change you missed. " + "Re-read the item, apply your edit to the CURRENT content, and send that with expected_seq set to the seq you just read." // ContentSetAsideHint replaces ContentPendingFlushHint when the refusal is // about edits a collab schema-version rebuild set aside (BUG-3244, // details.set_aside_rows > 0). Waiting and re-reading cannot clear those: no // editor will write them back. Duplicated in internal/cli; a test // asserts the two match. const ContentSetAsideHint = "Re-reading will not clear this, and neither will waiting or opening the item: these edits were set aside by an editor upgrade and no editor will write them back. " + "Read them with pad item set-aside , then resend with overwrite_pending_edits=true to discard them." // contentWriteIn reads content_write from an update_conflict's details // (PLAN-3555). func contentWriteIn(details json.RawMessage) bool { var d struct { ContentWrite bool `json:"content_write"` } _ = json.Unmarshal(details, &d) return d.ContentWrite } // setAsideRowsIn reads set_aside_rows from a refusal's details (BUG-3244). func setAsideRowsIn(details json.RawMessage) int { if len(details) == 0 { return 0 } var d struct { SetAsideRows int `json:"set_aside_rows"` } if json.Unmarshal(details, &d) != nil { return 0 } return d.SetAsideRows } // conflictHintFor generates the actionable hint for ErrConflict. func conflictHintFor(bodyMsg, route string) string { parts := []string{"Re-read the current item state and retry the update."} if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } if bodyMsg != "" { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } // validationHintFor generates the actionable hint for // ErrValidationFailed. The backend message usually pinpoints the // field, so it's prioritized. func validationHintFor(bodyMsg, route string) string { parts := []string{} if bodyMsg != "" { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } parts = append(parts, "Adjust the input shape and retry.") return strings.Join(parts, " ") } // moveNeedsValueHintFor names the fix for a move or copy refused for a // destination value only the caller can supply (BUG-3200): a required field // with no value, or a change between open, done and abandoned the caller did // not name. The code and the payload shape stay validation_failed; only the // hint is specific, because a structured needs_value on this catalog waits on // the catalog decision (BUG-3200 trail). "" for any other refusal. func moveNeedsValueHintFor(up upstreamErrorEnvelope, bodyMsg string) string { switch up.Code { case "missing_required_fields": return fmt.Sprintf("Backend: %s Supply each named field in the same call: field: [\"=\"].", bodyMsg) case "state_change_requires_value": var d struct { Field string `json:"field"` Options []string `json:"options"` } _ = json.Unmarshal(up.Details, &d) if d.Field == "" || len(d.Options) == 0 { return fmt.Sprintf("Backend: %s Name the destination's done field in the same call: field: [\"=\"].", bodyMsg) } return fmt.Sprintf("Backend: %s Name the value in the same call: field: [\"%s=<%s>\"].", bodyMsg, d.Field, strings.Join(d.Options, "|")) } return "" } // upstreamHintFor generates the actionable hint for ErrUpstreamError. // 5xx responses are usually transient; the hint encodes that bias. func upstreamHintFor(bodyMsg, route string, status int) string { parts := []string{ fmt.Sprintf("Backend returned %d.", status), "Usually transient — retry once or check pad logs for the underlying error.", } if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } if bodyMsg != "" && bodyMsg != http.StatusText(status) { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } // serverHintFor generates the actionable hint for ErrServerError. // Catch-all — surface enough for an agent to file a bug report. func serverHintFor(bodyMsg, route string, status int) string { parts := []string{fmt.Sprintf("Unexpected status %d from backend.", status)} if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } if bodyMsg != "" { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } // hint403For picks the hint for an allow-listed structured 403. The // plan-limit hint tells the caller to upgrade, which is wrong for an invitee // refused by someone else's member cap (BUG-3098). func hint403For(code, bodyMsg, route string) string { if code == string(ErrWorkspaceMemberLimit) { parts := []string{"The workspace is at its member limit; only its owner can make room. Retry the accept after they do."} if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } if bodyMsg != "" { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } return planLimitHintFor(bodyMsg, route) } // planLimitHintFor generates the actionable hint for ErrPlanLimitExceeded. // The upgrade_url is already in the Details blob; the hint surfaces it in // prose so agents that only read Hint (not Details) still get the destination. func planLimitHintFor(bodyMsg, route string) string { parts := []string{"Upgrade to Pro at /console/billing to remove this limit."} if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } if bodyMsg != "" { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } // rateLimitHintFor generates the actionable hint for ErrRateLimited. // 429s are recoverable with backoff — the hint encodes that bias and // points at the Retry-After response header. BUG-1430 added this // helper alongside the dedicated case in classifyHTTPStatusKind so // agents see a distinct envelope for "back off" vs ErrServerError's // "file a bug" framing. // // The hint is intentionally generic about the cap. classifyHTTPStatusKind // handles 429s from the dispatcher's SYNTHESIZED /api/v1/... requests, // which can fire from several different limiters with different sizing // (the general API limiter at 600/min/burst-60, the Search limiter at // 30/min/burst-10, etc.) — Codex review #546 round 1 [P2] caught the // first draft of this hint hard-coding the MCP per-token cap, which // fires BEFORE the dispatcher runs and so never lands here. The // Retry-After header carries the limiter-specific wait time; agents // honoring it get correct backoff without needing the cap in prose. func rateLimitHintFor(bodyMsg, route string) string { parts := []string{ "Rate-limited by the backend. Retry after a backoff (see the Retry-After response header for the suggested wait).", "For burst-heavy workflows (agent onboarding, bulk import), space out tool calls or sequence them.", } if route != "" { parts = append(parts, fmt.Sprintf("Route: %s.", route)) } if bodyMsg != "" { parts = append(parts, fmt.Sprintf("Backend: %s", bodyMsg)) } return strings.Join(parts, " ") } // tooLargeHint mirrors cli.TooLargeHint, duplicated for the reason // structuredErrorMarker is and pinned equal to it by a test, so the two // transports give the same guidance for the same refusal (BUG-2829). const tooLargeHint = "Refused by a server size cap, not a fault: retrying the same call fails the same way. " + "Shrink or split the change (details.reason names which cap), then retry." // tooLargeHintFor is the guidance for a 413 (BUG-2829). It does not repeat the // server's message, which is already the envelope's message. func tooLargeHintFor(route string) string { hint := tooLargeHint if route != "" { hint += fmt.Sprintf(" Route: %s.", route) } return hint } // ───────────────────────────────────────────────────────────────────── // Helpers for non-HTTP error paths (TASK-1077 — replace plain-string // NewToolResultErrorf with structured envelopes uniformly). // ───────────────────────────────────────────────────────────────────── // validationFailedResult wraps a "missing required input" or other // caller-input error into the structured envelope. cmdKey identifies // which tool was invoked; msg is the human-readable issue (e.g. // "workspace is required", "ref is required"); fixHint is the // recovery suggestion. func validationFailedResult(cmdKey, msg, fixHint string) *CallToolResult { return NewErrorResult(ErrorPayload{ Code: ErrValidationFailed, Message: fmt.Sprintf("%s: %s", cmdKey, msg), Hint: fixHint, }) } // dispatcherErrorResult wraps an internal dispatcher failure // (build request, encode body, parse response, marshal merged // fields, etc.) into the structured envelope. These shouldn't // happen at runtime — they're usually programmer errors or // genuinely unexpected I/O failures. // // op is a short verb describing what failed ("build prefetch // request", "encode body", "parse current item"); err is the // underlying error — its message goes in the hint so debugging // info isn't lost. func dispatcherErrorResult(cmdKey, op string, err error) *CallToolResult { hint := fmt.Sprintf("Internal: %s — %s", op, err.Error()) return NewErrorResult(ErrorPayload{ Code: ErrServerError, Message: fmt.Sprintf("%s: %s failed", cmdKey, op), Hint: hint, }) } // upstreamHTTPErrorResult is the canonical wrapper for "the // dispatcher made an HTTP call that returned a non-2xx" cases // outside the main executeRequest/packageHTTPResponse pipeline // (in-handler prefetches, link create/delete, project dispatchers, // etc.). Routes the failure through classifyHTTPStatusKind so the // shape matches the main pipeline exactly — agents see the same // envelope regardless of which dispatcher path produced it. func upstreamHTTPErrorResult( ctx context.Context, cmdKey, op, route string, status int, body []byte, lookup WorkspaceLister, kind ResourceKind, refOrSlug string, ) *CallToolResult { res := classifyHTTPStatusKind(ctx, cmdKey, route, status, body, lookup, kind, refOrSlug) // Layer the per-call op verb into the message so a "prefetch" // failure surfaces distinct from a top-level "execute" failure. env := envelopeFrom(res) if op != "" && env.Error.Message != "" { env.Error.Message = fmt.Sprintf("%s (%s)", env.Error.Message, op) } return NewErrorResult(env.Error) }