mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
ed89604ad8
* fix(mcp): a refused call names its cause on both transports (BUG-3864) A 403 from one of the shared gates now carries details.reason (role, token_scope, no_access, workspace_not_authorized), with code and message unchanged. MCP builds one permission_denied envelope from it on both transports, with a hint that says what to do next. It used to lead with "Insufficient role" for every 403, so a bot with a read-only API token was told to ask for a role. An unverified Cloud email is reported as the server's own email_not_verified code. Stdio gets the same envelope from a structured marker the CLI root writes for a 403 coded forbidden, permission_denied or email_not_verified. Before it, the token-scope and unverified-email refusals said server_error there. ToolSurfaceVersion 0.99 -> 0.100 (behavior). Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): the inline workspace-role gates name their reason too (BUG-3864) The bulk endpoint, the owner-only member doors and the owner-only app install and upgrade doors say role, or no_access for a guest. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(mcp): the permission hint is the same on both transports (BUG-3864) The remote hint appended the route, which stdio has no way to know, so one refusal reached the two transports with different hints. The tests compared prefixes only and missed it; both now assert the transports agree exactly. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): no role hint for a caller with no role to change (BUG-3864) requireMinRole and the grants list said role for a guest, who holds grants rather than a role, and for a caller with no workspace role at all. Both now go through roleGateReason, which answers no_access for either. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
1825 lines
81 KiB
Go
1825 lines
81 KiB
Go
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=<slug> 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=<slug> 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=<slug>; 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 <ref> --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 <cmd> 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: <type-mismatch>") 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 <ref>, 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: [\"<key>=<value>\"].", 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: [\"<field>=<value>\"].", 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)
|
|
}
|