mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 11:52:08 +00:00
ba303e456fa66bd92e27206aee463812e7e21021
268 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ba303e456f |
fix(mcp): publish PAD_MCP_PUBLIC_URL verbatim as canonical resource (no /mcp suffix) (#381)
Per the MCP authorization spec the client MUST verify the URL it was given matches the discovery doc's `resource` field exactly; auto- suffixing was forcing operators publishing the bare hostname (the industry convention — mcp.stripe.com, mcp.linear.app, mcp.atlassian.com) into a permanent client-side mismatch and Claude Desktop / Cursor reject pasting `https://mcp.getpad.dev` even though everything else works. Both production sites that previously appended "/mcp" to MCPPublicURL now use the value verbatim: - cmd/pad/main.go: AllowedAudience for the OAuth server constructor. Tokens are now audience-bound to MCPPublicURL exactly. - internal/server/handlers_well_known.go: the protected-resource discovery doc's `resource` field is the bare MCPPublicURL. The transport itself is unchanged — pad still mounts at /mcp on the chi router; pad-cloud's nginx router transparently rewrites mcp.* root → /mcp (TASK-997 PR #28) so external clients see a single canonical URL regardless of the internal HTTP path. The audience binding is just a string; it doesn't have to equal the internal mount path. config.go's MCPPublicURL doc updated to reflect the new semantic ("canonical URL clients paste") rather than the old "vhost URL we suffix-mangle". Operators who want the old shape just include the /mcp suffix in PAD_MCP_PUBLIC_URL — the operator owns the canonical. Test fixtures: testCanonicalAudience flipped from "https://mcp.test.example/mcp" to "https://mcp.test.example", and the two SetMCPTransport call sites that previously stripped /mcp now pass it directly. The TestMCP_DiscoveryDoc_PopulatedFromConfig assertion uses testCanonicalAudience so future renames stay consistent. All other test sites (audience= form fields, aud claim checks, mismatch fixtures) keep working unchanged because they reference testCanonicalAudience symbolically. |
||
|
|
69e471db8f |
fix(oauth): default to canonical audience when client omits RFC 8707 resource= (TASK-951) (#380)
* fix(oauth): default to canonical audience when client omits RFC 8707 resource= (TASK-951)
Real MCP clients (Claude Desktop, Cursor as of 2026-05) don't send the
RFC 8707 `resource` parameter on /oauth/authorize at all. Before this
fix, translateResourceToAudience only translated resource→audience
when resource= was present, so empty-resource requests reached
fosite's audienceMatchingStrategy with an empty needle and got
rejected with "resource parameter is required (RFC 8707)". fosite
then redirected to the client's redirect_uri with
?error=invalid_request&error_description=..., and Claude's
backend callback failed with the pydantic envelope "code: Field required"
(because no `code` parameter was in the redirect query).
RFC 8707 §2 marks the resource parameter OPTIONAL; servers with a
single canonical audience are expected to default to it. pad's OAuth
server has exactly one canonical audience by construction
(cfg.MCPPublicURL + "/mcp"), so the right policy is to inject
canonical when the client sends neither resource= nor audience=.
Now translateResourceToAudience handles three cases in priority order:
1. audience= already set — leave both keys untouched.
2. resource= present — copy to audience= (existing path).
3. Neither present — inject canonical into both. The token gets
bound to canonical exactly as if the client had sent it.
audienceMatchingStrategy's strict empty-needle reject stays as
defense in depth: case 3 only fires when canonical is configured
(main.go won't construct the OAuth server otherwise), but if some
future code path bypasses the translation helper, the matching
strategy still fails loudly rather than minting an unbound token.
Adds TestOAuth_Authorize_AcceptsNoResource_DefaultsToCanonical
pinning Claude Desktop's exact request shape (no resource=, no
audience=). Pairs with the existing AcceptsResourceOnly and
audience-mismatch tests to lock in the full /authorize matrix.
* docs(oauth): document RFC 8707 cross-server replay trade-off + audit log
Per Codex review #383 round 1: defaulting to canonical when the
client omits resource= weakens the cross-server replay defense
RFC 8707 was designed to provide. Threat is the confused-deputy
attack — malicious MCP server lies that pad's AS is its AS,
client (which doesn't send resource=) drives a flow against pad's
AS, pad mints a token bound to canonical, client returns it to
the attacker, attacker replays at pad's /mcp.
We're shipping with the default-to-canonical path because every
real-world MCP client (Claude Desktop / Cursor / ChatGPT as of
2026-05) omits resource= and the alternative is "remote MCP
doesn't work for any client until the entire ecosystem adopts
RFC 8707."
Mitigations now documented in the comment + active in the code:
- Consent screen (TASK-952) is the trust anchor. Every grant
requires a click-through that identifies the resource as
"your Pad workspaces" and lists the user's actual workspace
names. A user attempting to connect to a non-pad MCP server
who lands on pad's consent screen sees the mismatch.
- Matches industry practice (GitHub / Google / Atlassian all
rely on consent-as-trust-anchor since RFC 8707 is barely
deployed).
- audienceMatchingStrategy's strict empty-needle reject stays
as defense in depth — fires when canonical is unset and on
any future code path that bypasses the helper.
- Audit log (slog.Warn) on every default-fire gives ops a
signal to detect anomalies — a spike of defaulted requests
from a previously-unseen client_id is the earliest detectable
shape of a confused-deputy attempt.
Future task tracks restoring the strict reject once Claude /
Cursor / ChatGPT all send resource=.
|
||
|
|
9eb1a35f16 |
feat(mcp): privacy-preserving available_workspaces filter on error envelopes (TASK-977) (#379)
* feat(mcp): privacy-preserving available_workspaces filter on error envelopes (TASK-977)
Closes the last open work item in PLAN-943. HTTPHandlerDispatcher's
unknown_workspace error envelope now populates available_workspaces
filtered by the OAuth token's consent allow-list (TASK-952), so an
agent never sees workspace slugs the user didn't explicitly grant.
## What changed
- `HTTPHandlerDispatcher` gains a `Lister WorkspaceLister` field.
Production wires `mcpserver.NewOAuthWorkspaceLister(s)`; tests
can supply mocks.
- `packageHTTPResponse` now takes a `lister` parameter and threads
it down to `classifyHTTPStatus`. Both call sites in the package
updated.
- New `oauthWorkspaceLister` reads three things from request context:
- `server.CurrentUserFromContext` — the requesting user.
- `server.TokenAllowedWorkspacesFromContext` — the consent
allow-list (TASK-953 plumbing).
- `s.GetUserWorkspaces(user.ID)` — the user's full set.
Returns the intersection. Wildcard (`["*"]`) and nil (PAT auth)
short-circuit to "no filter" — the user's full set is returned
in those cases since the token doesn't constrain workspaces.
- `cmd/pad/main.go` wires the production lister.
## Privacy invariant
A token whose allow-list is `[alpha, beta]` MUST NOT see "gamma"
in the available_workspaces hint, even if the user is a member of
gamma. Tested explicitly via
TestUnknownWorkspace_AvailableWorkspaces_FilteredByAllowList —
the test fakes a 4-workspace user membership, sets allow-list to
2, and asserts exactly 2 slugs appear in the filtered envelope.
Without this filter, an attacker controlling an OAuth client could
hit any random workspace slug, get the unknown_workspace envelope,
and read OFF the user's full workspace list — defeating the whole
point of the consent UI's per-workspace selection.
## Tests (18 new)
8 envelope round-trip tests pin every documented HTTP status →
ErrorCode mapping (401 → auth_required, 403 → permission_denied,
404 generic → item_not_found, 404 workspace → unknown_workspace,
409 → conflict, 400/422 → validation_failed, 5xx → server_error,
418 → server_error fallback).
5 privacy-filter tests cover the allow-list shapes:
specific-list-filters, wildcard-no-filter, no-allow-list-no-filter,
no-user-empty-hints, store-error-empty-hints.
4 buildAllowSet unit tests for the helper.
1 end-to-end test through packageHTTPResponse.
* fix(mcp): use req.Context() when packaging HTTP response (Codex round 1)
Codex review #379 round 1 caught a real correctness issue: the
packageHTTPResponse calls in executeRequest + the prefetch path in
dispatchItemUpdate passed the dispatcher's outer ctx instead of
req.Context(). The lister reads CurrentUser + TokenAllowedWorkspaces
from context, and the canonical "everything attached" context is
the SYNTHESIZED request's context — buildHTTPRequest layers
WithCurrentUser + WithAPITokenAuth on it, and d.Apply (when wired)
attaches token state on top of req specifically.
In production this happened to work because MCPBearerAuth attaches
TokenAllowedWorkspaces on the inbound /mcp request's context, which
the dispatcher inherits as its outer ctx. But:
- Tests driving executeRequest with context.Background() + a
UserResolver-supplied user got empty available_workspaces
because the outer ctx had no user.
- Any future dispatcher attaching token state via Apply (rather
than relying on inbound-ctx propagation) would also see the
bug — the Apply hook is documented as the place for "TASK-953
token-scope context" exactly.
Fix: pass req.Context() / prefetchReq.Context() to packageHTTPResponse.
Same dispatcher, same ServeHTTP — just feed the lister the canonical
post-Apply context.
Test: TestExecuteRequest_UsesRequestContext_NotOuterContext drives
executeRequest with an empty outer context + a UserResolver, asserts
the resulting unknown_workspace envelope has the user's full
workspace list. With the buggy version the test fails (lister sees
no user → empty hints).
|
||
|
|
3319ad5ea1 |
feat(mcp): per-token rate limit on /mcp (TASK-959) (#378)
* feat(mcp): per-token rate limit on /mcp (TASK-959)
Add a per-token rate limit to /mcp's auth middleware. Closes the
"runaway agent burns through user quota" gap that PLAN-943 left as
a follow-up to TASK-950.
## Policy
- 60 requests / minute / token, burst 20.
- Per-token (not per-IP): office-NAT-shared users don't share a
bucket, and a runaway agent on one token can't burn another
token's quota for the same user.
- Limiter key: SHA-256(bearer) — the raw token never lives in the
limiter map even though buckets persist for the 5-minute
retention window.
- Discovery docs (`/.well-known/oauth-*`) are NOT rate-limited.
They're polled by MCP clients before any token exists; rate-
limiting them per-IP would penalize office NATs and per-bearer
doesn't apply (no bearer to hash).
- No-bearer requests are 401'd before the limiter sees them, so a
bare-bones DoS via empty Authorization headers gets the cheap
rejection path without sharing a (necessarily-empty) bucket key.
## 429 response
Per RFC 6585: `Retry-After: <seconds>` header (computed from the
limiter's refill rate), plus `X-RateLimit-Limit`,
`X-RateLimit-Remaining: 0`. Body is the MCP-shaped JSON envelope
`{"error": {"code": "rate_limited", "message": "..."}}` so MCP
clients (Claude Desktop, Cursor) can surface the error consistently.
## Implementation
- `RateLimiters.MCPPerToken` — new `*ipRateLimiter` instance,
drained in `Stop()` so cleanup goroutines don't leak (BUG-851
pattern).
- `Server.checkMCPRateLimit` — called from `MCPBearerAuth` BEFORE
auth validation. Returns false + writes 429 when bucket is
exhausted; auth still 401s if the token is also invalid (the
rate limit and validity checks are independent).
- `hashTokenForLimiter` — SHA-256 hex digest helper. Uniform with
the limiter's other (IP-string) keys.
- `writeMCPRateLimit` — emits the 429 envelope.
## Tests
- TestMCPRateLimit_PerToken_BucketEnforced — single token → 429
within 30 attempts (60/min, burst 20).
- TestMCPRateLimit_PerToken_TwoTokensIndependent — drain token1
to 429, verify token2 still passes a full burst.
- TestMCPRateLimit_DiscoveryDocsExempt — 50 hits to
/.well-known/oauth-protected-resource, zero 429s.
- TestMCPRateLimit_NoBearer_NotCounted — no-bearer requests 401
before the limiter, no 429s.
- TestMCPRateLimit_429EnvelopeShape — Retry-After,
X-RateLimit-* headers, MCP error envelope shape.
- TestHashTokenForLimiter — hash determinism, length, no collision
by prefix, empty input safety.
* fix(mcp): move per-token rate limit AFTER auth validation (Codex round 1)
Codex review #378 round 1 caught a memory-DoS risk: the pre-auth
limiter created a new bucket entry for every distinct bearer
string. An attacker rotating random bearer values would grow the
limiter map unbounded until the 5-minute cleanup tick — millions
of phantom entries before the goroutine catches up.
Fix: relocate the checkMCPRateLimit call to AFTER auth validation
in both PAT and OAuth paths. The limiter map now only fills with
hashes of *valid* tokens, bounding map size by the active-token
count rather than by the bearer-string space.
Trade-off: invalid-bearer spam still hits the auth path's DB
lookup (CPU cost, but a single indexed read per request) without
any rate limiting. The CPU exposure is small enough to accept for
v1; a follow-up could add a pre-auth per-IP cap for invalid-token
flooding if real abuse appears.
Tests:
- TestMCPRateLimit_InvalidBearerNotRateLimited — 50 invalid
bearers in a row, none get 429 (always 401).
- TestMCPRateLimit_LimiterMapBoundedByValidTokensOnly — direct
regression: 100 distinct invalid bearers, limiter map size
must NOT grow.
- Existing happy-path tests updated to use real PATs (via the new
mustCreatePATForTest helper) so the post-auth-validation guard
doesn't short-circuit them.
* fix(mcp): move OAuth rate limit AFTER all validation gates (Codex round 2)
Codex review #378 round 2 caught a P3 gap in round 1's fix. The
OAuth path's rate-limit check ran AFTER IntrospectToken but BEFORE:
- access-token-vs-refresh-token check
- RFC 8707 audience match
- session.GetSubject() presence
- GetUser lookup
So an active-but-not-authorized OAuth bearer (refresh token used as
a bearer, wrong-audience token, deleted user) would create a
limiter entry. After 30 such requests the response would flip from
the intended 401 invalid_token to 429 — leaking limiter state to
attackers and slightly defeating the bounded-map property.
Fix: move the OAuth-path checkMCPRateLimit call to the very end of
handleMCPOAuthAuth, just before context attachment + next.ServeHTTP.
Now the limiter map only contains tokens that would have reached
the dispatcher otherwise.
Test: TestMCPRateLimit_OAuthRefreshTokenNotCounted — mints a real
refresh token via the full OAuth flow, hammers /mcp with it 50
times, asserts every response is 401 AND the limiter map size is
unchanged.
* fix(mcp): move PAT rate limit AFTER all validation gates (Codex round 3)
Codex review #378 round 3 caught the symmetric issue in the PAT
path that round 2 fixed for OAuth. checkMCPRateLimit ran AFTER
ValidateToken but BEFORE:
- apiToken.UserID == "" check (legacy workspace-scoped tokens)
- GetUser lookup (deleted-user case)
Active-but-not-authorized PAT bearers (legacy tokens with no
user_id, tokens whose user was deleted) would have created limiter
entries and eventually 429'd instead of returning the intended
401 invalid_token.
Fix: move the PAT-path checkMCPRateLimit call to the very end of
handleMCPPATAuth, just before context attachment + next.ServeHTTP.
Now mirrors the OAuth path's positioning — both run the rate limit
exactly once, at the END of their happy path, so the limiter map
only contains tokens that would otherwise reach the dispatcher.
|
||
|
|
d01bbf6bf1 |
feat(oauth): live workspace allow-list + role enforcement (TASK-953) (#377)
Closes the third leg of PLAN-943's OAuth permission model:
(token capability tier) × (live workspace role) × (consent allow-list)
The first two were already in place — TASK-1027 wired the tier
scope check (pad:read / pad:write / pad:admin via tokenScopeAllows)
and RequireWorkspaceAccess does the live role lookup. This PR adds
the third gate: the workspace-allow-list set at consent time
(TASK-952) actually denies workspaces NOT in the user's selection.
## What's new
- `oauth.Session.AllowedWorkspaces()` / `SetAllowedWorkspaces()` —
typed accessors on session.Extra. Handle BOTH the in-memory
[]string shape (consent-decide path) AND the JSON-decoded
[]interface{} shape (post-storage round-trip path).
- `WithTokenAllowedWorkspaces` / `TokenAllowedWorkspacesFromContext` —
context helpers in internal/server with defensive copies so
callers can't corrupt the per-request token state.
- `MCPBearerAuth` (OAuth path) reads the token's allow-list from
session.Extra and stashes it in context.
- `RequireWorkspaceAccess` checks the allow-list against the
resolved workspace's slug. Three behaviours match
TokenAllowedWorkspacesFromContext's return shapes:
- nil → no token-level gate (PAT auth, pre-TASK-952 OAuth
tokens). Standard membership applies.
- ["*"] → wildcard. Every membership the user has passes.
- [slug-a, slug-b, ...] → only listed slugs. Anything else
gets 403 permission_denied BEFORE the membership check.
## Live role + revocation
Membership revocation takes effect immediately. RequireWorkspaceAccess
calls GetWorkspaceMember on every request — if the user lost
membership in workspace X, the token's allow-list including X no
longer helps; the request is rejected at the standard membership
gate. Tested explicitly via TestWorkspaceAllowList_LiveMembershipRevocation.
## Tier × role
The natural intersection of tokenScopeAllows (tier-based HTTP-method
gate) and per-handler role checks (e.g. requireEditPermission) handles
the tier × role table from the PLAN-943 spec:
- pad:write tier passes tokenScopeAllows for POST.
- But Viewer role fails requireEditPermission's role check.
- Net: 403 — tested explicitly via
TestWorkspaceAllowList_TierTimesRole_WriteByViewer.
## Tests
Unit (no I/O):
- TestTokenAllowedWorkspaceMatches — policy table for the helper.
- TestWithTokenAllowedWorkspaces_DefensiveCopy + 1 reader counterpart.
- TestSession_AllowedWorkspaces_*: setter/getter, nil-clear, defensive
copy, JSON round-trip ([]string + []interface{} branches),
wildcard JSON round-trip, not-set, nil-session.
Integration (full chain, real OAuth flow):
- TestWorkspaceAllowList_AllowsListedSlug — listed workspace passes.
- TestWorkspaceAllowList_DeniesUnlistedSlug — unlisted gets 403
even though user is owner.
- TestWorkspaceAllowList_WildcardAllowsAnyMembership — wildcard
passes for every membership.
- TestWorkspaceAllowList_LiveMembershipRevocation — token works,
then membership revoked, then same token denied.
- TestWorkspaceAllowList_PATPathUnaffected — PAT regression: PATs
don't carry an allow-list, must NOT hit the gate.
- TestWorkspaceAllowList_TierTimesRole_WriteByViewer — pad:write
tier × Viewer role on POST item → 403.
|
||
|
|
7d0de978f7 |
feat(oauth): consent UI with workspace allow-list + capability tier (TASK-952) (#376)
* feat(oauth): consent UI with workspace allow-list + capability tier (TASK-952)
Replace the inline-HTML stub from sub-PR C (TASK-1025) with the real
consent page described in PLAN-943: server-rendered HTML with
workspace multi-select, "any workspace" wildcard, and a capability-
tier radio (read / write / admin).
## What the page does
- Lists every workspace the user is a member of, with their role
shown next to each row (informational — TASK-953 does live role
resolution at MCP-call time).
- Wildcard checkbox grants "any workspace I currently or later have
access to," with a clear warning when checked. Mutually exclusive
with per-workspace boxes (vanilla JS for UX, server-side rejection
as the security gate).
- Capability tier radio is constrained to the intersection of
{pad:read, pad:write, pad:admin} and the client's requested
scopes — fosite's grant-time subset check (RFC 6749 §3.3) rejects
scopes outside the request, so the UI must never offer them. Default
selects the highest tier the client requested.
- Allow button stays disabled until ≥1 workspace (or wildcard) is
selected. Server-side validation enforces the same rule regardless
of JS state.
## Selective consent
This is the central security property. The decide handler now grants
*exactly* the chosen tier scope, NOT every requested scope. If the
client requests `pad:read pad:write` and the user picks "read", the
issued token has `scope=pad:read` only.
Bonus fix: removed redundant scope re-grant loop in handleOAuthToken
that would have expanded granted scopes back to the full requested
set on every /token exchange — a real security bug that the
auto-approve stub from sub-PR C masked because granted == requested
for that flow. fosite's flow_authorize_code_token.go:134-138 +
flow_refresh.go:91-103 copy GrantedScope/Audience automatically;
our loop was undoing selective consent.
## Workspace allow-list storage
The user's workspace selections live in `session.Extra["allowed_workspaces"]`
(round-trips via storage.go's existing JSON marshal). Either
`["*"]` for wildcard or a list of slugs. fosite's
WriteIntrospectionResponse serializes Extra into the introspection
response as top-level fields, so TASK-953's enforcement layer reads
them off `/oauth/introspect` (or in-process via fosite.IntrospectToken).
This sidesteps fosite's strict "granted ⊆ requested ⊆ client.Scopes"
check — clients don't request `pad:workspaces:foo`, but the consent
UI lets the user pick from their workspaces regardless. TASK-953
implements the live role resolution + workspace gate.
## Defense in depth
- Server validates `capability_tier ∈ {read, write, admin}` AND that
the chosen tier is among the client's requested scopes — fosite
would reject otherwise with a less-readable error.
- Server validates every non-wildcard slug is in the user's current
membership table. A tampered form sending other slugs gets 400.
- Wildcard wins: if a tampered POST sends both `*` and specific
slugs, the result is `["*"]` only — never partial allow-list.
## Tests
- TestConsent_RendersUserWorkspaces — multi-workspace list with role
labels.
- TestConsent_NoWorkspaces_ShowsEmptyState — clean empty state.
- TestConsent_TierRadios_OnlyRequestedScopes — UI hides tiers the
client didn't request.
- TestConsent_ApproveWithSpecificWorkspaces — happy path, asserts
introspection returns `allowed_workspaces=[alpha, beta]`.
- TestConsent_ApproveWithWildcard — wildcard yields `["*"]`.
- TestConsent_ApproveWithoutWorkspaceSelection_Rejected — 400 on
empty allow-list.
- TestConsent_ApproveWithUntrustedSlug_Rejected — defense in depth.
- TestConsent_ApproveWithUnrequestedTier_Rejected — server tier
validation matches UI's tier-radio constraint.
- TestConsent_TokenScopeMatchesTierChoice_Read — selective consent:
user picks read-only despite client requesting both, token has
exactly `pad:read`.
Existing tests + helpers updated to include the new consent fields
(`capability_tier`, `allowed_workspaces`).
* fix(oauth): prevent URL parameter pollution attack on consent UI (round 1)
Codex review #376 round 1 caught a P1 security bug in the consent
UI. The hidden-input round-trip used the full r.URL.Query() with
only `csrf_token` stripped, so a malicious OAuth client could craft
/oauth/authorize?...&capability_tier=admin&allowed_workspaces=*
and the consent form would render those as hidden inputs BEFORE the
user-controlled radios + checkboxes. On submit, the hidden values
precede the user's selection in the form encoding, so:
- r.FormValue("capability_tier") returns "admin" (first value
matches the attacker's, not the user's)
- r.PostForm["allowed_workspaces"] sees "*" first, the wildcard
scan matches, the result is ["*"] regardless of which boxes
the user actually checked
Net effect: a user clicking through the consent UI for "read-only,
just my docapp workspace" would silently authorize "admin, all
workspaces" — without any visible cue that the values were wrong.
Fix: build hidden inputs from an explicit allowlist of OAuth-standard
authorize-request parameters (response_type, client_id, redirect_uri,
scope, state, audience, resource, code_challenge, code_challenge_method,
nonce). Anything outside the allowlist is silently dropped. This is
strictly stronger than blocklisting consent-control names, because
it also defends against future OAuth extensions adding new attacker-
controllable params we haven't enumerated.
Test: TestConsent_URLPollution_DoesNotOverrideUserSelection simulates
the attack — GET /authorize with attacker params, asserts the rendered
HTML contains zero `<input type="hidden" name="<attacker_name>">`,
then completes the flow with the user's actual selection and
confirms the issued token's scope matches the user's choice
(pad:read), not the attacker's URL injection (pad:admin).
|
||
|
|
924d82dae4 |
feat(oauth): MCPBearerAuth OAuth integration + public-info (TASK-1027) — closes TASK-951 (#375)
* feat(oauth): MCPBearerAuth OAuth integration + public-info endpoint (TASK-1027, sub-PR E of TASK-951)
Closes the OAuth server build-out by connecting sub-PRs A-D to the MCP
transport from TASK-950 and shipping the consent-screen support endpoint.
## MCPBearerAuth OAuth path
middleware_mcp_auth.go now branches on token shape:
- pad_<60-hex> → existing PAT validation (TASK-950 path)
- anything else → fosite.IntrospectToken via the new
internal/oauth.Server.IntrospectToken wrapper (server-side, no
HTTP roundtrip — pad-cloud is both auth server and resource
server, so the public /oauth/introspect endpoint is for external
clients only).
OAuth path validation gates:
- Token must be active (fosite returns ErrInactiveToken / ErrNotFound
on revoked / unknown / expired tokens).
- tokenUse must be access_token; refresh tokens explicitly rejected
(RFC 6749 §1.5 — refresh tokens aren't bearers for resource calls).
- Granted audience MUST contain the canonical MCP URL (RFC 8707
anti-replay; resource-server-side check defends against compromised
or shared auth servers).
- Subject must resolve to a real user row.
Successful path stashes user + scopes via WithCurrentUser /
WithTokenScopes. Scopes are translated from fosite's space-separated
form to JSON-array form via oauthScopesToJSON.
## tokenScopeAllows pad:* extension
Extended to recognize the OAuth scope vocabulary alongside PAT scopes:
- pad:read ↔ read (GET/HEAD/OPTIONS only)
- pad:write ↔ write (all methods)
- pad:admin ↔ * (all methods)
So MCP tool authorization stays uniform regardless of which transport
issued the bearer.
## /api/v1/oauth/clients/{id}/public-info
New read-only endpoint for the consent screen (TASK-952) and the
OAuth-intent banner (TASK-1001, already shipped). Returns four
non-sensitive fields: client_id, client_name, logo_uri, redirect_uris.
- Auth-required (any logged-in user).
- Cloud-mode-gated (404s outside cloud).
- 404 for unknown clients.
- Whitelisted leak surface — explicit fields, no embedded
models.OAuthClient, so a future field addition (e.g. a confidential-
client secret) doesn't accidentally appear here.
## Tests
- TestMCP_OAuthAccessToken_Authenticates — happy path: full flow
yields a token that authenticates against /mcp.
- TestMCP_OAuthAccessToken_AudienceMismatch_Rejected — RFC 8707
resource-server check; mints a token, swaps the OAuth server
for one with a different canonical, confirms 401.
- TestMCP_OAuthRefreshToken_RejectedAtMCP — refresh tokens MUST
NOT authenticate.
- TestMCP_RevokedOAuthToken_Rejected — revocation takes effect at
the resource server.
- TestMCP_PATPath_StillWorks — regression for sub-PR D's coexistence
with the OAuth path.
- TestMCP_OAuthScopeReadOnly_StashesPadReadScope — scope round-trip.
- TestOAuthClientPublicInfo_HappyPath / UnknownClient_404 /
Unauthenticated_401 / NotMountedOutsideCloudMode — full coverage
of the new endpoint.
- TestE2E_ClaudeDesktopFlow — simulates the full sequence
(discovery → DCR → authorize → token → /mcp call) Claude Desktop
walks on first connect.
- TestTokenScopeAllows extended with pad:* coverage.
## TASK-951 status
Closes TASK-951 when this lands (5/5 sub-PRs done):
- A: schema + storage layer (#370 /
|
||
|
|
4250fb1976 |
feat(oauth): revoke + introspect endpoints (TASK-1026) (#373)
* feat(oauth): revoke + introspect endpoints (TASK-1026, sub-PR D of TASK-951)
Add the RFC 7009 revocation and RFC 7662 introspection endpoints,
completing the spec'd surface that sub-PR C left as placeholders.
- POST /oauth/revoke — fosite NewRevocationRequest delegates to our
storage adapter's RevokeRefreshToken / RevokeAccessToken which
walk the request_id (grant family) and mark every chain member
inactive in one statement. Public clients authenticate by sending
only client_id (token_endpoint_auth_method=none).
- POST /oauth/introspect — fosite NewIntrospectionRequest with
Bearer auth (a separate active access token). Returns
{active:true, sub, scope, aud, client_id, exp, iat} for active
tokens; bare {active:false} for unknown/revoked/expired (RFC 7662
§2.2 no-leak rule). Sub-PR E's MCPBearerAuth integration uses
fosite.IntrospectToken directly server-side, but the public
endpoint satisfies the discovery contract for clients that follow
the chain.
- Discovery doc populates revocation_endpoint +
introspection_endpoint and their auth_methods_supported lists
("none" for both — public-clients-only model).
Tests cover:
- /revoke marks an access token inactive (verified via introspect).
- /revoke on a refresh token revokes the entire grant family
(paired access also goes inactive).
- Refresh-token rotation: old pair becomes inactive, new pair active.
- Refresh-token replay detection: replaying a rotated refresh kills
the family (OAuth 2.1 §6.1, RFC 6819 §5.2.2.3).
- Introspect happy path returns sub/scope/aud/client_id/exp.
- Introspect on unknown token returns just {active:false} with no
field leakage.
- Introspect rejects requests with no Bearer Authorization header.
- /revoke + /introspect 404 outside cloud mode.
- Discovery doc advertises both endpoints + auth-methods lists.
* fix(oauth): drop introspection_endpoint_auth_methods_supported per Codex review (round 1)
Codex review of #373 caught a contradiction in the discovery doc:
introspection_endpoint_auth_methods_supported: ["none"]
advertised "no client authentication" for the introspection endpoint,
but fosite's NewIntrospectionRequest rejects a request without
Authorization: Bearer ... (and the test in this PR locks that in).
A discovery-driven client would treat "none" as "post token+client_id
unauthenticated" and get 401 — worse than no advertisement at all.
Fix: omit introspection_endpoint_auth_methods_supported entirely.
RFC 8414 §2 marks the field OPTIONAL; omission tells clients to
negotiate auth out-of-band, which for our public-clients-only model
means "send a separate active access token in the Authorization
header." We document that in getpad.dev/mcp/local.
revocation_endpoint_auth_methods_supported = ["none"] is kept and
honest — fosite's NewRevocationRequest really does accept a public
client posting only client_id (no Bearer required).
* fix(oauth): RFC 7009 §2.2 idempotent revoke per Codex review (round 2)
Codex caught that fosite v0.49 returns ErrInvalidRequest for the
unknown-token path of NewRevocationRequest, which WriteRevocationResponse
turns into 400. RFC 7009 §2.2 explicitly requires:
"The authorization server responds with HTTP status code 200 if
the token has been revoked successfully or if the client submitted
an invalid token."
The 400 break the entirely normal "client retried after a previous
revoke succeeded" or "operator typo'd the token" cases.
Fix: detect the bare ErrInvalidRequest from the !found branch via
isRevocationUnknownToken (which inspects HintField — fosite sets the
hint on every other ErrInvalidRequest path it returns from
NewRevocationRequest) and write 200 directly. Genuine malformed
requests (wrong method, unparseable body, empty form) still return
400 because their ErrInvalidRequest carries a hint.
Tests:
- TestOAuth_Revoke_UnknownToken_Returns200 — locks in 200 for the
unknown-token path.
- TestOAuth_Revoke_MalformedRequest_Returns400 — counterpart that
ensures the 200 override doesn't accidentally swallow real
malformed-request errors.
* fix(oauth): require token param + remove dead-code revoke override (round 3)
Codex round 3 noticed that POST /oauth/revoke with client_id but no
token returned 200 OK — silently swallowing a missing-required-
parameter error. RFC 7009 §2.1 marks `token` REQUIRED.
Investigating the fix surfaced that round 2's isRevocationUnknownToken
override was actually dead code: fosite v0.49's
handler/oauth2/revocation.go's RevokeToken collapses ErrNotFound +
ErrInactiveToken to nil via storeErrorsToRevocationError, so
NewRevocationRequest returns nil and WriteRevocationResponse writes
200 natively for unknown tokens. The override never fired in any
real path.
Cleanup:
- Replace the unused isRevocationUnknownToken + override with a
pre-check that returns 400 invalid_request when `token` is
missing. RFC 7009 §2.1 enforced; fosite's native idempotency
handles unknown tokens.
- Update TestOAuth_Revoke_UnknownToken_Returns200's comment to
reflect that it pins fosite's native behavior (not our override).
- Add TestOAuth_Revoke_MissingToken_Returns400 to lock in the
pre-check.
- Keep TestOAuth_Revoke_MalformedRequest_Returns400 — verifies
fosite's own ErrInvalidRequest paths still surface as 400.
|
||
|
|
48776a3967 |
feat(oauth): DCR + authorize + token endpoints + populated discovery (TASK-1025, sub-PR C of TASK-951) (#372)
* feat(oauth): DCR + authorize + token endpoints + populated discovery doc (TASK-1025, sub-PR C of TASK-951)
Third of 5 sub-PRs landing the OAuth 2.1 authorization server in
PLAN-943. Mounts the three flow-driving HTTP endpoints over the
fosite-backed server constructed in sub-PR B, replaces the
TASK-950 501 stub with the real RFC 8414 discovery doc, and ships
an inline-HTML consent stub as a TASK-952 placeholder so the
auth-code flow runs end-to-end.
What lands:
- internal/server/handlers_oauth.go (744 LoC)
- POST /oauth/register: RFC 7591 DCR. Hand-written, no fosite.
Public clients only (token_endpoint_auth_method=none rejected
for any other value), authorization_code + refresh_token
grants only, code response type only. Validates redirect_uris
(absolute, no fragment, https or loopback-http or custom-
scheme like claude://, blocks file:/javascript:/data:/vbscript:).
- GET /oauth/authorize: starts auth-code flow. fosite validates
request shape (PKCE-S256 required, audience matched, redirect
exact-match). If user has session → renders inline consent
stub. If not → 302 to /login?redirect=<self> (TASK-998's
plumbing in pad-cloud honors the redirect=).
- POST /oauth/authorize/decide: processes consent decision.
Form-bound CSRF token (the existing __Host-pad_csrf cookie,
read from a hidden form field instead of header). Approve →
fosite NewAuthorizeResponse → 303 to client.redirect_uri
with code. Deny → fosite WriteAuthorizeError(access_denied).
- POST /oauth/token: code + refresh exchange. fosite verifies
PKCE verifier (S256-required) + RFC 8707 audience. Returns
{access_token, token_type, expires_in, refresh_token, scope}.
RefreshTokenScopes=[] from sub-PR B means refresh ALWAYS
issues on authorize-code grant.
- Inline consent stub: minimal HTML form with Approve/Deny,
auto-grants every requested scope (TASK-952's UI replaces
with workspace allow-list selection per TASK-953).
- internal/server/handlers_well_known.go: handleOAuthAuthorizationServerStub
→ handleOAuthAuthorizationServer. Returns RFC 8414 metadata
with all six endpoint URLs (revoke + introspect URLs sub-PR D
fills with handlers; the URLs are stable now), advertised
scopes, S256-only code_challenge_methods,
resource_indicators_supported=true, authorization_response_iss_parameter_supported=true.
- internal/server/server.go: Server.oauthServer field +
SetOAuthServer + registerOAuthRoutes called from setupRouter
inside an r.Group with requireCloudMode + SessionAuth (so
/authorize can detect the logged-in user via __Host-pad_session;
SessionAuth falls through gracefully when no cookie).
- cmd/pad/main.go: oauthpkg.NewServer wired in cloud mode using
cfg.EncryptionKey as HMAC secret + cfg.MCPPublicURL+/mcp as
AllowedAudience. Wiring is conditional on PAD_MCP_PUBLIC_URL
being set (the OAuth surface needs a canonical audience to
bind tokens to).
CSRF posture: middleware_csrf.go runs only on /api/* paths so
/oauth/* is naturally exempt. The consent decision endpoint
adds its own form-token check (validateConsentCSRFToken) using
the same __Host-pad_csrf cookie the SPA uses, just with the
token in a hidden form field rather than a header. Same security
model, different transport.
Tests (12, all passing):
- TestOAuth_AuthorizationServerMetadata_PopulatedShape: pins
RFC 8414 metadata fields including S256-only PKCE +
resource_indicators_supported.
- DCR (5): happy path; missing redirect_uris; bad redirect-URI
shapes (relative, non-loopback http, fragment, javascript:);
non-public client auth method rejected; unknown grant type
rejected; not mounted outside cloud mode.
- /authorize (3): redirects to /login when no session;
renders consent stub when logged in; rejects audience
mismatch via fosite's audienceMatchingStrategy.
- /authorize/decide (2): rejects missing csrf_token; deny
produces access_denied redirect.
- Full PKCE flow: end-to-end /authorize/decide (approve) →
/token with code_verifier → 200 with access+refresh tokens.
- /token: rejects missing PKCE verifier.
Replaces the 501 stub assertion in TestMCP_AuthServerStub with
TestMCP_AuthServerMetadata_Mounted (just confirms 200; full
shape lives in the OAuth-handler test).
Out of scope:
- /oauth/revoke + /oauth/introspect (sub-PR D, TASK-1026)
- MCPBearerAuth OAuth introspection branch (sub-PR E, TASK-1027)
- Real consent UI with workspace allow-list (TASK-952)
* fix(oauth): translate RFC 8707 resource= to audience= + omit unmounted endpoints from discovery per Codex review (round 1)
Two findings from PR #372 round 1:
1. P1: Real RFC 8707 clients (Claude Desktop / Cursor / ChatGPT)
send `resource=` not `audience=`. fosite v0.49 reads only
`audience` from the form, so audienceMatchingStrategy was hit
with an empty needle and rejected every real-world authorize /
token request. Tests masked the gap by sending both keys.
Fix: translateResourceToAudience() copies r.Form["resource"]
into r.Form["audience"] before each handler invokes fosite.
Idempotent — if both keys are present, audience wins (test
harness sends both for belt-and-suspenders). Applied at
/authorize, /authorize/decide, and /token entry points.
Test TestOAuth_Authorize_AcceptsResourceOnly sends ONLY
resource= (no audience=) and asserts the request reaches the
consent stub. Without the translation it 303s with
invalid_request.
2. P2: /.well-known/oauth-authorization-server advertised
/oauth/revoke + /oauth/introspect endpoints that don't exist
yet (sub-PR D wires them). Real clients dialing those URLs
would get 404. RFC 8414 §2 lists revocation_endpoint +
introspection_endpoint as OPTIONAL, so omitting until the
handlers ship is spec-compliant + honest.
Fix: drop revocation_endpoint, introspection_endpoint, and
their *_endpoint_auth_methods_supported counterparts from
authServerMetadata. Sub-PR D's PR description includes
"populate these here" as a follow-up.
Test TestOAuth_AuthorizationServerMetadata_OmitsUnimplementedEndpoints
asserts the four fields are absent.
* fix(oauth): rate-limit /oauth/register + drop misleading iss flag per Codex review (round 2)
Two findings from PR #372 round 2:
1. P1: /oauth/register is open by RFC 7591 design (Claude Desktop /
Cursor self-register without prior auth) but had no rate limit.
An attacker could flood the oauth_clients table indefinitely.
Fix: extend RateLimit middleware to gate /oauth/register at
the same 5/hour/IP rate the existing /api/v1/auth/register
uses (RateLimiters.Register, burst 5). Added the OAuth route
group to the s.RateLimit middleware chain so the new path
actually runs through the limiter.
Other /oauth/* endpoints aren't rate-limited here: /authorize
rides session cookies (cheap to abuse but ineffective without
a logged-in user), /token is PKCE-bound to a stored code
(single-use), /authorize/decide is form-bound. Explicit per-
endpoint /oauth/* limits arrive with TASK-959.
Test TestOAuth_Register_RateLimited fires 5 requests
successfully, asserts the 6th returns 429.
2. P2: Discovery doc advertised
authorization_response_iss_parameter_supported=true, but the
/authorize success path delegates to fosite v0.49 which doesn't
add iss=<issuer> to the redirect. RFC 9207-aware clients seeing
the flag would treat the missing parameter as a protocol
violation.
Fix: drop the field from authServerMetadata. RFC 8414 §2
marks it OPTIONAL — omission is spec-compliant. We'll add
the parameter (+ post-processing of fosite's response) in a
future PR if a real client requires it; today's MCP clients
(Claude Desktop, Cursor, ChatGPT) don't.
Test TestOAuth_AuthorizationServerMetadata_OmitsUnimplementedEndpoints
extended to cover the field.
* fix(oauth): gate auth-server discovery doc on oauthServer != nil per Codex review (round 3)
Codex round 3 caught: /.well-known/oauth-authorization-server lives
in the MCP route group (registerMCPRoutes), while the /oauth/{
register,authorize,token} handlers live in the OAuth route group
(registerOAuthRoutes, gated on s.oauthServer != nil). A cloud
deployment with PAD_MCP_PUBLIC_URL unset gets MCP routes mounted
but NOT OAuth — the discovery doc would 200 with /oauth/* URLs
that 404. Worse for clients than no document at all.
Fix: handleOAuthAuthorizationServer now also nil-checks
s.oauthServer; on nil it returns 503 with config_error, matching
the existing fail-loud branch for when the issuer URL isn't
configured. Ops detect the misconfiguration immediately rather
than fielding "OAuth registration is failing with 404" tickets.
Test:
- TestOAuth_AuthorizationServerMetadata_503WhenOAuthDisabled
builds a Server with SetCloudMode + SetMCPTransport (so the
MCP route group mounts) but NOT SetOAuthServer; asserts the
endpoint returns 503 with config_error.
- TestMCP_AuthServerMetadata_Mounted renamed →
TestMCP_AuthServerMetadata_MountedAndGated to reflect the new
behavior under mcpEnabledTestServer (which doesn't wire OAuth).
The full 200 happy path lives in
TestOAuth_AuthorizationServerMetadata_PopulatedShape (uses
oauthEnabledTestServer).
* fix(oauth): apply gofmt to handlers_oauth_test + handlers_well_known
* fix(oauth): bump go-jose/v3 to v3.0.4 to resolve GO-2025-3485
CI govulncheck rejected the build: fosite v0.49.0 transitively
pulls github.com/go-jose/go-jose/v3@v3.0.3 which has
GO-2025-3485 (DoS in JWS parsing). Affected call site:
internal/server/handlers_oauth.go:408 — handleOAuthAuthorize calls
fosite.NewAuthorizeRequest which eventually calls jose.ParseSigned.
Fix: bump go-jose/v3 to v3.0.4 (the fixed version per the advisory).
go mod tidy auto-bumped dependent indirect deps too.
Verified locally:
govulncheck ./... → "No vulnerabilities found"
go test ./... → all green
go build ./... → clean
|
||
|
|
f6eeee4f81 |
feat(oauth): fosite-backed authorization-server constructor (TASK-1024, sub-PR B of TASK-951) (#371)
* feat(oauth): fosite-backed authorization-server constructor (TASK-1024, sub-PR B of TASK-951)
Second of 5 sub-PRs landing the OAuth 2.1 authorization server in
PLAN-943. Wires fosite v0.49.0 over the storage layer from sub-PR A.
No HTTP routes yet — sub-PR C mounts /authorize, /token, /register;
sub-PR D mounts /revoke and /introspect.
What lands:
- internal/oauth/session.go — pad's *Session embedding fosite.DefaultSession
with typed UserID() accessor + Clone override returning *Session
(so handler-side type-assertions don't lose the concrete type
during refresh-token rotation).
- internal/oauth/storage.go — Storage adapter satisfying:
fosite.ClientManager
handler/oauth2.AuthorizeCodeStorage
handler/oauth2.AccessTokenStorage
handler/oauth2.RefreshTokenStorage
handler/oauth2.TokenRevocationStorage
handler/pkce.PKCERequestStorage
Compile-time guards in server.go assert each interface remains
satisfied. Translation: fosite.Requester ⇄ models.OAuthRequest
via JSON-encoded session_data + URL-encoded form. Sentinel errors
from sub-PR A map to fosite.ErrNotFound /
ErrInvalidatedAuthorizeCode / ErrInactiveToken.
- internal/oauth/audience.go — RFC 8707 custom AudienceMatchingStrategy.
fosite has no native RFC 8707; we close over a canonical audience
and reject any request that doesn't carry exactly that resource.
Belt-and-suspenders haystack check defends against fixtures /
migrations that register a client without setting Audience.
Plus ValidateAudienceParam (HTTP-handler entry helper) and
audienceForNewClient (DCR seed for sub-PR C).
- internal/oauth/server.go — NewServer(Config) → *Server returning
fosite.OAuth2Provider configured for:
- PKCE-S256 required (EnforcePKCE + EnablePKCEPlainChallengeMethod=false)
- Opaque HMAC tokens (compose.NewOAuth2HMACStrategy)
- Refresh rotation with grant-family revocation (sub-PR A round-2 fix)
- Audience binding via the custom strategy
Sensible default lifespans (1h access, 30d refresh, 15m authcode);
overridable via Config. Excluded by design: client-credentials,
implicit, ROPC (deprecated in OAuth 2.1), OpenID factories
(we're not OIDC), PAR (not needed for v1).
- go.mod — github.com/ory/fosite pinned at v0.49.0 (direct dep).
Tests (20):
- NewServer required-field validation (3) + default-lifespan path
- audienceMatchingStrategy: empty needle, mismatch, client-without-canonical,
canonical-only happy path, multi-audience rejection, no-canonical=ServerError
- ValidateAudienceParam (5 sub-cases) + audienceForNewClient
- Session: clone returns concrete *Session (not *DefaultSession);
nil-safe accessors
- Storage adapter: auth-code round-trip + invalidated-code error,
GetClient not-found mapping, access-token-inactive mapping,
rotation-revokes-entire-grant (end-to-end), PKCE round-trip,
requester-to-OAuthRequest session encoding + missing-client guard
Out of scope (subsequent sub-PRs):
- HTTP route handlers + DCR endpoint (sub-PR C / TASK-1025)
- /revoke + /introspect endpoints (sub-PR D / TASK-1026)
- MCPBearerAuth OAuth introspection branch (sub-PR E / TASK-1027)
* fix(oauth): inject canonical audience into hydrated clients per Codex review (round 1)
Codex round 1 caught a P1 in the storage adapter: modelClientToFosite
returned fosite.DefaultClient.Audience=nil for every persisted
client, but audienceMatchingStrategy's haystack-side check requires
client.GetAudience() to contain the canonical audience. Net result:
every authorize / token / refresh flow would fail with invalid_request
once the strategy ran, regardless of how the client was registered.
Fix: thread the canonical audience through Storage. NewStorage now
takes a canonicalAudience string; modelClientToFosite (now a method
on Storage) injects [canonicalAudience] into the hydrated client's
Audience field. The audience isn't persisted as a column —
single-resource AS for v1 (PLAN-943) means every client implicitly
allows the same audience, so storing what we'd always set to the
same value is pure write amplification.
Threading:
cfg.AllowedAudience → NewServer → NewStorage(store, audience)
└─ Storage.canonicalAudience
└─ modelClientToFosite injects
Misconfigured Storage (empty canonicalAudience — caught earlier by
NewServer's required-field check, but tests pin the fail-loud branch
in case Storage is ever constructed directly): produces clients
with Audience=nil so audienceMatchingStrategy rejects every request
with ServerError, surfacing the misconfiguration fast rather than
silently issuing wide-open tokens.
Tests:
- TestStorage_GetClient_InjectsCanonicalAudience — pins the
injection contract; without the fix this fails.
- TestStorage_NewStorage_EmptyCanonicalLeavesAudienceNil — pins the
fail-loud branch for misconfigured Storage.
- 6 existing tests updated to pass canonical audience to NewStorage
(mechanical sed update; behaviour unchanged).
* fix(oauth): hydrate request payload on inactive token Get*Session per Codex review (round 2)
Codex round 2 caught a HIGH-severity gap: GetRefreshTokenSession
returned (nil, fosite.ErrInactiveToken) for revoked rows, but
fosite's handleRefreshTokenReuse (flow_refresh.go:178-204) derefs
req.GetID() to drive the family revocation that's the OAuth 2.1
BCP §4.14 replay-detection rule. Returning nil nil-derefs that
flow and defeats replay detection — the very thing rotation exists
to enable.
Fix: hydrate the stored row even on the inactive path and return
(req, fosite.ErrInactiveToken). Mirrors the pattern already used
by GetAuthorizeCodeSession's invalidated-code branch. If
hydration itself fails (client deleted between issuance and use),
return the underlying error rather than masking it — replay
detection loses but the failure is observable.
Symmetric fix applied to GetAccessTokenSession even though
no fosite caller currently derefs on inactive there. Defense in
depth + uniform contract makes the adapter resilient to future
fosite changes (e.g. an introspector that wants req.GetID() for
audit-log enrichment).
Tests:
- TestStorage_GetRefreshTokenSession_InactiveReturnsPayload —
pins the refresh-side contract; without the fix this fails
on the nil-check.
- TestStorage_GetAccessTokenSession_InactiveReturnsPayload —
same pattern for access tokens.
* fix(oauth): set RefreshTokenScopes=[] so authorize-code grants issue refresh per Codex review (round 3)
Codex round 3 caught a P1: fosite defaults
Config.RefreshTokenScopes to ["offline", "offline_access"]. fosite
only mints refresh tokens when one of the listed scopes is granted.
PLAN-943's scope vocabulary is pad:read / pad:write / pad:admin —
no "offline" scope — so the default silently disabled refresh
issuance for every Pad grant, defeating the entire refresh-rotation +
family-revocation machinery this PR adds.
Fix: explicitly set RefreshTokenScopes: []string{} in NewServer's
fosite.Config. fosite reads the empty slice as "issue refresh on
every authorize-code grant whose client allows the refresh_token
grant type, no scope predicate" — matches fosite's own tests
(flow_authorize_code_token_test.go:129).
Pin: TestNewServer_RefreshTokenScopesIsEmpty documents the decision
+ smoke-checks that the constructor still returns a usable provider.
The actual "refresh issued on authorize-code grant" assertion lands
in sub-PR C's /token endpoint test — that's where fosite's
flow_authorize_code_token.go reads the field.
* fix(oauth): bump go.opentelemetry.io/otel{,/sdk} to v1.40.0 to resolve GO-2026-4394
CI govulncheck job rejected the build: fosite v0.49.0 transitively
pulls in go.opentelemetry.io/otel/sdk@v1.21.0 which has known
vulnerability GO-2026-4394 (Arbitrary Code Execution via PATH
Hijacking in go.opentelemetry.io/otel/sdk). Affected
init-time call sites:
internal/oauth/audience.go:8 → fosite.init → otel resource.init
internal/server/middleware_ratelimit.go:80 → sync.Once.Do → resource.Default
internal/cli/client.go:415,794,798 → otelhttp.* → trace.*
Fix: bump otel core + sdk + metric + trace to v1.40.0 (the fixed
version per GO-2026-4394's advisory). go mod tidy also pulled in
go.opentelemetry.io/auto/sdk@v1.2.1 as a new transitive.
Verified locally:
govulncheck ./... → "No vulnerabilities found"
go test ./... → all green
go build ./... → clean
|
||
|
|
2a00775481 |
feat(oauth): schema + storage layer (TASK-1023, sub-PR A of TASK-951) (#370)
* feat(oauth): schema + storage layer for OAuth 2.1 server (TASK-1023, sub-PR A of TASK-951)
First of 5 sub-PRs landing the OAuth 2.1 authorization server in
PLAN-943. This one is foundation only — no HTTP exposure, no fosite
import, no public surface change.
Schema (5 tables, parallel SQLite + Postgres migrations):
- oauth_clients — RFC 7591 Dynamic Client Registration; public clients only for v1
- oauth_authorization_codes — short-lived codes for the auth-code grant
- oauth_access_tokens — opaque HMAC; subject denormalized for fast user-bound queries
- oauth_refresh_tokens — same shape; access_token_signature link + request_id chain
- oauth_pkce_requests — PKCE session keyed by auth-code signature
Storage layer (internal/store/oauth.go):
- 12 public methods covering fosite's ClientManager + CoreStorage +
PKCERequestStorage + TokenRevocationStorage interface shapes,
using pad-internal types so the package stays fosite-free.
- Three sentinel errors (ErrOAuthNotFound, ErrOAuthInvalidatedCode,
ErrOAuthInactiveToken) that sub-PR B's adapter maps to the
matching fosite errors.
- request_id IS the chain identifier (fosite preserves it across
rotations — handler/oauth2/flow_refresh.go:86), so family
revocation is a single indexed UPDATE rather than a separate
chain_id column.
14 tests covering: client CRUD + idempotent delete + empty-slice
normalization, auth-code create/get/invalidate (including the
"return payload alongside ErrInvalidatedCode" contract fosite
relies on for family revocation), access-token CRUD + delete,
refresh CRUD + RotateRefreshToken (single-row flip), refresh-token
family revocation (entire chain via request_id, leaves other
chains untouched), access-token family revocation, PKCE CRUD, and
required-field validation.
Both backends share test bodies via testStore(t); set
PAD_TEST_POSTGRES_URL=... to run the same suite against Postgres.
Out of scope for this PR (subsequent sub-PRs):
- fosite import + adapter (sub-PR B / TASK-1024)
- DCR + authorize + token endpoints (sub-PR C / TASK-1025)
- revoke + introspect endpoints (sub-PR D / TASK-1026)
- MCPBearerAuth OAuth integration (sub-PR E / TASK-1027)
* fix(oauth): always insert active=true; drop broken zero-value Active override per Codex review (round 1)
Codex round 1 caught a P1 in insertOAuthRequestRow:
active := defaultActive
if req.Active != defaultActive {
active = req.Active // <- zero-value collides
}
When defaultActive=true and req.Active=false (the zero value), this
branch fires and the row is stored with active=FALSE — silently
producing immediately-revoked tokens. Any sub-PR B adapter that
built an OAuthRequest without explicitly setting Active=true would
ship broken.
Fix: hardcode active=TRUE on insert. Drop the defaultActive
parameter (it's always true for the three flagged tables; PKCE
has no active column). Pre-seeding inactive isn't a supported flow
— fosite never does it, and tests that need a revoked row do
Create + Invalidate / Rotate / RevokeFamily as a two-step.
Regression test TestOAuth_Insert_AlwaysActive constructs an
OAuthRequest with zero-value Active and asserts the row is
readable as active for all three table types (codes, access,
refresh). Without the fix the test fails on the first GetAccessToken
call with ErrOAuthInactiveToken.
* fix(oauth): RotateRefreshToken revokes both refresh + access families per Codex review (round 2)
Codex round 2 caught: my RotateRefreshToken only marked the named
refresh row inactive, but fosite's reference MemoryStore.RotateRefreshToken
(storage/memory.go:497-504) revokes BOTH the refresh family AND the
access family for the grant's request_id. Without this, every access
token issued before a refresh remained active until TTL — defeating
the rotation's invalidation contract.
Fix: RotateRefreshToken now delegates to RevokeRefreshTokenFamily +
RevokeAccessTokenFamily (both already existed). The signatureToRotate
parameter becomes vestigial — fosite passes it but the family revoke
catches every chain member regardless of which row triggered the
rotation. The new pair fosite immediately issues via
CreateAccessTokenSession + CreateRefreshTokenSession inherits the
same request_id (flow_refresh.go:86) and lands active=TRUE per the
round-1 hardcode, so the net post-rotation state is "all old rows
in this grant inactive, the new pair active."
Test rewrite: TestOAuth_RotateRefreshToken_FlipsActiveOnSingleRow
asserted the OPPOSITE behavior (only one row touched) — that was
the original bug. Replaced with TestOAuth_RotateRefreshToken_RevokesEntireGrant
which seeds a refresh + access pair in the same chain, plus a
distinct unrelated grant, then asserts after rotation:
- old refresh + old access both inactive
- unrelated grant untouched (request_id-scoped)
* fix(oauth): DeleteOAuthClient cascades dependent rows in a tx per Codex review (round 3)
Round 3 finding: DeleteOAuthClient errored with FK constraint
violation for any client that had ever issued a grant. The
migrations declare client_id FKs without ON DELETE CASCADE — by
design, so a stray DELETE FROM oauth_clients elsewhere fails
loudly rather than silently nuking grants — but that meant the
"officially supported" delete path was unusable.
Fix: DeleteOAuthClient now runs five sequential DELETEs inside a
single transaction:
1. oauth_pkce_requests
2. oauth_refresh_tokens
3. oauth_access_tokens
4. oauth_authorization_codes
5. oauth_clients
Order matters (children before parent) because the FKs aren't
cascading. The tx makes it atomic — if any step fails, nothing's
deleted, so we never leave a half-deleted client. Idempotent
because every WHERE matches nothing on a non-existent client.
Test TestOAuth_DeleteOAuthClient_CascadesDependentRows seeds a row
in each of the four dependent tables, deletes the client, and
asserts ErrOAuthNotFound on every dependent row + the client itself.
Without the fix this fails on the first DELETE FROM oauth_clients
with an FK constraint violation.
* fix(oauth): SELECT FOR UPDATE row lock in DeleteOAuthClient on Postgres per Codex review (round 4)
Codex round 4 caught a Postgres race in DeleteOAuthClient: the
five-DELETE cascade is atomic, but between the child-row deletes
and the parent delete, a concurrent fosite handler can insert a
fresh grant/token referencing the same client_id. The parent
DELETE then fails with an FK violation and the whole tx rolls
back — the cascade is correct, but unreliable under concurrent
OAuth issuance.
Fix: take SELECT id FROM oauth_clients WHERE id = ? FOR UPDATE
as the very first statement in the tx (Postgres only). The
exclusive row-level lock blocks any concurrent statement that
tries to read the client row — which fosite does on FK resolution
during grant/token inserts — until our tx commits.
Skipped on SQLite because:
(a) BEGIN IMMEDIATE serializes the entire write workload globally
(DSN configures _txlock=immediate per store.go), so the race
doesn't exist.
(b) FOR UPDATE syntax isn't reliably accepted across SQLite
drivers.
ErrNoRows on the lock query is treated as "client doesn't exist
yet" — the subsequent DELETEs match nothing and the call remains
idempotent. Tests still pass on the SQLite path; the Postgres
path's race fix will be exercised by CI's PAD_TEST_POSTGRES_URL
runs and any future concurrency test we add.
|
||
|
|
521853e0a1 |
feat(mcp): mount /mcp Streamable HTTP transport + OAuth discovery (TASK-950) (#369)
* feat(mcp): mount /mcp Streamable HTTP transport + OAuth discovery (TASK-950) First public cut of pad-cloud as a remote MCP server (PLAN-943). Mounts the Streamable HTTP transport on /mcp, the RFC 9728 protected-resource discovery doc on /.well-known/oauth-protected-resource, and a 501 stub for RFC 8414 auth-server metadata that TASK-951 will fill in. - internal/server/handlers_mcp.go — Server.SetMCPTransport + chi route registration under cloud-mode gate (self-host stays free of MCP overhead unless explicitly opted in). - internal/server/middleware_mcp_auth.go — Bearer auth that produces the spec-shape 401 + WWW-Authenticate (resource_metadata pointer) MCP clients expect, distinct from /api/v1's JSON-only 401 envelope. Reuses the existing PAT (api_tokens) validation path; OAuth-issued tokens layer in via this same middleware in TASK-951. - internal/server/handlers_well_known.go — RFC 9728 discovery doc + RFC 8414 stub. URLs come from PAD_MCP_PUBLIC_URL + PAD_AUTH_SERVER_URL with request-host fallback for local dev. - internal/server/handlers_mcp_test.go — 7 tests covering cloud-off routes-absent, cloud-on-no-transport routes-absent, discovery doc shape, 501 stub, no-token 401+WWW-Authenticate, bad-format-token 401+WWW-Authenticate, and the valid-PAT happy path with user attached to transport context. - cmd/pad/main.go — wires mcpserver.NewServer + HTTPHandlerDispatcher + StreamableHTTPServer in cloud mode, after SetCloudMode. - internal/config — adds PAD_MCP_PUBLIC_URL and PAD_AUTH_SERVER_URL. Resources are intentionally skipped in this v1 — they require an HTTPResourceFetcher equivalent of ExecResourceFetcher and that's a follow-up task. Tools, prompts, instructions, and meta all flow through identically to the stdio surface (verified via spike against mcp-go v0.50.0's StreamableHTTPServer before writing the real PR). * fix(mcp): enforce PAT scopes on /mcp + WWW-Authenticate fallback per Codex review (round 1) Two findings from PR #369 round 1: 1. SECURITY: A PAT with scopes ["read"] could drive write MCP tools. MCPBearerAuth skipped tokenScopeAllows entirely; the dispatcher's synthesized in-process request bypassed TokenAuth's chain-level check (because WithCurrentUser was already set), so a read-scoped token could POST item create / PATCH update / DELETE silently. Fix: stash apiToken.Scopes via server.WithTokenScopes in MCPBearerAuth; re-check per synthesized request in HTTPHandlerDispatcher.executeRequest using the public server.TokenScopeAllows wrapper. Read-scoped tokens can still drive read-only tools (their HTTP method is GET) — only writes are rejected, with a structured permission_denied envelope. 2. DISCOVERY: writeMCPUnauthorized dropped the WWW-Authenticate header when PAD_MCP_PUBLIC_URL was unset. Cloud-mode deploys without that env var mounted /mcp but broke the discovery handshake — fresh MCP clients rely on the header to find /.well-known/oauth-protected- resource. Fix: pass *http.Request through to writeMCPUnauthorized, derive "https://" + r.Host as the fallback (matches handleOAuthProtected- Resource's existing fallback). Tests: - handlers_mcp_test.go: TestMCP_NoToken_FallsBackToHostWhenPublicURLUnset pins the WWW-Authenticate fallback. TestMCP_ReadScopedPAT_StashesScopes- InContext + TestTokenScopeAllows_PublicWrapper pin the scope-stash side. - dispatch_http_test.go: TestHTTPHandlerDispatcher_ScopeEnforcement_* pin the dispatcher-side enforcement (read-on-write rejected, read-on-read allowed, no-scope-context allows-all). - recordingHandler updated to handle nil r.Body so the read-only GET path can be exercised. * fix(mcp): move scope check to buildAuthedRequest so bulk-update can't bypass it per Codex review (round 2) Round 1 enforced scopes in executeRequest, but dispatch_http_project.go's item bulk-update path constructs each per-item PATCH directly via buildAuthedRequest + d.Handler.ServeHTTP, skipping executeRequest. Net result: a PAT with ["read"] scope could still mutate items through bulk-update even after the round-1 fix. Move the scope check from executeRequest into buildAuthedRequest so every synthesized request — main writes, RMW prefetches, bulk-update per-item PATCHes, link-create POSTs, attachment HEADs — passes through the same gate uniformly. The check is dropped from executeRequest to avoid double-checking; buildAuthedRequest is the universal funnel everything calls. Reads (GET/HEAD/OPTIONS) under ["read"] scope still pass — bulk- update's per-item GET prefetch succeeds, the subsequent PATCH fails at request-build time with permission_denied. The bulk operation returns successfully with all-errors recorded per ref (the "no abort on per-item failure" contract is unchanged). Test: TestHTTPHandlerDispatcher_ScopeEnforcement_BulkUpdateBlockedOnReadScope spies on the test handler; asserts the PATCH never reaches it under ["read"] scope and that each per-item entry carries permission_denied. |
||
|
|
48bbe7453b |
fix(dashboard): suggested_next surfaces in-progress + filters blocked items (BUG-990) (#366)
Pre-fix algorithm only considered status == "open" child items in active plans. Two consequences agents flagged in BUG-987 / BUG-990: 1. In-progress items never appeared. The most likely "what should I work on next" answer is "the work the user is already on" — pre-fix the engine returned [] when nothing was open AND a task was actively in-progress. 2. Blocked items appeared. Suggesting work that has unresolved blockers wastes the user's time when they go to start it. Algorithm changes (internal/server/handlers_dashboard.go): - Include both `open` and active-status (in-progress / fixing / exploring / etc., via existing isActiveStatus helper) child items. - Filter out items with at least one active "blocks" link from a non-done blocker. Mirrors the attention-section logic, factored into a new itemBlockedByActive helper. - Sort: in-progress first (always wins over open, regardless of priority), then by priority rank within each bucket. Pinned in test expectations. - Reason text distinguishes "In-progress task..." vs "Open task..." so agents see why an item was suggested. Tests: - TestDashboardSuggestedNext expectations updated for new ordering (in-progress wins over open). Test now covers the in-progress surfacing path the bug specifically wanted. - New TestDashboardSuggestedNext_FiltersBlockedItems exercises the blocker-filter — explicitly creates a blocks link and confirms the blocked task is suppressed even at critical priority. Out of scope: high-priority orphan suggestions (items not in any active plan). The bug item flagged this as a stretch goal; this PR sticks to the active-plan-children scope of the existing algorithm. Adding orphans would need a sort/relevance model since "everything high priority" can be a long list. Parent: BUG-990. |
||
|
|
9657051e43 |
fix(mcp): dedup implementation_notes / decision_log from fields blob (BUG-992) (#365)
Item responses carry implementation_notes and decision_log in TWO places: 1. Top-level arrays on the item (item.ImplementationNotes, item.DecisionLog) — populated by hydrateItemComputedMetadata. 2. Inside the stringified `fields` blob — written there by AppendImplementationNote / AppendDecisionLogEntry at write time. This duplicate forces agents to dedup or pick a source; the bug report's recommendation was to keep the top-level arrays as the canonical shape and drop the embed. Path-consistent with BUG-991 path A (also MCP-only normalization): extend the boundary normalizer in packageJSONResult so that AFTER fields is parsed (BUG-991), implementation_notes and decision_log keys are dropped from the parsed fields object. The top-level arrays continue to surface unchanged. Server / web / CLI keep their existing behavior — fields still carries the embed at rest, hydration still extracts to top-level. The boundary fix is the cheap clean-up; a write-side migration (stop persisting into the fields blob, plus a one-shot data migration to clean existing items) is the architecturally proper fix and remains tracked in BUG-992's notes. Tests: - internal/mcp/bug992_test.go: stripDuplicatedFieldsKeys helper (strips both keys, no-op when absent, defensive on non-object inputs) + end-to-end packageJSONResult cases for single-item and array-style responses. Parent: BUG-992. |
||
|
|
708897dd0c |
fix(mcp): parse fields/tags at MCP boundary so agents see native shapes (BUG-991 path A) (#364)
Item responses carry `fields` and `tags` as JSON-stringified strings
because the underlying SQLite columns store them that way. For agents
going through MCP this means a double-encode every read — they have
to JSON.parse the field's string value before doing anything useful.
Path A (this PR): normalize at the MCP boundary. Recursively walk
parsed JSON in packageJSONResult, find string-typed `fields` and
`tags` properties, parse the embedded JSON, substitute the native
shape. Server / web / CLI keep their existing stringified contract;
agents see clean JSON.
The walk handles every common item shape:
- Single-item responses (top-level item)
- Item arrays (item list, dashboard.active_items, comment lists)
- Nested items (dashboard.recent_activity[].item, parent_*)
Conservative parse: only strings starting with `{` or `[` and
successfully parseable as JSON get substituted. Hand-written values
that happen to share a key name (e.g. a `fields` description text)
pass through untouched. Malformed JSON also passes through as the
original string rather than dropping the value.
Text fallback (content[0].text on the MCP result) preserves the
original CLI body verbatim. Older clients that read the text content
keep seeing the same shape — the structured wire is the agent
upgrade path; text is the back-compat path. Same pattern as BUG-985's
{items: [...]} array wrap.
Path B (full migration of models.Item.Fields from string to
map[string]any across server/store/web/CLI) is tracked in BUG-991's
notes — bigger surgery, deferred.
Tests:
- internal/mcp/bug991_test.go: single-item, item-array, nested-item,
primitives-pass-through, malformed-stays-string, plus end-to-end
packageJSONResult cases proving structured + text branches both
work.
- Existing dispatch_http_advanced_test.go and dispatch_http_project_test.go
updated: tests that previously did `json.Unmarshal([]byte(fieldsStr))`
now use a small itemFieldsAsMap helper that reads the parsed map
directly. Cleaner, and a clear error message if normalization
regresses.
Parent: BUG-991.
|
||
|
|
55d3a078a8 |
fix(mcp): standup CLI ref + classifier polish for BUG-987 round 2 (#362)
Round-2 hotfix on top of PR #361 (which shipped to v0.1.0-rc.4). Claude Desktop's re-review of rc.4 surfaced two fixes that didn't fully land: - Bug 8 (round 1 went to wrong layer). My HTTPHandlerDispatcher fix populated ref on standup blockers, but Claude Desktop's path is ExecDispatcher → CLI subprocess → standupCmd, which has its own JSON composition struct. That struct's Attention + SuggestedNext anonymous types didn't even define ItemRef as a parseable field. Now both define `item_ref` and the JSON-emit loops set Ref from it. Verified live: blockers now carry refs (TASK-X), not empty strings. - Bug 11 part 2. Round 1 stripped the cobra Usage block but two artifacts still leaked: 1. The "pad <verb> failed: <stderr>" prefix on server_error fallback messages. The verb name is the OLD CLI verb (e.g. `pad item block`) which doesn't match the v0.2 catalog actions agents see, and the cmdPath is already implicit from the invoked tool. Drop the prefix; emit the cleaned stderr directly. 2. Self-link / "cannot ..." validation rejections classified as server_error instead of validation_failed. Extended the validation regex with `cannot ` so server-side rejections like "cannot link an item to itself" / "cannot modify archived item" route to ErrValidationFailed. Verified live with a self-link attempt — now returns code=validation_failed, hint="cannot link an item to itself", no prefix. - New stripErrorPrefix helper trims leading `Error:` / `error:` / `ERROR:` from every classified hint+message so the envelope text isn't redundant with the envelope's `code` signal. Bug 13 / Bug 14: my round-1 fixes verified working locally on rc.4 (tested with a fresh Task → convention=None; dashboard by_role shows "Unassigned"/"unassigned" for the bucket). The reviewer's stale results almost certainly reflect a pad server process that wasn't restarted with the rc.4 binary swap. Tests: - TestClassifyExecError_CannotPhrasingClassifiesAsValidation — three "cannot ..." stderr cases must classify validation_failed. - TestClassifyExecError_NoLegacyVerbPrefixInMessage — pins the prefix-strip behaviour on the server_error fallback path. - TestStripErrorPrefix — trim-rule round-trip across casing variations and empty input. Parent: BUG-987. |
||
|
|
0f05012169 |
fix(mcp): six surgical fixes for BUG-987 (bugs 6, 8, 11, 12, 13, 14) (#361)
* fix(mcp): six surgical fixes for BUG-987 (bugs 6, 8, 11, 12, 13, 14)
Hotfix follow-up to v0.1.0-rc.3's Claude Desktop dogfood. Six
surgical fixes; bigger items (5, 7, 9, 10) deferred to separate
tasks.
- Bug 6: `pad project next --format json` was emitting the entire
dashboard, indistinguishable from `pad project dashboard --format
json`. Now slices to suggested_next only. cmd/pad/main.go.
- Bug 8: standup blockers carried empty `ref` strings, blocking
agent linkback to the actually-blocked items. dashboard's
attention[].item_ref is canonical; the standup composer in
internal/mcp/dispatch_http_slice4.go just wasn't propagating it.
Same fix applied to suggested_next entries.
- Bug 11: cobra's auto-emitted "Usage: pad item block ..." help
block leaked into MCP error envelopes via classifyExecError. The
Usage text references OLD CLI verb names (pre-v0.2 catalog) that
agents using the new surface have no business seeing, and bloats
every error response. New stripCobraUsageBlock helper truncates
stderr at the first line-anchored "Usage:" marker before
classification + envelope construction.
- Bug 12: BuildCLIArgs validation errors (missing required arg, type
mismatch) came out of env.Dispatch as bare-text NewToolResultErrorf
results, breaking the structured envelope contract. New helper
validationFailedFromBuildErr wraps them as ErrValidationFailed
envelopes with the field name extracted via regex from the
underlying message.
- Bug 13: every Task / Idea / Plan with a `priority` field got a
phantom `convention: { enforcement: "<priority>" }` surfaced on
its response, because ExtractItemConventionMetadata's legacy
fallback treated `priority` as the Convention enforcement tier
unconditionally. Restructured to track hasConventionShape
separately from hasMetadata — only Convention-specific markers
(structured convention field, trigger, scope, surfaces, commands,
direct enforcement) flip the shape flag. category alone is
insufficient (Ideas / Bugs / Roadmap items legitimately use it).
Final guard returns nil when only category was matched.
- Bug 14: GetRoleBreakdown's unassigned row was emitted with empty
role_name + role_slug, presenting as a "phantom" entry in the
dashboard. Now explicitly labelled "Unassigned" / "unassigned"
while keeping role_id null so it's still distinguishable from a
real role.
Tests:
- internal/mcp/bug987_test.go (new) — stripCobraUsageBlock + classify
+ validation envelope wrapping + env.Dispatch integration.
- internal/models/item_test.go — three cases covering non-Convention
items (Task, Idea, Plan with priority) returning nil metadata, and
one preservation test for legacy Conventions with priority field.
- internal/store/agent_roles_test.go (new) — confirms unassigned row
carries explicit "Unassigned" / "unassigned" labels.
Live verified: pad_project action=next returns just the suggestions
array; pad_item action=create with no fields returns validation_failed
with field=collection; pad_item action=link with self-target returns
without Usage-block leakage.
Deferred to separate items (per BUG-987 triage):
- Bug 5: text vs JSON returns across note, decide, star, unstar,
delete, bulk-update — needs CLI-side handler updates per command.
- Bug 7: suggested_next algorithm — needs to consider in-progress
items, not just open ones; behavior change needs design.
- Bug 9: fields/tags double-stringified — potentially breaking for
web UI/CLI consumers.
- Bug 10: decision_log/notes embedded in fields blob duplicating
top-level arrays — might require data migration.
Parent: BUG-987.
* fix(mcp): HTTP transport equivalence + ordering for BUG-987 per Codex review (round 1)
Two findings from Codex review of PR #361:
1. project.next on HTTP transport still returned the full dashboard.
The route table mapped "project next" directly to /dashboard, so
the CLI fix (slice to suggested_next) didn't reach OAuth-authed
agents going through HTTPHandlerDispatcher. Catalog actions must
produce equivalent shapes on stdio and HTTP — that's the contract
that lets agents be transport-agnostic.
Fix: new dispatchProjectNext method on HTTPHandlerDispatcher that
fetches the dashboard via the existing fetchDashboardJSON helper,
slices to suggested_next[], re-encodes, and runs through
packageJSONResult so it gets the same {items: [...]} wrap as
other list responses.
Also retires the broken route-table entry — replaced with a
comment pointing at the new method so future contributors don't
re-add a passthrough.
Test: TestDispatch_ProjectNext_SlicesToSuggestedNext + the empty-
array case. Asserts dashboard-only top-level fields (summary,
active_items) don't leak into the response — that's the whole
point of project.next being distinct from project.dashboard.
2. ExtractItemConventionMetadata's priority→enforcement legacy
fallback ran BEFORE surfaces/scope/commands had a chance to flip
hasConventionShape, so a Convention with only `{scope, priority}`
would silently drop enforcement.
Fix: move the priority fallback to AFTER all marker checks. Direct
`enforcement` still resolves first; the legacy priority fallback
runs at the bottom once shape detection is complete.
Tests: two new cases covering scope-only and commands-only legacy
Conventions — both must resolve enforcement via the priority
fallback.
Parent: BUG-987.
|
||
|
|
4cf0c297e4 |
fix(mcp): explicit workspace param + wrap list responses (BUG-985) (#360)
Two regressions surfaced by Claude Desktop dogfooding v0.1.0-rc.2:
1. Explicit `workspace` parameter silently dropped (bug 1).
`--workspace` is registered as a persistent ROOT flag in cobra
(rootCmd.PersistentFlags), so cmdhelp doesn't include it in any
leaf command's per-command Flags map. BuildCLIArgs's per-flag
iteration only emits flags from cmdInfo.Flags — meaning the
explicit `input["workspace"]` value was never read or emitted.
The post-loop session-fallback fired in some cases, masking the
problem when a session default was set, but agents passing
`workspace=docapp` explicitly got `no_workspace` errors despite
the catalog schema documenting the resolution order as
"explicit > session > .pad.toml".
Fix: after the per-flag loop, check input["workspace"] directly.
Prefer explicit, fall back to sessionWorkspace, otherwise omit
(CLI handles CWD .pad.toml). The old session-only branch
collapses into this combined check.
2. List responses produced top-level array structuredContent, which
MCP host validators (Claude Desktop) reject with "expected:
record" (bug 3). Affected pad_collection list, pad_workspace
list, pad_role list, item deps, item starred, webhook list,
etc. — anywhere the CLI emits a JSON array.
Fix: extract a shared `packageJSONResult` helper that wraps
top-level arrays in `{items: [...]}` for structuredContent. The
text fallback preserves the ORIGINAL JSON so clients reading
text content keep seeing the raw array shape they used to.
ExecDispatcher.Dispatch and packageHTTPResponse both go through
the helper now, so stdio + HTTP transports produce identical
wire shapes.
Bug 2 (claim that only pad_project honors the session default) didn't
reproduce in isolation locally — the symptom was bug 1 manifesting
inconsistently under concurrent JSON-RPC, with pad_project's empty
flag list dodging the loop entirely while flag-bearing commands hit
the dropped-explicit code path. The bug 1 fix resolves both.
Bug 4 (tool_surface_stable: true vs reality) self-resolves once 1-3
land — the surface is now stable as documented.
Tests:
- internal/mcp/bug985_test.go (new) — 10 subtests covering
BuildCLIArgs explicit/session/no-flag-in-cmdinfo cases plus the
packageJSONResult wrap behavior across object/array/non-JSON/
empty-array/malformed/marshal-round-trip scenarios.
- Existing integration tests that asserted the old top-level-array
shape on item.deps / item.starred / item.list / workspace.list /
webhook.list now go through an unwrapItems(t, sc) helper so the
refactor lands in one consistent place.
Live verified: from /tmp (no .pad.toml), all four bug-report
operations now succeed with explicit `workspace=docapp` and return
`dict` structuredContent.
Parent: BUG-985.
|
||
|
|
a928222ace |
feat(mcp): add pad://workspaces top-level resource (TASK-974) (#358)
Promotes the workspace catalog to a static MCP resource so hosts can
prefetch it once at session start instead of forcing a pad_workspace.list
tool call per turn.
URI: pad://workspaces
Shape: JSON array of {slug, name, updated_at, default} entries —
exactly the shape `pad workspace list --format json` emits, so the
resource handler and classifyExecError's available_workspaces side
channel consume the same source of truth.
Implementation:
- internal/mcp/resources.go: add WorkspacesURI constant + readWorkspaces
handler. Registered via AddResource (not AddResourceTemplate) since
the URI is parameter-free and lives in resources/list.
Tests:
- TestReadWorkspaces_DispatchesWorkspaceListJSON: end-to-end resource/read
round-trip through HandleMessage; asserts the fetcher saw the right
CLI args.
- TestReadWorkspaces_RejectsWrongURI: defensive guard against URI/handler
binding drift in future refactors.
- TestReadWorkspaces_PropagatesFetcherError: fetcher errors surface
cleanly to the MCP client instead of being swallowed.
Out of scope (per task description):
- Live updates / resources/subscribe — future enhancement.
- Per-collection resource (pad://workspace/{ws}/collections/{slug}) —
flat list stays for now.
Parent: TASK-974 → PLAN-969.
|
||
|
|
1e94fcbd9d |
feat(mcp): structured MCP error envelopes with closed taxonomy (TASK-973) (#357)
* feat(mcp): structured MCP error envelopes with closed taxonomy (TASK-973)
Replaces raw stderr / status-text passthrough with a closed-set
ErrorCode taxonomy + structured ErrorEnvelope. Agents can now branch
on `error.code` instead of parsing free-form text:
{
"error": {
"code": "no_workspace",
"message": "No workspace context. Pass `workspace` explicitly, ...",
"hint": "Available workspaces: docapp, pad-web",
"available_workspaces": [{"slug": "docapp", "default": true}, ...]
}
}
Taxonomy (8 codes):
- no_workspace, unknown_workspace — populate available_workspaces
- auth_required, permission_denied
- item_not_found, validation_failed, conflict
- server_error (catch-all)
Implementation:
- internal/mcp/errors.go (new): ErrorCode constants, ErrorEnvelope +
ErrorPayload + WorkspaceHint types, NewErrorResult constructor,
classifyExecError + classifyHTTPStatus dispatchers, regex pattern
matchers for stderr classification, WorkspaceLister interface for
hint enrichment.
- internal/mcp/dispatch.go: ExecDispatcher.Dispatch routes failures
through classifyExecError (with itself as the WorkspaceLister).
Adds ListWorkspaces method that shells out to `pad workspace list
--format json`. Adds RootArgs field so the listing inherits root
flags (--url etc.).
- internal/mcp/dispatch_http.go: packageHTTPResponse routes 4xx/5xx
through classifyHTTPStatus. Lookup is intentionally nil here —
TASK-977 (PLAN-943) owns the privacy-preserving available_workspaces
filtering by OAuth allow-list.
- cmd/pad/mcp.go: pre-flatten rootFlags into RootArgs at
dispatcher construction.
NewErrorResult emits BOTH structured content (for Claude Desktop,
Cursor) AND a JSON text body (for older clients). Both decode to the
same envelope so wire-level shape stays uniform.
Tests:
- TestNewErrorResult_Envelope: round-trip the envelope through
structured + text surfaces.
- TestClassifyExecError: 11 cases covering every taxonomy code via
stderr patterns.
- TestClassifyExecError_LookupFailureStillReturnsEnvelope: lookup
failures degrade to empty available_workspaces, never drop the
whole envelope.
- TestClassifyHTTPStatus: 10 cases covering each HTTP status mapping
including the workspace-vs-item 404 fork.
- TestParseWorkspaceListJSON: happy path, empty / null / malformed,
entry-without-slug skipping.
- TestExtractUnknownWorkspaceSlug: regex helper round-trip.
Out of scope (per task description):
- HTTPHandlerDispatcher available_workspaces filtering by OAuth
allow-list → TASK-977 (PLAN-943).
- item_not_found "recent items" hint enrichment → also TASK-977.
- Per-code docs page on getpad.dev/mcp/local → TASK-976.
Parent: TASK-973 → PLAN-969.
* fix(cli): add JSON output to pad workspace list per Codex review (round 1)
Codex P2: classifyExecError's WorkspaceLister side channel calls
`pad workspace list --format json` to populate available_workspaces
in no_workspace / unknown_workspace error envelopes (TASK-973). The
CLI command silently ignored formatFlag and always printed the
human-readable shape, so parseWorkspaceListJSON would fail and the
hint was effectively never populated.
Add JSON branch to workspacesCmd that emits a {slug, name,
updated_at, default} array. The `default: true` flag marks the
CWD-linked workspace so agents can prefer it without a separate
DetectWorkspace call.
Manual verification:
$ pad workspace list --format json | jq '.[0]'
{
"slug": "docapp",
"name": "pad",
"updated_at": "2026-04-14T13:24:51Z",
"default": true
}
Parent: TASK-973 → PLAN-969.
* fix(mcp): tighten unknown-workspace slug regex per Codex review (round 2)
Codex finding: extractUnknownWorkspaceSlug's bare-word regex captured
stop-words like "not" out of generic "Workspace not found" messages.
The server emits exactly this generic body in middleware_auth.go and
handlers_workspaces.go, so the resulting envelope would say
`Workspace "not" is not visible to this session.` — pushing agents
toward retrying with a bogus slug.
Tighten the regex to only match QUOTED slug forms ("workspace 'foo'"
or "workspace \"bar\""). Bare-word phrasings yield empty slug, and
unknownWorkspaceResult now emits a generic "Workspace not visible to
this session." instead of the misleading empty-string `Workspace ""`.
Test cases updated:
- "workspace 'foo' does not exist" → "foo" (still works)
- "workspace \"bar\" not found" → "bar" (still works)
- "unknown workspace baz" → "" (was "baz", now intentionally empty)
- "workspace docapp not visible" → "" (was "docapp", now empty)
- "Workspace not found" → "" (the actual server response)
The other taxonomy / hint behavior is unchanged: ErrUnknownWorkspace
still classifies correctly, available_workspaces still populates from
ListWorkspaces, and the body text still appears in Hint via the
classifyHTTPStatus 404 branch's body-append logic.
Parent: TASK-973 → PLAN-969.
|
||
|
|
9068e3e7da |
feat(mcp): document + lock workspace explicit-param precedence (TASK-972) (#356)
Workspace as an explicit per-call parameter was already wired in v0.2 —
every workspace-scoped tool's schema includes a `workspace` field, and
BuildCLIArgs implements (explicit > session > omit-and-let-CLI-find-CWD)
precedence. This commit closes the documentation + test gaps.
Changes:
- catalog.go: tighten the workspace param description to spell out the
three-step resolution order (explicit param > pad_set_workspace
session > CWD .pad.toml). Agents reading tools/list see why and how
to switch workspaces mid-session without a `cd`.
- catalog_workspace_precedence_test.go (new): three regression tests:
1. TestCatalogWorkspacePrecedence — drives pad_item.list through the
fan-out handler covering 3 of TASK-972's 4 cases (explicit-wins,
session-fallback, neither). Case 4 (no_workspace structured
error) is documented as out-of-scope here; it depends on
TASK-973's error taxonomy.
2. TestCatalogWorkspaceParamAdvertisedOnAllWorkspaceTools — every
ToolDef with Schema.Workspace=true must include `workspace` in
its tools/list schema. pad_meta is enumerated as the one
intentionally server-wide tool; future additions that drop
workspace by accident fail this test loudly.
3. TestCatalogWorkspaceDescriptionDocumentsPrecedence — pins the
schema description text so a future shortening doesn't drop the
substantive resolution-order detail.
Out of scope (per task description):
- no_workspace structured error envelope → TASK-973.
- Multi-workspace dogfood with Claude Desktop → manual verification.
Parent: TASK-972 → PLAN-969.
|
||
|
|
eb896e4469 |
feat(mcp): server-level instructions advertised in initialize handshake (TASK-971) (#355)
Adds a top-level `instructions` string to the MCP initialize response so agents know WHEN to reach for pad without having to guess from tool descriptions alone. The Svelte MCP server in the dogfooding session that triggered PLAN-969 does this; pad now does too. Implementation: - internal/mcp/instructions.md (new) — embedded source content. MCP-aware adaptation of skills/pad/SKILL.md's opener: what pad is, when to reach for it, the v0.2 tool catalog summary, resource cheatsheet, workspace resolution order, ref convention, update flow, conventions hint, and the four prompts. - internal/mcp/instructions.go (new) — //go:embed wrapper exposing the content as the Instructions package var. - internal/mcp/server.go — pass server.WithInstructions(Instructions) into NewMCPServer. Single source of truth: the same string ships in both the local stdio handshake AND PLAN-943's HTTPHandlerDispatcher (when remote /mcp mounts in TASK-950 it will reuse the same constant — no docs drift between local and remote surfaces). Test: TestServer_InitializeAdvertisesInstructions drives a real initialize round-trip and asserts the response's Instructions field equals the embedded source. Sanity-checks the embed didn't truncate. Parent: TASK-971 → PLAN-969. |
||
|
|
19f20c5911 |
feat(mcp): v0.2 catalog migrate pad_item + retire cmdhelp walker (TASK-981) (#354)
* feat(mcp): v0.2 catalog migrate pad_item + retire cmdhelp walker (TASK-981)
Final commit of TASK-970's 3-stage rollout (PLAN-969). pad_item lands
with 17 actions consolidating the v0.1 verb tools (item_create /
item_block / item_star / item_unstar / item_supersedes / item_unsupersede /
...) into one resource × action shape. cmdhelp leaf walker retired —
tools/list now advertises only the v0.2 catalog (~7 catalog tools +
pad_set_workspace).
pad_item actions:
- Lifecycle: create, update, delete, get, list, move
- Relationships: link, unlink, deps
- Stars: star, unstar, starred
- Comments: comment, list-comments
- Bulk + notes + decisions: bulk-update, note, decide
link / unlink dispatch on link_type via itemLinkRoutes table:
- blocks, blocked-by → item block / blocked-by + item unblock
- supersedes → item supersedes / unsupersede
- implements → item implements / unimplements
- split-from → item split-from / unsplit
Per-direction op (cmdPath, firstArg, secondArg, inverted) handles
the asymmetric "blocked-by unlink reuses unblock with operands swapped"
case correctly.
Walker retirement:
- registry.go shrinks dramatically. Register() now registers
pad_set_workspace + delegates to RegisterCatalog. Drop identifyLeaves,
hasExcludedAncestor, buildTool, makeDispatchHandler, propertyForArg,
propertyForFlag, propertyOptionsCommon, stringifyEnum, ToolNameFromPath,
DefaultExcludes, RegistryOptions.ExcludeCommands.
- mergeDispatchInput moves to dispatch.go (still used by env.Dispatch).
- registry_test.go pruned to: validation tests, MCPPropertyName tests,
shared helpers (fakeDispatcher, fixtureDoc, equalSlice). DOC-978
said to "delete and rebuild" — done; the v0.1 walker assertions
weren't worth carrying forward.
- cmd/pad/mcp.go: single Register() call (no separate RegisterCatalog).
ToolSurfaceVersion bumped 0.1 → 0.2. pad_meta.tool-surface's
rollout_status flips from "in-progress" to "complete" automatically
because the bump makes ToolSurfaceVersion != "0.1".
CLAUDE.md updated to reflect the new architecture (catalog over walker;
two version constants — CmdhelpVersion + ToolSurfaceVersion).
Tests:
- TestPadItemLink_DispatchTable iterates itemLinkRoutes and asserts
link/unlink dispatch correctly for every link_type, including the
blocked-by-uses-unblock-with-swapped-operands case.
- TestPadItemLink_Missing/UnknownLinkType for the structured error path.
- catalog_readonly_test.go's expected{} extended with pad_item
passThrough actions; link/unlink intentionally skipped (custom
dispatch).
- TestRegister_PassesPadVersionToCatalog round-trips PadVersion through
RegistryOptions → CatalogOptions → ActionEnv.
Parent: TASK-981 → TASK-970 → PLAN-969.
* fix(mcp): support repeatable refs for pad_item.bulk-update per Codex review (round 1)
Codex P (no priority shown — substantive issue): pad_item exposed
`ref: string` everywhere, but bulk-update's CLI takes a repeatable
positional (one or more refs). The retired cmdhelp walker generated
array schemas for repeatable args; v0.2's scalar `ref` made
bulk-update effectively single-item or schema-invalid for its
primary use case.
Fix: dedicated `refs: array<string>` schema param + custom
actionItemBulkUpdate handler. Translates `refs` array → repeatable
`ref` positional (the form BuildCLIArgs feeds CLI commands with
arg.Repeatable=true).
Why a separate `refs` param vs. overloading `ref`: keeps the schema
consistent across actions — agents see one shape per param name.
JSON Schema oneOf would also work but mcp-go's helpers don't expose
it cleanly.
Lenient fallback: a single ref passed unwrapped as a string still
works (logically equivalent to a 1-element array). Empty arrays and
missing refs both surface structured errors with `refs is required`.
Tests cover: array of strings → multiple positionals, single string
fallback, missing refs error, empty array error. Existing fixture
in TestReadOnlyCatalog_ActionsDispatchExpectedCmdPath extended with
`refs: ["TASK-1", "TASK-2"]` so bulk-update reaches dispatch.
Parent: TASK-981 → TASK-970 → PLAN-969.
|
||
|
|
fe05c170ea |
feat(mcp): v0.2 catalog read-only tools (workspace, collection, project, role, search) (TASK-980) (#353)
* feat(mcp): v0.2 catalog read-only tools (workspace, collection, project, role, search) (TASK-980)
Second commit of TASK-970's 3-stage rollout (PLAN-969). Adds 5 read-only
tools to the v0.2 catalog. v0.1 cmdhelp walker stays live alongside;
TASK-981 retires it.
Tools added:
- pad_workspace: list, members, invite, storage, audit-log
- pad_collection: list, create
- pad_project: dashboard, next, standup, changelog
- pad_role: list, create, delete
- pad_search: query (dispatches to "item search"; cross-workspace FTS)
Catalog adjustments from DOC-978's original list:
- Dropped pad_workspace.action: get — no equivalent CLI command exists.
- Dropped pad_collection.action: schema — schema is in `collection list`
output; can be added later if dogfooding shows demand.
Latent bug fix in fan-out handler:
- makeFanOutHandler now strips the catalog's `action` routing key from
the input map before invoking the action handler. Required because
some CLI commands (workspace audit-log) declare their own `--action`
flag — without stripping, BuildCLIArgs would silently emit
`--action audit-log` instead of the user's filter value. The bug was
latent in TASK-979 (pad_meta's actions don't dispatch) and surfaces
in TASK-980 with workspace audit-log.
Custom handler for workspace.audit-log:
- The CLI's `--action <filter>` flag would still collide if exposed
directly. Schema exposes it as `action_filter`; actionWorkspaceAuditLog
renames to `action` before dispatch. All other audit-log flags flow
through unchanged.
Tests:
- TestReadOnlyCatalog_AllToolsRegistered locks the new catalog entries.
- TestReadOnlyCatalog_ActionsMatchCmdhelp verifies every passThrough
cmdPath resolves in cmdhelp (catches drift at test time).
- TestPadWorkspaceAuditLog_RenamesActionFilter / _ForwardsWithoutFilter
pin the audit-log rename behavior in both directions.
- TestMakeFanOutHandler_StripsActionFromInput pins the strip behavior
so no future passThrough can leak the routing key.
Parent: TASK-980 → TASK-970 → PLAN-969.
* fix(mcp): tighten v0.2 catalog tests per Codex review (round 1)
Two P3 findings on test gaps in TASK-980's catalog_readonly_test.go:
1. TestReadOnlyCatalog_ActionsMatchCmdhelp checked a hardcoded `expected`
table against a hardcoded `liveCmdhelpDoc`. Catalog action drift
(rename, removal, addition) and CLI command renames could pass
silently because both halves were under test control. Now the test
does three-way validation:
- Every expected cmdPath resolves in liveCmdhelpDoc (catches typos
in our own table — the original check).
- Every catalog action (modulo inline-handling tools like pad_meta)
has an expected entry (catches new actions added without coverage).
- Every expected entry has a real catalog action (catches stale test
entries that outlive the action).
2. TestReadOnlyCatalog_AllToolsRegistered only verified expected names
were present; an accidental 7th tool would pass silently. Now fails
on unexpected entries AND on Catalog length mismatch.
A `skipTools` set lets us exclude pad_meta (whose actions are inline,
not dispatched) and document the exclusion. TASK-981 will extend the
expected{} map for pad_item.
Parent: TASK-980 → TASK-970 → PLAN-969.
* fix(mcp): exercise catalog actions through fake dispatcher per Codex review (round 2)
Codex P2: TestReadOnlyCatalog_ActionsMatchCmdhelp + AllToolsRegistered
were tightened in round 1, but they still didn't catch a class of drift —
e.g. flipping pad_search.query from passThrough([]string{"item","search"})
to passThrough([]string{"some","other"}) would pass the bijection check
because the action name still matches. The expected{} table was never
exercised against the actual handler.
Add TestReadOnlyCatalog_ActionsDispatchExpectedCmdPath: invokes every
catalog action through a fake dispatcher with a maximal input fixture
(satisfies all required positionals across the read-only surface) and
asserts the captured cmdPath matches the expected table.
Closes the catalog → dispatch drift hole. Now if anyone changes a
passThrough cmdPath without updating expected{}, this test fails
loudly with the actual dispatched path printed in the error.
Parent: TASK-980 → TASK-970 → PLAN-969.
|
||
|
|
df8a3631e7 |
feat(mcp): v0.2 catalog scaffold + ToolSurfaceVersion + pad_meta tool (TASK-979) (#352)
* feat(mcp): v0.2 catalog scaffold + ToolSurfaceVersion + pad_meta tool (TASK-979) First commit of TASK-970's 3-stage rollout (PLAN-969). Introduces the hand-curated v0.2 catalog types (ToolDef, ActionFn, ActionEnv) and ships one tool — pad_meta — end-to-end. v0.1 cmdhelp-walk surface stays live alongside; subsequent commits (TASK-980, TASK-981) migrate the rest and flip v0.1 off. Architecture record: DOC-978. The fan-out registry sits ABOVE the dispatcher boundary — Dispatcher / route table are unchanged, so both ExecDispatcher (stdio) and HTTPHandlerDispatcher (HTTP) inherit the new shape for free. Changes: - internal/mcp/catalog.go (new) — ToolDef, ActionFn, ActionEnv, passThrough helper, RegisterCatalog, makeFanOutHandler, structured error helpers. - internal/mcp/catalog_meta.go (new) — pad_meta tool with three inline actions: server-info, version, tool-surface (full catalog dump for PLAN-943 docs generation). - internal/mcp/version.go — add ToolSurfaceVersion = "0.2" + matching experimentalToolSurfaceKey. Independent of CmdhelpVersion (cmdhelp owns CLI help-tree contract; ToolSurfaceVersion owns MCP catalog). - internal/mcp/meta.go — extend MetaPayload with ToolSurfaceVersion; experimentalCapabilities advertises both padCmdhelp + padToolSurface. - cmd/pad/mcp.go — call RegisterCatalog alongside Register so v0.2 surface is live. - Tests: catalog_test.go + catalog_meta_test.go (new); meta_test.go + server_test.go updated to assert the new field/capability. Parent: TASK-970 → PLAN-969. * fix(mcp): keep ToolSurfaceVersion at "0.1" until catalog is complete per Codex review (round 1) Codex P1: advertising tool_surface_version=0.2 while the user-visible surface is still predominantly v0.1 (cmdhelp walker active alongside, only pad_meta in the catalog) misleads consumers that pin against the handshake or pad://_meta/version. The padToolSurface namespace would suggest the full resource/action shape is available when in reality only pad_meta uses it. Delay the 0.1 → 0.2 bump to TASK-981 — the commit that retires the cmdhelp walker and ships the complete catalog. The constant stays declared so the surface contract is wired through the handshake + meta resource + pad_meta.tool-surface, the version string just truthfully reflects "still v0.1" until the catalog is complete. No test changes needed: every assertion uses the constant, not a literal "0.2". Parent: TASK-979 → TASK-970 → PLAN-969. * fix(mcp): scope pad_meta.tool-surface to v0.2 catalog only per Codex review (round 2) Codex P1: pad_meta.tool-surface description claimed "Full catalog dump: every tool" but during PLAN-969's parallel rollout, tools/list contains both the catalog (currently just pad_meta) AND the cmdhelp walker's ~85 verb tools. Calling the catalog dump "every tool" misleads consumers who expect a complete enumeration. Same spirit as round 1's fix: stop claiming what isn't true. The catalog dump is the v0.2 catalog by design — consumers wanting the complete advertised surface should read tools/list directly. Hand-mapping the walker output into the catalog dump would cost duplication for a surface that's about to disappear in TASK-981. Wire-level changes: - Tighten the action description in padMetaToolDescription to say "v0.2 catalog dump: every tool managed by the hand-curated catalog" and explicitly note tools/list is the source for the complete surface. - Add rollout_status field to the response payload: "in-progress" while ToolSurfaceVersion stays at "0.1", "complete" once TASK-981 bumps it. Lets consumers detect the rollout state programmatically. - Test asserts the new field tracks ToolSurfaceVersion. Parent: TASK-979 → TASK-970 → PLAN-969. * fix(mcp): include params in pad_meta.tool-surface dump per Codex review (round 3) Codex P1: tool description claimed the dump includes each tool's "input schema" but the payload only emitted name/description/workspace/ actions[]. Misleading for docs generators (TASK-957) that would build getpad.dev/docs/mcp from this canonical source. Going with the substantive fix rather than just trimming the description: include a synthesized params[] per tool entry. Mirrors what consumers see in tools/list — `action` (always required, enum of declared action names), `workspace` (when ToolDef.Schema.Workspace=true), and per-tool ParamDefs. Synthesizing `action` and `workspace` rather than copying them from ToolDef makes the dump self-contained: a docs generator doesn't need to reproduce buildToolFromDef's implicit-param logic separately. Test asserts each catalog entry has params[] starting with `action` (enum length matches action handler count) and the right total length based on Schema.Workspace + Schema.Params. Parent: TASK-979 → TASK-970 → PLAN-969. |
||
|
|
c70186547a |
feat(mcp): attachment list + show; reject upload/download/view as CLI-only (TASK-968 final slice) (#350)
* feat(mcp): wire attachment list + show; reject upload/download/view as CLI-only (TASK-968 partial)
Final slice of TASK-968's surface expansion. Two attachment commands
wired (the metadata-only ones), three rejected as noRemoteEquivalent
(those that take a local filesystem `<path>` argument), and the
noRemoteEquivalent map gets a small refactor to carry per-entry
rationale clauses.
New commands:
- attachment list → custom dispatcher with --item ref→UUID
resolution, --attached/--unattached
mutex fold, full filter pass-through
(category, collection, sort, limit,
offset).
- attachment show → HEAD /api/v1/workspaces/{ws}/attachments/{id}
packaging response headers
(Content-Type, -Length, -Disposition,
ETag, Last-Modified) into the CLI's
--format json shape:
{id, mime, size, filename?, etag?,
last_modified?}
Rejected as noRemoteEquivalent (with per-command rationale):
- attachment upload → needs a local filesystem `<path>` arg;
agents fetch raw bytes via the
attachment URL directly.
- attachment download → writes to a local filesystem `<out-path>`;
agents read raw bytes via the URL.
- attachment view → writes to a local filesystem path and
prints it; agents read bytes via the URL.
Refactor: noRemoteEquivalent went from `map[string]struct{}` to
`map[string]string` where the value is a rationale clause appended
to the error message. The previous generic message ("operates on
local pad client / config state, not the workspace") was misleading
for attachments (which DO operate on workspace state, just via a
local filesystem argument). Per-entry rationale lets each rejection
point at the alternative path agents should use — e.g. github
commands now suggest `item update --field github_pr=...`,
attachment commands point at `attachment show` + the URL.
The `--item TASK-5` resolution on `attachment list` reuses the
existing resolveItemRef helper (introduced in PR #346 for the link
commands), so the OAuth-scope hook (d.Apply) applies uniformly to
the prefetch — no scope bypass.
`parseAttachmentFilename` reproduces the CLI helper of the same
name. Handles both the bare `filename="value"` form and the RFC
5987 `filename*=UTF-8''<urlencoded>` form, preferring the latter
when both appear (spec-compliant carrier for non-ASCII names).
Tests:
- attachment list happy path with full query string forwarding
- --attached/--unattached fold + mutex rejection
- --item ref → UUID resolution end-to-end (and abort-on-resolution-
failure pin)
- attachment show: header → JSON extraction with all five fields
populated
- --variant query forwarding
- 404 surfaces as IsError
- parseAttachmentFilename: standard/quoted/unquoted/RFC5987 cases
- Per-entry rationale verified: github vs attachment messages
differ (the refactor's behavioural test).
- Integration smoke: attachment list against fresh workspace
(returns total=0); upload/download/view rejected with stable
"no remote equivalent" prefix.
TASK-968 is now functionally complete: 38 commands wired across
PR #346, #347, #348, #349, this PR, plus 13 commands explicitly
rejected as noRemoteEquivalent. The route table expansion lifts
the dispatcher from TASK-965's seed of 1 command to the full
mid-tier MCP surface PLAN-943's TASK-950 needs.
Parent: PLAN-943.
* fix(mcp): use mime.ParseMediaType for Content-Disposition parsing per Codex review (round 1)
Codex caught that the previous strings.Split(";") approach in
parseAttachmentFilename chopped quoted filenames containing semicolons
at the first internal `;`, returning `"a"` for
`attachment; filename="a;b.png"`. The CLI's helper of the same name
uses mime.ParseMediaType which respects the quote boundaries, so
the MCP path was diverging from CLI behaviour.
Fix: replace the hand-rolled splitter with mime.ParseMediaType +
filepath.Base(name) — matching the CLI exactly. mime.ParseMediaType
also handles the RFC 5987 `filename*=UTF-8''<urlencoded>` form
automatically, so we no longer need the explicit precedence check
either.
filepath.Base is the same defensive base the CLI applies even
though the server is supposed to sanitize before emitting the
header — keeps a stray `../` from sneaking through.
Tests added:
- PreservesSemicolonsInQuotedFilename pins the
`attachment; filename="a;b.png"` regression Codex flagged.
- AppliesBasenameDefense pins the directory-stripping behaviour
that filepath.Base provides.
Pre-existing tests (StandardForm, PrefersFilenameStarOverFilename,
HandlesQuotedAndUnquoted) still pass — mime.ParseMediaType handles
all three cases correctly.
Parent: PLAN-943.
|
||
|
|
a5c58a5d66 |
feat(mcp): wire project standup + changelog + library activate (TASK-968 partial) (#349)
Continues TASK-968 past PR #348. Three more commands wired — the last of the project intelligence + library composition surfaces that needed multi-call composition. New commands: - project standup → multi-call: GET /dashboard + iterate terminal statuses via /items list + GET /items?status= in-progress. Filters completed by --days cutoff (default 1) client-side. Builds {date, days, completed, in_progress, blockers, suggested_next} matching the CLI's --format json output exactly. - project changelog → multi-call: iterates terminal statuses, filters by date (--since YYYY-MM-DD overrides --days, default 7) and parent (--parent ref/slug/title, case-insensitive across parent_link_id / parent_ref / parent_title). Groups by collection_slug, preserving first-seen order for stable output. - library activate → looks up convention/playbook by title via internal/collections.GetLibraryConvention / GetLibraryPlaybook (avoids two HTTP round-trips for the static library data), builds canonical fields blob via models.BuildConventionItemFields for conventions or {status,trigger,scope} for playbooks, POSTs into the workspace's conventions / playbooks collection. The standup + changelog dispatchers consolidate the multi-call patterns onto a small helper set: - listWorkspaceItems(ctx, user, workspace, query) for the per-status iteration. Best-effort error tolerance per call (matches CLI: a single failed status doesn't abort the standup). - itemUpdatedAfter for date-cutoff filtering. - itemMatchesParent for case-insensitive ref/slug/title parent filtering (mirrors the CLI's strings.EqualFold across three fields). - itemRefFromMap / itemTitleFromMap / extractItemFieldString / stringFromMap for the map[string]any decode path. The dispatcher operates on maps (not typed structs) for forward compat with server-side field additions, same approach as the slice 3 fix Codex caught in PR #348. Library activate routes through internal/collections package access rather than HTTPing the /convention-library + /playbook-library endpoints. Both paths return identical data (the handlers wrap the same constants), and the in-process accessor saves two round-trips per activate. The OAuth-scope hook still applies to the eventual POST so it's not a scope bypass. Tests: - Standup: shape pinned + cutoff filter (old item excluded) + --days default to 1. - Changelog: collection grouping + counts + --since overrides --days + parent filter + bad-since rejection. - Library activate: convention path (commits format, with seed metadata) + playbook fallthrough (Implementation Workflow) + not-found error + missing-input rejection. - Helpers: itemRefFromMap covers float64/int/int64/json.Number/ nil/string forms; extractItemFieldString handles malformed JSON; itemMatchesParent walks all three parent fields case-insensitively. - Integration smoke against real *server.Server: standup + changelog (empty workspace) + library activate (Conventional commit format from seed library). Cumulative TASK-968 progress: 36/~50 commands wired across PR #346, #347, #348, this PR. Remaining: attachments (5 commands, multipart bodies — separate PR). Parent: PLAN-943. |
||
|
|
1fc41f2f4a |
feat(mcp): project intel + collection create + library list + bulk-update + note/decide (TASK-968 partial) (#348)
* feat(mcp): wire project intel + collection create + library list + bulk-update + note/decide (TASK-968 partial) Continues TASK-968 past PR #347's stars/roles/webhooks slice. Nine more commands land here, one more joins noRemoteEquivalent. New commands: - project next → alias /dashboard (matches CLI's verbatim --format json output) - project ready → custom: extracts suggested_next as {count, results} - project stale → custom: filters dashboard.attention to interesting types (stalled/blocked/ overdue/orphaned_task), sorted - collection create → custom: parses --fields DSL (key:type[:opts];...) into CollectionSchema, builds settings - library list → composes /convention-library + /playbook-library based on --type - item bulk-update → iterates refs with per-item RMW; per-item failures surface in results rather than aborting - item note → RMW append using models.AppendImplementationNote - item decide → RMW append using models.AppendDecisionLogEntry Extended noRemoteEquivalent: - project reconcile → shells out to `gh` CLI for live PR state, same locality reasoning as the github commands The project-intelligence dispatchers reproduce the CLI's --format json shapes exactly: - `next` returns dashJSON verbatim (CLI does the same) - `ready` returns {count, results} extracted from suggested_next - `stale` returns {count, results} after filterAgentAttention's type filter + (type, ItemRef, ItemTitle) sort Aliasing all three to /dashboard would diverge — agents would see an unexpected wrapper shape. `item bulk-update` mirrors the CLI's per-item RMW loop, including the "existing fields survive" guarantee. The response shape {updated, total, results[]} makes per-item outcomes available so the agent can inspect what succeeded vs. failed without re-querying. Per-item refs accept string / []string / []any for schema-permissive callers. `item note` / `item decide` reuse models.AppendImplementationNote / AppendDecisionLogEntry so CLI-created and MCP-created entries are indistinguishable. The created_by field uses the requesting user's name (or email fallback) so audit trails work in multi-user MCP deployments — the CLI hardcodes "user" since it's single-user-per- process. Tests: - Per-command happy + missing-input rejection. - project ready/stale shape pinned (count, results); stale's filter + sort order verified. - parseCollectionFieldsDSL pinned: title-cased labels, status:select auto-required+default, malformed entries rejected, empty input returns empty fields[]. - library list type-filter skips other endpoint when --type set; unknown --type rejected with clear error. - bulk-update: per-item failure doesn't abort batch; existing fields survive RMW; status/priority required gating. - note/decide: AppendImplementationNote/Decision entries land in fields with correct created_by user label. - project reconcile rejected with stable noRemoteEquivalent message. - Integration smoke against real *server.Server: create+bulk-update +note → project ready/stale → collection create → library list. Cumulative TASK-968 progress: 33/~50 commands wired across PR #346, #347, this PR. Remaining sections: project standup + changelog (multi-call composition); library activate (model-helper composition); attachments (multipart, separate PR). Parent: PLAN-943. * fix(mcp): preserve all dashboard.attention fields by switching to map-based decoding per Codex review (round 1) Codex caught that projectAttention's typed-struct round-trip dropped the `collection` field from the dashboard's attention entries — and would have dropped any future field additions silently. Same risk applied to projectSuggestion. Fix: stop decoding into a reduced typed struct. The dispatcher now unmarshals dashboard JSON into map[string]any, pulls named arrays via dashboardArrayField helper, and operates on the maps directly through filterAgentAttention's filter+sort. Result: every field the server emitted on each attention/suggestion entry survives to the response, no maintenance burden when handlers add new fields. filterAgentAttention now operates on []map[string]any with typed-string asserts at the comparator. Same filter set (stalled / blocked / overdue / orphaned_task) and same (type, item_ref, item_title) sort order — output ordering still pinned by the existing test. Test added: TestDispatch_ProjectStale_PreservesAllFields seeds an attention entry with every documented field PLUS a forward-compat `future_field` and asserts all of them flow through to the response. That regression-pins the wire-shape forwarder behaviour. Parent: PLAN-943. |
||
|
|
e2127868d8 |
feat(mcp): stars + roles + webhooks + auth.whoami + workspace surface (TASK-968 partial) (#347)
* feat(mcp): wire stars + roles + webhooks + auth.whoami + workspace surface (TASK-968 partial) Continues TASK-968's route-table expansion past PR #346's link/lifecycle slice. Twelve more commands land here, plus the github commands move into the noRemoteEquivalent rejection set. New commands: - item star / unstar / starred (3 — stars) - role create / role delete (2 — admin role mgmt) - webhook list / create / delete / test (4 — webhook lifecycle) - auth whoami (1 — global, /auth/me) - workspace list / storage / audit-log (3 — workspace surfaces) - workspace invite (bonus — POST /members/invite) The simple-shape commands go through routeSpec; the few with custom shape (role create body, webhook create body, item starred's --all toggle, workspace audit-log's filter forwarding, workspace invite's default-respecting role omission) get their own RouteMappers. `auth whoami` and `workspace list` are global routes — the route-spec framework already supports paths without a {workspace} placeholder so they slot in cleanly. `workspace audit-log` hits /api/v1/audit-log (NOT scoped to workspace by URL — admin-only, can be filtered by ?workspace=<id> via input pass-through). `item star`/`unstar` rely on handleStarItem's store.ResolveItem accepting refs, slugs, AND UUIDs as the URL param — no prefetch needed. Extends noRemoteEquivalent with `github link`/`status`/`unlink`. These chain `git rev-parse` + the `gh` CLI to build the PR data they write — that data inherently lives in the agent's local checkout, so they can never have a useful remote equivalent. The error message points agents at the alternative path: use their own GitHub tools to fetch PR data, then `item update --field github_pr=...`. Tests: - Per-command happy path + missing-input rejection. - mapItemStarred default-vs-all behaviour for include_terminal. - mapRoleCreate and mapWorkspaceInvite omit empty optional fields so handler defaults aren't clobbered. - mapWorkspaceAuditLog forwards filters (action/actor/days/limit) and skips empty ones; verifies query string shape. - github commands rejected with stable noRemoteEquivalent message. - Integration smoke against real *server.Server: whoami, workspace list, create+star+starred, role create+delete, webhook list+create+delete. Parent: PLAN-943. * fix(mcp): drop session-default workspace forwarding for audit-log per Codex review (round 1) Codex caught a real divergence on PR #347: mergeDispatchInput auto- injects the session workspace into every MCP input map, so my mapWorkspaceAuditLog forwarding `input["workspace"]` as `?workspace=<id>` would silently scope audit-log calls to the session workspace by default. That's not what the CLI does — `pad workspace audit-log` deliberately omits the workspace filter so admins get the GLOBAL audit log. The fix matches CLI behaviour exactly: drop the `workspace` query-param forwarding. If a future CLI flag exposes workspace-scoped audit-log queries, it should surface as a separate input via cmdhelp (e.g. `--filter-workspace`) rather than folding-in the implicit session value. Test pin: TestMapWorkspaceAuditLog_DoesNotForwardSessionWorkspace asserts that workspace passed as input (the session-default form) does NOT show up as ?workspace= in the URL. The pre-existing pass-through test loses its workspace assertion since it's no longer forwarded. Parent: PLAN-943. * fix(mcp): normalize webhook events filter to JSON-array shape per Codex review (round 2) Codex caught that mapWebhookCreate forwarded the CLI's comma-separated --events string verbatim (e.g. "item.created,item.updated"), but the store persists events into a column that webhooks.matchesEvent unmarshals as a JSON array. Anything that isn't valid JSON-array syntax matches no events at all. The CLI is independently bugged here — its example `--events "item.created,item.updated"` produces a webhook that never fires — but the MCP path can produce a working webhook the first time. Add normalizeWebhookEvents: - Empty / missing → omit, let the store apply `["*"]` default. - Already a JSON array → pass through unchanged (no double-encoding). - Comma-separated → split, trim, encode as JSON array. - []any / []string from a schema-permissive caller → encode directly. - Malformed input → omit safely (caller falls back to default). Tests: - mapWebhookCreate normalizes comma-separated to JSON array; other fields untouched. - normalizeWebhookEvents passes through valid JSON, accepts array inputs, omits empty. - mapWebhookCreate omits events when missing (lets store default apply, matching the CLI's omit-flag behaviour). Parent: PLAN-943. |
||
|
|
a7b62ed886 |
feat(mcp): wire item link/lifecycle commands + slug-based --role resolution (TASK-968 partial) (#346)
* feat(mcp): wire item link/lifecycle commands + slug-based --role resolution (TASK-968 partial)
Expands HTTPHandlerDispatcher beyond TASK-967's per-command set with
twelve new commands across the link/lifecycle surface, plus the
prefetch-based --role slug → agent_role_id resolution that mirrors the
--assign path TASK-967 introduced.
New commands:
- item block / blocked-by / unblock (blocks links)
- item implements / unimplements (implements links)
- item supersedes / unsupersede (supersedes links)
- item split-from / unsplit (split_from links)
- item deps / related / implemented-by (read-only link queries)
The link create/delete commands have a URL/body asymmetry the simple
routeSpec framework can't express: block uses (source.Slug, target.ID)
while blocked-by inverts to (blocker.Slug, source.ID). They also need a
ref→{slug,id} prefetch pair before building the request, which means
they need a Handler reference. Those land as method-bound dispatchers
in dispatch_http_links.go alongside an itemLinkSpec table that captures
the per-command (urlRefKey, bodyTargetRefKey, linkType) tuple. Read-only
link queries (deps/related/implemented-by) all GET /items/{ref}/links
and let the agent group however it wants — same payload, different
rendering on top.
Slug-based --role resolution mirrors TASK-967's --assign path:
resolveRoleSlug + lookupRoleID hit /api/v1/workspaces/{ws}/agent-roles/{slug}
and rewrite role: <slug> → agent_role_id: <uuid> before the mapper
runs. This replaces the older --role rejection in mapItemCreate /
dispatchItemUpdate that pointed agents at the agent_role_id workaround;
the workaround still works (lifted via liftFieldsToColumns) but slugs
now flow through cleanly. commandsAcceptingRoleBySlug is the allowlist;
item.list intentionally stays out because its handler accepts both UUID
and slug at the query-param level.
Adds noRemoteEquivalent rejection set for genuinely-CLI-only commands
(agent status, mcp status/uninstall, server info/open, workspace
link/switch/context). The error message says "no remote equivalent —
CLI-only command" to give agents a stable signal distinct from
"not yet implemented over HTTP transport" — the former never lands
remotely, the latter just hasn't been wired yet.
Tests:
- Per-command happy path + missing-input rejections + prefetch
failure rejections.
- Asymmetry test (blocked-by URL goes through blocker, body
target_id is source).
- Canonical link-type wire form (split_from not split-from).
- Slug → agent_role_id resolution end-to-end through Dispatch.
- noRemoteEquivalent stable error format.
- Integration smoke against *server.Server: create A + B → block →
deps surfaces → unblock → deps empty → supersedes works (catches
canonical-wire-form regressions in the store).
Parent: PLAN-943.
* fix(mcp): related/implemented-by emit CLI's grouped JSON shape; wrap DELETE 204 as {status:removed} per Codex review (round 1)
Two parity gaps Codex caught on PR #346:
1. `item related` and `item implemented-by` were returning the raw
/links array. The CLI's `--format json` output wraps and post-
processes — `related` returns `{item_ref, item_title, collection,
group_count, groups[]}` (links grouped by canonical type +
direction); `implemented-by` returns `{item_ref, item_title, count,
results[]}` (filtered to incoming `implements` only).
Fix: the read-only link queries now have command-specific
dispatchers — `dispatchItemDeps` keeps the raw-array shape (matches
`deps --format json`), while `dispatchItemRelated` and
`dispatchItemImplementedBy` reproduce the CLI's wrapping and
filtering. The grouping helpers (buildRelatedGroups,
incomingImplementedBy, relatedEntryFromLink) are reproduced from
cmd/pad/query.go since the CLI versions are in package main.
2. Successful unblock/unimplements/unsupersede/unsplit dispatches
were returning empty TextContent because the handler emits 204
No Content. The CLI's `--format json` for these commands prints
`{"status":"removed"}`, so MCP clients lose the structured
success signal.
Fix: dispatchDeleteItemLink now wraps the 2xx response with
`{"status":"removed"}` via the new packageStructuredResponse
helper.
Also adds packageStructuredResponse: a small helper that marshal-
decodes synthesized payloads back to `any` before stuffing them into
NewToolResultStructured, so the StructuredContent surface matches
what packageHTTPResponse emits for normal route paths (`map[string]any`
/ `[]any` / JSON-decoded primitives, not Go-typed slice/struct
instances).
Tests:
- Per-command tests for `related`'s grouped shape (blocks +
implemented_by groups appear)
- `implemented-by`'s incoming-only filter (outgoing implements
+ non-implements links excluded from `count`/`results`)
- DELETE 204 wrapping for unblock
- `deps` keeps raw-array shape (parity preserved)
Parent: PLAN-943.
|
||
|
|
cb526a5875 |
feat(mcp): item.update + --assign name resolution + workspace.members (TASK-967 partial) (#345)
* feat(mcp): item.update with field-merge + --assign name resolution + workspace.members (TASK-967, partial) Closes the biggest item-write gap in HTTPHandlerDispatcher: - `item.update` now works through the dispatcher with full CLI parity, including the read-modify-write merge of the item's fields JSON. Without RMW, `item update TASK-5 --status done` would erase every other field set on the item — Codex caught the equivalent top-level-vs-fields shape regression on item.create in PR #343. - `--assign <name|email>` now resolves to a user UUID via the workspace-members endpoint, matching the CLI's behaviour. Previously rejected with a clear error pointing at `--field assigned_user_id=<uuid>`; now the dispatcher does the lookup so agents can use the same human-friendly form they'd use in the CLI. - `workspace.members` is wired into the route table both as a tool (agents can list members directly) and as the resolution backend. ## Architecture - New `dispatch_http_advanced.go` holds dispatcher methods that need access to the wrapped `Handler` (for in-handler prefetches like RMW or member lookup). Stays separate from `dispatch_http_routes.go` which is the simple-mappers domain. - `Dispatch` now has a preprocess step that runs before the route table lookup: for an allowlisted set of commands (item.create / update / list), `--assign` is resolved to `assigned_user_id`. Other commands' input passes through untouched, so `item.show` won't spuriously hit the members endpoint. - `item.update` is a special case in Dispatch's switch — it doesn't use the route table because it needs to issue a GET-then-PATCH pair with merged fields. Other RMW commands (none for now) would slot into the same switch. - New `executeRequest` helper on the dispatcher centralizes the request-build → recorder → packageHTTPResponse path so the simple table-driven case and the RMW case share it without duplication. ## Tests - Unit: 7 tests on `resolveAssignName` covering pass-through (missing / empty), name + email matches (case-insensitive), no-match error, explicit-ID precedence, and members-endpoint failure propagation. - Unit: 3 Dispatch-level tests for the preprocess (success path, failure surfaces as IsError without dispatching the main request, allowlist scoping). - Unit: 4 dispatchItemUpdate tests covering the field merge, the no-fields-changes guard, prefetch 404 → no PATCH, and missing workspace/ref validation. - Integration: end-to-end through real *server.Server + SQLite — create with --assign Alice, then update --status without re-specifying priority / category, and assert the existing fields survive the merge. ## Out of scope - `--role <slug>` resolution. The route table has `role list` so agents can fetch the slug → ID mapping themselves; full prefetch-resolution lives in the next route-table expansion. mapItemCreate now rejects `--role` loudly (was previously bundled with the `--assign` rejection). - The remaining ~50 commands (links, dependencies, star, project intelligence, library, github, attachments, webhooks, …) — TASK-967 stays open for follow-up PRs. Parent: PLAN-943. * fix(mcp): item.update --role rejection + Apply hook on prefetches per Codex review (round 1) Codex caught two real parity / security issues: 1. `item.update` silently ignored `--role`. mapItemCreate rejects it loudly because slug → role-ID resolution isn't built yet, but dispatchItemUpdate was a separate code path that didn't echo the guard. Agents would have gotten a successful update response while the role assignment got dropped on the floor. Reject with the same message + same pointer to `--field agent_role_id=<uuid>`. 2. The new prefetches (workspace.members lookup for --assign, item.update GET) bypassed `d.Apply`. Apply is the OAuth-scope hook the future TASK-953 middleware will use to attach token-allow-list / capability- tier context. Prefetches running outside that hook would have been a scope-bypass surface — agents could read members or items their token shouldn't have access to during resolution. Centralized the build + apply step in a new buildAuthedRequest helper on HTTPHandlerDispatcher. Both the in-handler prefetches and executeRequest now go through it so every synthesized request — main PATCH, GET prefetch, members lookup — sees Apply uniformly. Tests: - TestDispatchItemUpdate_RejectsUnsupportedRole asserts the role rejection trips before any handler call. - TestDispatch_PrefetchesGoThroughApplyHook asserts all three request types in an --assign + RMW flow (members lookup, item GET, item PATCH) flow through the Apply callback. Parent: PLAN-943. * fix(mcp): real --role workaround pass-through + corrected error message per Codex review (round 2) Codex caught a misleading error message: the rejection of --role pointed agents at `--field agent_role_id=<uuid>` as the workaround, but `--field` writes into the item's fields JSON blob, NOT the agent_role_id column on the ItemCreate / ItemUpdate models. So agents following the workaround would have ended up with the value sitting inert inside fields and the actual role assignment unchanged — the exact silent-success failure mode the rejection was meant to prevent. Two changes: 1. mapItemCreate + dispatchItemUpdate now pass `agent_role_id` through to the request payload at the top level, the same way `assigned_user_id` is passed through. So an agent that knows the role's UUID (from a prior `role list` call) can set it without --role slug resolution. 2. The error message text now points at "pass `agent_role_id=<uuid>` directly in the tool input" instead of `--field agent_role_id=...`. Adds the hint to discover the UUID via `role list` since that's the workflow agents would follow. Tests: - TestMapItemCreate_PassesThroughAgentRoleID asserts agent_role_id lands at the top level of the create body, NOT inside fields. - TestDispatchItemUpdate_PassesThroughAgentRoleID asserts the same for the update PATCH body. Parent: PLAN-943. * fix(mcp): lift column keys out of --field blob so the --role workaround is reachable per Codex review (round 3) Codex caught: the rejection of --role pointed agents at `agent_role_id=<uuid>` as a top-level input, but the MCP tool schema (auto-generated from cmdhelp) doesn't expose a top-level `agent_role_id` flag — only `--role` and `--field`. Strict clients (Claude Desktop, Cursor) following the schema have no way to send the recommended workaround as written. Two changes: 1. Added a `liftFieldsToColumns` helper that scans the fields blob for column keys (agent_role_id, assigned_user_id) and moves them onto the top-level payload. mapItemCreate runs this before serializing fields; dispatchItemUpdate runs it after the merge step. 2. The error message text now points at `--field agent_role_id=<uuid>` — that IS in the schema, and the lift logic ensures the value actually reaches the column rather than sitting inert in the fields JSON. The text explains the lift behaviour explicitly so readers don't think it's a hack. This makes the workaround genuinely reachable for any agent that follows the auto-generated tool schema. The "right" long-term fix (adding agent_role_id and assigned_user_id as proper named flags in cmdhelp so the schema exposes them directly) is captured for TASK-968. Tests: - TestMapItemCreate_LiftsAgentRoleIDFromFieldKVPToColumn pins the lift end-to-end on item.create. - TestMapItemCreate_LiftsAssignedUserIDFromFieldKVP confirms the same path works for the other column key. - TestMapItemCreate_TopLevelAgentRoleIDWinsOverFieldKVP locks the precedence rule (top-level wins; lift still strips the dup). - TestDispatchItemUpdate_LiftsAgentRoleIDFromFieldKVPToColumn pins the same behaviour on item.update's PATCH path. Parent: PLAN-943. |
||
|
|
5320f988ee |
feat(mcp): expand HTTPHandlerDispatcher route table — 10 new commands + routeSpec framework (TASK-966) (#344)
* feat(mcp): expand HTTPHandlerDispatcher route table — 10 new commands + declarative routeSpec framework (TASK-966)
TASK-965 shipped HTTPHandlerDispatcher with `item create` as the
proof-of-concept route. This expansion lays a small declarative
framework (`routeSpec` → `RouteMapper`) and wires the high-value
read + write surface, so an OAuth-authenticated agent connecting via
the future /mcp endpoint (TASK-950) gets a useful tool surface
out-of-the-box rather than 11/12 tools returning "not yet implemented
over HTTP transport."
## Framework
`routeSpec` (in dispatch_http_routes.go) is the declarative shape
shared across simple commands:
routeSpec{
method: http.MethodGet,
pathTemplate: "/api/v1/workspaces/{workspace}/items/{ref}",
queryParams: map[string]string{"q": "query", ...}, // dst→src
bodyKeys: []string{"title", "content", ...},
}
`{placeholder}` segments substitute from the input map (snake_case
keys per TASK-964); `collection` and `target_collection` placeholders
are auto-normalized via `collections.NormalizeSlug`. queryParams maps
URL-query names to input keys and handles type coercion for ints
(json.Number / float64) and bool presence-only treatment. bodyKeys
emits a flat JSON body with empty values omitted.
Commands that don't fit the shape (item.create's fields-rolling,
item.move's nested overrides, item.list's path-varies-on-arg,
item.search's renamed q param, item.comment's message→body rename)
stay as standalone RouteMapper functions. The escape hatch is
deliberate — the simple cases get one-line entries; the weird cases
get full functions with their own test coverage.
## Commands wired (10 new + item.create)
| Cmd | Method | Path / Notes |
| --- | --- | --- |
| item create *(prior)* | POST | /workspaces/{ws}/collections/{coll}/items, fields-rolling |
| item show | GET | /workspaces/{ws}/items/{ref} |
| item delete | DELETE | /workspaces/{ws}/items/{ref} |
| item list | GET | path varies on collection arg; filters → query |
| item move | POST | /items/{ref}/move with target_collection + field_overrides body |
| item search | GET | /search?q=...&workspace=... (cross-workspace) |
| item comment | POST | /items/{ref}/comments, message→body, reply_to→parent_id |
| item comments | GET | /items/{ref}/comments |
| project dashboard | GET | /workspaces/{ws}/dashboard |
| collection list | GET | /workspaces/{ws}/collections |
| role list | GET | /workspaces/{ws}/agent-roles |
## Out of scope
`item update` requires read-modify-write semantics (the CLI fetches
the existing fields JSON, merges in --status / --priority / --field
entries, then PATCHes the merged result; the handler treats Fields
as a complete replacement). Implementing that here would mean making
two HTTP calls per dispatch and adding a new "prefetch" hook to the
framework — out of scope for this PR. Captured as the next follow-up.
`project next` / `project standup` / `project changelog` are CLI-side
compositions (multiple API calls + presentation logic) with no
single backing endpoint. Their HTTP equivalent for an agent is "call
project dashboard and read the suggested_next field." Documented in
the follow-up task.
The remaining ~40 commands (attachments, webhooks, library, github,
workspace audit-log, role create / delete, ...) are tracked in the
follow-up.
## Tests
- Framework unit tests: expandPath (substitution, normalization,
escaping, error paths), buildQuery (rename, type coercion,
json.Number support, empty-skip), flatJSONBody (omission rules).
- Per-command unit tests: every wired command has a happy-path
assertion + at least one error path. Custom mappers
(item.list / move / search / comment) get table-driven coverage of
their renames + path-variation behaviour.
- Lock test: TestRouteTable_ContainsExpectedCommands fails loudly if
an entry gets accidentally deleted.
- Integration smoke (TestHTTPHandlerDispatcher_Integration_ReadPaths)
drives item create → list → show → project dashboard → collection
list end-to-end against a real *server.Server, asserting the
full chain stays wired together after the refactor.
Parent: PLAN-943.
* fix(mcp): item.list parity with CLI per Codex review (round 1)
Codex caught three CLI-parity bugs in mapItemList:
1. Default active-status filter missing. `pad item list` ships a
broad inclusion list of active statuses unless --status or --all
is set; the HTTP mapper returned no status filter, so MCP would
leak done/completed/archived items by default.
2. `--parent <ref>` mapped to query param `parent_id`, which the
server treats as a literal ID. The CLI uses `parent`, which
parseItemListParams' unknown-key path routes to resolveParentFilter
for ref → UUID resolution. Without this, `?parent=PLAN-3` would
silently match nothing.
3. `--assign <name>` passed straight through as `assigned_user_id`.
The CLI resolves names → user IDs via a workspace-members lookup
first; passing the raw name to the store filter (which compares
against `i.assigned_user_id` UUID) returns nothing.
Fixes:
1. Added `defaultActiveStatusFilter` constant mirroring the CLI's
hardcoded list at cmd/pad/main.go itemListCmd. Applied when
neither --status nor --all is set; --all drops it (so done items
show); explicit --status wins (so the user can pin to any tier).
2. Renamed the query-param target from `parent_id` to `parent` so
the handler's resolveParentFilter sees it as a field filter and
does ref→UUID resolution.
3. Reject `--assign` with a clear error pointing agents at
`--field assigned_user_id=<uuid>` for explicit-ID filtering. Same
pattern as the existing rejection on item.create. Full name → ID
prefetch belongs in the same follow-up that handles `--assign` on
item create / update.
Tests:
- TestRoute_ItemList_AllItemsPath_AppliesDefaultActiveStatusFilter
asserts the broad inclusion list is on the wire and verifies a
spot-check of well-known active + terminal statuses.
- TestRoute_ItemList_AllFlagDropsDefaultStatus pins --all behaviour.
- TestRoute_ItemList_ExplicitStatusOverridesDefault pins explicit
--status precedence.
- TestRoute_ItemList_FiltersAsQuery now asserts `parent` (not
`parent_id`) is what reaches the wire.
- TestRoute_ItemList_RejectsAssignByName covers the rejection.
- TestRoute_ItemList_NumericLimitFromJSONNumber covers the
json.Number path through the new numericInput helper.
Parent: PLAN-943.
* fix(mcp): normalize collection alias on item.search per Codex review (round 2)
Codex caught: `pad item search foo --collection task` was passing
"task" through verbatim to /api/v1/search?collection=task, but the
search store filters via `c.slug = ?` (exact match) and 0-matches
shorthand. The CLI normalizes to "tasks" first; mapper now does the
same.
Lifted the mutation into a tiny cloneStringMap helper so the input
map the caller hands us isn't accidentally rewritten — the registry
attaches the original via WithDispatchInput, and downstream code
shouldn't see a mapper's normalization leak back.
Tests:
- TestRoute_ItemSearch_NormalizesCollectionAlias asserts task → tasks
on the wire.
- TestRoute_ItemSearch_DoesNotMutateInput pins the no-mutation
contract so future refactors of the helper don't regress.
Parent: PLAN-943.
|
||
|
|
d84f1180a7 |
feat(mcp): pluggable Dispatcher + HTTPHandlerDispatcher for remote MCP (TASK-965) (#343)
* feat(mcp): pluggable Dispatcher + HTTPHandlerDispatcher for remote MCP (TASK-965)
Architectural prerequisite for PLAN-943's remote MCP at /mcp. The
existing ExecDispatcher (PLAN-942) shells out to the pad binary and
inherits credentials from ~/.pad/credentials.json — fine for local
stdio MCP where the user IS the subprocess owner, but unworkable for
a multi-tenant /mcp endpoint where the dispatcher must serve many
OAuth-authenticated users from a single process.
This PR ships the alternative path: HTTPHandlerDispatcher calls
pad-cloud's existing HTTP handler chain in-process, with the
requesting user attached via context. Same handlers, same audit /
event-bus / webhook plumbing — just no fork().
## What's in
- internal/mcp/dispatch.go: keeps the existing Dispatcher interface
(so ExecDispatcher unchanged) and adds a context-keyed
WithDispatchInput helper. The registry attaches the original JSON
input map to the dispatch context so dispatchers that prefer
structured data over reverse-parsed cliArgs can use it.
- internal/mcp/registry.go: forwards the merged input (user-supplied
values + session workspace + root flags) to the dispatcher via
WithDispatchInput. ExecDispatcher ignores it.
- internal/mcp/dispatch_http.go (new): HTTPHandlerDispatcher
implementation with a routeTable[cmdPath]→RouteMapper mapping. Seed
entry: `item create`. Adding more commands is one RouteMapper per
cmdPath plus a routeTable insert.
- internal/server/context.go (new): exported WithCurrentUser /
WithAPITokenAuth / WithTokenWorkspaceID + read-only
CurrentUserFromContext / IsAPITokenFromContext. Lets internal/mcp
synthesize an authenticated request without reaching the
package-private context keys.
- internal/server/middleware_csrf.go: extends the existing "Bearer
token requests skip CSRF" rule to also honor the ctxIsAPIToken
context flag. Same semantic — non-cookie auth means no CSRF risk —
but covers the in-process dispatch path where TokenAuth never sets
the Authorization header. Safe because ctxIsAPIToken can only be
set by trusted in-process code (TokenAuth on the live Bearer path,
or server.WithAPITokenAuth from the dispatcher).
## What's tested
- Unit:
- TestHTTPHandlerDispatcher_RoutesItemCreate — full happy path
with a recordingHandler asserting method/path/body/user-context.
- TestHTTPHandlerDispatcher_UnsupportedToolReturnsErrorResult —
tools not yet in the routeTable produce IsError-flagged results
rather than panicking.
- TestHTTPHandlerDispatcher_NoUserReturnsErrorResult — UserResolver
returning nil produces an IsError, never a nil-deref.
- TestHTTPHandlerDispatcher_HandlerErrorSurfacesAsToolError — 4xx
handler responses come back as IsError MCP results matching
ExecDispatcher's `pad <cmd> failed: <stderr>` format.
- mapItemCreate validation + parseFieldKVP variants.
- Integration: TestHTTPHandlerDispatcher_Integration drives the full
*server.Server (real chi router, real SQLite store, full middleware
chain) with a synthesized OAuth user and asserts the item lands in
the DB.
## Scope discipline
The DoD called for "dispatch item.create end-to-end" — that's the seed
entry. Wiring the remaining ~70 MCP-exposed commands into routeTable
is naturally a follow-up before TASK-950 ships /mcp to real users
(captured as a separate task post-merge).
Audit-log assertion in the integration test is deferred until TASK-960
(B6b) lands the audit log itself.
Parent: PLAN-943.
* fix(mcp): roll status/priority/category/parent into fields JSON per Codex review (round 1)
Codex caught: mapItemCreate placed status / priority / category /
parent at the top level of the JSON body, but handleCreateItem only
reads them after unmarshalling the Fields string from the request. As
written, MCP-driven `item create` would silently drop those flags —
breaking parity with the CLI for almost every realistic call (parent-
linked tasks, priority-set items, status-overridden ideas, etc.).
Mirrored the CLI's behaviour (cmd/pad/main.go ~L2200): build a fields
map from the named flags, overlay the repeatable --field entries on
top, JSON-encode into ItemCreate.Fields. The handler's existing
schema-validation + parent-resolution path now runs unchanged.
Repeatable --field still wins last-write — locked into a new test so
it doesn't drift.
Also rejects --assign / --role with a clear error rather than silently
dropping them. The CLI resolves user-name → user-ID and role-slug →
role-ID via additional API calls before posting; replicating that
pre-resolution belongs in a follow-up that expands the route table for
production use. Failing loudly is better than partial parity.
Tests:
- TestHTTPHandlerDispatcher_RoutesItemCreate now asserts the
status/priority/category/parent values land in fields, not the top
level — guards against the regression directly.
- TestMapItemCreate_ExplicitFieldOverridesNamedFlag locks the
last-write-wins precedence between --status and --field status=...
- TestMapItemCreate_RejectsUnsupportedAssignRole asserts the
defensive error path for the deferred flags.
Parent: PLAN-943.
* fix(mcp): persist source=cli for HTTPHandlerDispatcher calls per Codex review (round 2)
Codex caught: actorFromRequest derives source from the Authorization
header — without one, dispatcher-driven calls would persist
source="web" instead of source="cli", regressing dashboard/standup/
audit attribution vs. ExecDispatcher.
Same pattern as the round-1 CSRF fix: extend actorFromRequest to also
honor the ctxIsAPIToken context flag (which TokenAuth sets on the live
Bearer-auth path and HTTPHandlerDispatcher sets via
server.WithAPITokenAuth on synthesized requests). Both signals mean
"non-cookie authenticated, attribute as CLI/agent traffic".
Integration test now asserts source="cli" on the created item, so any
future regression of this attribution surfaces immediately.
Parent: PLAN-943.
* fix(mcp): normalize collection aliases in HTTPHandlerDispatcher per Codex review (round 3)
Codex caught: CLI's `item create task ...` works because
cmd/pad/main.go's normalizeCollectionSlug maps singular/short forms
("task" → "tasks", "doc" → "docs", etc.) to the canonical slug
before posting. HTTPHandlerDispatcher's mapItemCreate skipped that
step, so the same documented call shape would 404 through the HTTP
transport even though it worked through ExecDispatcher.
Extracted the alias map to internal/collections.NormalizeSlug so the
two transports stay in lockstep without duplication. cmd/pad/main.go's
normalizeCollectionSlug now delegates to it; the in-process
dispatcher calls it from mapItemCreate after pulling the collection
out of input.
TestMapItemCreate_NormalizesCollectionAliases locks every documented
alias plus a passthrough case for custom collections.
Parent: PLAN-943.
* fix(server): WithTokenWorkspaceID actually clears on empty input per Codex review (round 4)
Codex caught: the docstring said "Pass an empty string to clear" but
the implementation early-returned `ctx` unchanged in that case,
leaving any stale ctxTokenWorkspaceID set further up the chain
active. Always overwrite so the contract holds: passing "" produces
a context where tokenWorkspaceID(r) returns "", same as a never-set
context.
Parent: PLAN-943.
|
||
|
|
e05ea07d62 |
fix(docs): use canonical wire path capabilities.experimental.padCmdhelp (#342)
Codex caught the same accuracy issue on pad-web that exists in five
spots in this repo: prose described the handshake location as
"serverCapabilities.experimental.padCmdhelp", but per the MCP spec
the InitializeResult shape is
{ result: { capabilities: { experimental: { ... } } } }
There's no `serverCapabilities` field on the wire — `ServerCapabilities`
is the Go-side struct type name in mcp-go; the JSON tag is
`capabilities`. Anyone copying the path out of our docs to navigate
a real JSON-RPC envelope was getting the wrong key.
Updated to `capabilities.experimental.padCmdhelp` (or the fully
qualified `result.capabilities.experimental.padCmdhelp` where the
JSON-RPC envelope context wasn't otherwise obvious) in:
- README.md — public-facing prose
- CLAUDE.md — agent-facing prose
- internal/mcp/version.go — discovery-surfaces doc comment + the
experimentalCapabilityKey doc comment
- internal/mcp/server.go — comment near WithExperimental
- internal/mcp/meta.go — experimentalCapabilities() doc + the wire
shape example (now wrapped under `result` for accuracy)
- internal/mcp/server_test.go — test docstring + failure message
- cmd/pad/mcp.go — comment near RegisterMeta
The Go type `serverCapabilities` in `internal/server/handlers_capabilities.go`
is unrelated (it's the response shape for `GET /api/v1/server/capabilities`)
and stays as-is.
No code/behaviour changes; pure prose accuracy fix. `make check` clean.
Companion fix to pad-web PR #42, which Codex flagged the same issue on.
|
||
|
|
0cd22f7bad |
feat(mcp): translate hyphenated CLI flag names to snake_case on the MCP surface (TASK-964) (#341)
Cobra's flag names are kebab-case (--due-date), and JSON Schema
property names accept any string, but Anthropic's tool-use convention
— and most LLMs' training data — is snake_case (due_date). Hyphenated
property names trip up agents that auto-normalize to snake_case before
emitting JSON, silently losing every flag with a hyphen.
This commit adds the translation at the registry/dispatch boundary:
- MCPPropertyName(cliName) helper: kebab-case → snake_case (idempotent
for already-snaked or single-word names).
- propertyForArg / propertyForFlag use MCPPropertyName when emitting
the JSON Schema property name.
- BuildCLIArgs reads the input map by translated key, but still emits
the kebab-case CLI flag (--due-date value) on dispatch, so the
CLI's flag definitions stay untouched.
Round-trip contract:
schema: { "due_date": "..." } ← what agents see
input: { "due_date": "..." } ← what agents send
CLI: pad item update REF --due-date ... ← what dispatch runs
Defensive choice over verification:
The task spec called for an interactive verification phase (connect
Claude Desktop / Cursor, ask each to call a tool with a hyphenated
flag, observe whether it round-trips). That requires real agents and
isn't feasible in an autonomous shipping flow. Adding the translation
defensively eliminates the risk regardless of agent behaviour and
aligns with Anthropic's documented snake_case convention. If a future
verification round shows hyphens worked fine for some agents, the
translation is harmless — both forms map to the same schema.
Tests:
- TestBuildTool_HyphenatedFlagsSurfaceAsSnakeCase locks the schema
side: hyphens disappear from JSON property names.
- TestMCPPropertyName_RoundTrip locks the translation rule itself
(idempotent for snake/single-word, hyphens-only).
- TestBuildCLIArgs_HyphenatedFlagAcceptedAsSnakeCase verifies the
full round-trip: agent sends due_date, CLI gets --due-date.
- TestBuildCLIArgs_HyphenatedRepeatableFlagRoundTrips covers the
slice/repeatable path (--add-tag x --add-tag y).
- TestBuildCLIArgs_KebabInputKeyIsIgnored guards against agents that
bypass the schema and send the kebab form — they pass an unknown
property, which is silently dropped (matching today's behaviour for
unknown optional flags).
- Existing TestBuildCLIArgs_BoolPresenceForm updated to assert the
new snake_case input contract.
Parent: PLAN-942.
|
||
|
|
2d98f2a170 |
feat(mcp): advertise cmdhelp_version stability tier in handshake (TASK-963) (#340)
* feat(mcp): advertise cmdhelp_version stability tier in handshake (TASK-963)
External agents (Cursor, Claude Desktop, the future Pad Cloud remote MCP
in PLAN-943) depend on tool names, argument shapes, and resource URIs
being stable across pad releases. Without an explicit contract, any
future surface change breaks consumers silently.
This commit ships the contract on two complementary surfaces:
- serverCapabilities.experimental.padCmdhelp in the initialize handshake
— namespaced map carrying {version, tool_surface_stable}, discoverable
in one round-trip.
- pad://_meta/version static resource — full JSON document with
{pad_version, cmdhelp_version, tool_surface_stable, mcp_protocol_version}
for clients that prefer reading a typed payload.
CmdhelpVersion is pinned at "0.1" — the initial cmdhelp-derived surface
shipped in PLAN-942. Bump the major when tool names / arg shapes /
resource URIs change incompatibly.
Tests:
- TestServer_InitializeHandshake extended to assert the experimental
capability shape on the wire (not just the existence of the field).
- TestBuildMetaPayload_* lock the payload field names + fallback
behaviour.
- TestRegisterMeta_ResourceRoundTrip drives the resource through the
real HandleMessage path so a regression in the dispatcher would
surface as a test failure.
Docs:
- README's MCP section briefly mentions the contract surfaces.
- CLAUDE.md's MCP section gets a stability-contract paragraph + the new
resource URI.
- Public docs at getpad.dev/mcp/local will need a follow-up PR in the
pad-web repo (per CONVE-159) — captured at the end of TASK-963.
Parent: PLAN-942.
* fix(mcp): source MCP protocol version from mcp-go LATEST_PROTOCOL_VERSION per Codex review (round 1)
Codex caught: the local MCPProtocolVersion constant was pinned at
"2024-11-05", but mcp-go@v0.50.0 negotiates "2025-11-25" for clients
that request mcp.LATEST_PROTOCOL_VERSION. The meta resource was
therefore reporting a protocol revision newer than what the server
actually speaks, which defeats the field's purpose for feature
detection (e.g. RFC 8707 Resource Indicators land in 2025-11-25).
Drop the local constant and read mcp.LATEST_PROTOCOL_VERSION at
BuildMetaPayload time so the value tracks whatever revision the
linked library will negotiate. The handshake's serverInfo.version
already does this implicitly via NewMCPServer; making the meta
resource follow the same source-of-truth keeps both surfaces in
lockstep across mcp-go upgrades.
Test updated to assert against mcp.LATEST_PROTOCOL_VERSION instead of
the removed constant, plus an "empty-string" guard in case a future
library refactor unsets the constant.
Parent: PLAN-942.
|
||
|
|
e90ee18907 |
feat(mcp): pad mcp install / uninstall / status (TASK-948) (#338)
* feat(mcp): pad mcp install / uninstall / status (TASK-948)
One-shot config writers for the three MCP-capable client apps:
pad mcp install claude-desktop # ~/.config/Claude/claude_desktop_config.json (linux)
pad mcp install cursor # ~/.cursor/mcp.json
pad mcp install windsurf # ~/.codeium/windsurf/mcp_config.json
pad mcp install --all # all three
pad mcp uninstall cursor # remove
pad mcp status # report install state
Implementation:
- internal/mcp/install.go (new) — Agent registry with per-OS path
resolvers (PathFor takes (home, goos) so tests inject); AddPadEntry
/ RemovePadEntry / HasPadEntry primitives that read-modify-write
JSON, preserving every entry except mcpServers.pad. Installer
façade with Home/GOOS overrides for tests.
- cmd/pad/mcp.go — three new cobra subcommands wired into mcpCmd:
install (no-args = status, --all = batch), uninstall, status.
Binary path resolved via os.Executable().
DOD coverage:
- Existing entries preserved (TestAddPadEntry_PreservesOtherServers
asserts both other mcpServers and unrelated top-level keys survive).
- Idempotent install (binary unchanged → modified=false).
- Update install (binary changed → modified=true).
- Idempotent uninstall (missing file / missing entry → no-op).
- Per-platform path resolution tested for linux + darwin.
- 16 unit tests including edge cases: empty/whitespace files,
malformed JSON rejected (no silent overwrite), case-insensitive
agent aliases.
Live verified end-to-end:
- HOME=/tmp/fakehome pad mcp install cursor → writes valid JSON
- pad mcp status → shows [x] Cursor with command path
- pad mcp uninstall cursor → leaves mcpServers:{} skeleton
Config file perms: 0600 (configs may hold credentials for OTHER
MCP servers; tighten on principle).
Parent: PLAN-942.
* fix(mcp): tighten install argument validation + chmod existing configs (Codex round 1)
Two findings on PR #338:
1. `pad mcp install` had no Args validator so cobra silently accepted
extras: `pad mcp install cursor windsurf` only installed Cursor.
Added cobra.MaximumNArgs(1) plus an explicit guard rejecting
`--all` combined with an agent name (those flows are
mutually exclusive).
2. os.WriteFile(path, data, 0o600) only honors the mode when CREATING
the file. A pre-existing 0644 config kept 0644 after the install,
defeating the security-tightening claim in the comment. Added an
explicit os.Chmod(path, 0o600) after writing; chmod failures are
stderr warnings, not hard errors (the data write already
succeeded; perms hardening is best-effort defense-in-depth).
New test TestAddPadEntry_TightensExistingFilePerms locks the
0600-after-install contract; live verified the cobra guards reject
both error cases with clean messages.
Parent: PLAN-942.
* fix(mcp): tighten perms on idempotent install path too (Codex round 2)
Codex caught: AddPadEntry's no-op early-return (when desired config
matches existing) skipped the chmod step from round 1's fix. So an
already-up-to-date 0644 config retained 0644 after re-running
`pad mcp install`.
Extracted tightenPerms() as a helper called from BOTH paths:
- writeJSONConfig (modified path) — chmod after write
- AddPadEntry's no-op return — chmod even when content is unchanged
Best-effort: chmod failures still emit a warning rather than failing
the install (the user's intent already succeeded; perms tightening is
defense-in-depth, not core functionality).
New test TestAddPadEntry_TightensPermsOnIdempotentNoop locks the
no-op-path contract.
Parent: PLAN-942.
|
||
|
|
ca2fc04a5b |
feat(mcp): static prompts lifted from SKILL.md (TASK-947) (#337)
Four MCP prompts expose pad's high-value multi-step workflows so agents can prompts/get them as user-role system messages: pad_plan — draft + decompose a Plan pad_ideate — brainstorm + capture as items pad_retro — retrospective on a completed Plan pad_onboard — workspace onboarding / codebase scan Implementation: - internal/mcp/prompts.go (new) — RegisterPrompts(srv) + PromptBody accessor; sorted iteration for deterministic prompts/list ordering. - internal/mcp/prompts_data.go (new) — embedded body strings, lifted near-verbatim from skills/pad/SKILL.md "Multi-Step Workflows". - cmd/pad/mcp.go wires RegisterPrompts after the resources path. 7 unit tests including SKILL.md drift contract: - All four prompts registered + reachable via PromptBody - Each body has the standard "# Pad: <workflow>" heading - Unknown prompt name returns error - Lockstep: every prompt body contains its key SKILL.md CLI invocations (catches silent drift if SKILL.md is updated without bumping prompts) - skills/pad/SKILL.md still exists as the source-of-truth (catches rename / removal during refactors) Live smoke: prompts/list returns 4 prompts (with descriptions); prompts/get pad_plan returns 1225 chars of workflow text starting "# Pad: Plan workflow\n\nYou are helping the user...". Naming choice: `pad_plan` (snake_case) over `pad/plan` (slash form) — some MCP clients interpret slashes as namespace paths. Matches the tool-naming convention from TASK-945. Parent: PLAN-942. |
||
|
|
342a564113 |
feat(mcp): read-only resource templates (TASK-946) (#336)
* feat(mcp): read-only resource templates for items / dashboard / collections (TASK-946)
Four MCP resource templates expose pad workspace state to agents
without requiring a tool invocation:
pad://workspace/{ws}/items/{ref} → single item markdown
pad://workspace/{ws}/items → list of items (JSON)
pad://workspace/{ws}/dashboard → project dashboard (JSON)
pad://workspace/{ws}/collections → collections + schemas (JSON)
Why resources, not tools: agents can `resources/read` a URI and
ingest the body directly into context without going through a
tool-call round-trip. Useful for "load TASK-5 then plan" workflows
where the agent shouldn't need to pick a tool.
Implementation:
- internal/mcp/resources.go (new) — RegisterResources installs all
four templates on an MCPServer; ResourceFetcher interface +
ExecResourceFetcher shell-out (separate from Dispatcher because
resource handlers return raw bytes, not CallToolResult).
- parsePadURI extracts (workspace, kind, arg) from pad:// URIs;
defensive guards reject mismatched URIs at each handler.
- rootFlagsToArgs forwards startup --url to every fetched call
(same contract as TASK-945's tool dispatch).
- cmd/pad/mcp.go wires RegisterResources after the tool registry
in mcpServeCmd.
15 new unit tests:
- parsePadURI: all 4 forms + 4 malformed inputs
- each handler: dispatches correct CLI args + MIME type
- readItem rejects mismatched URI (defensive)
- fetch errors propagate as Go errors (so MCP returns JSON-RPC
error rather than empty contents)
- root flag forwarding via the resources path
- ExecResourceFetcher: missing binary, stdout capture, non-zero
exit folds stderr into error
Live verified:
- resources/templates/list returns 4 templates with correct mime
types and uri patterns.
- resources/read pad://workspace/docapp/items/TASK-944 returns
1764 bytes of markdown.
Parent: PLAN-942.
* fix(mcp): compose full item markdown from JSON in resource path (Codex round 1)
Codex flagged: pad://workspace/{ws}/items/{ref} fetched
`pad item show --format markdown` which prints only item.Content
(see cmd/pad/main.go:2562). The resource description promised
"Full markdown content … includes title, fields, body, and links",
so clients reading the URI lost ref/title/metadata/parent and
couldn't reliably identify the item.
Fix scoped to the resource path (rather than changing the CLI's
markdown output, which other callers may parse): readItem fetches
`--format json` and a new formatItemAsMarkdown composes the
document — heading with ref + title, optional parent link, sorted
metadata fields, then the content body.
3 new unit tests + the existing readItem test rewritten:
- Full-shape JSON → exact markdown layout (deterministic via sorted keys)
- Missing fields → heading-only doc, no panic
- Empty `{}` fields → no stray list section
- Invalid JSON → error propagates
Live verified: pad://workspace/docapp/items/TASK-944 now returns
"# TASK-944: <title>\n\n**Parent:** PLAN-942 — ...\n\n- **priority:**
high\n- **status:** done\n\n<body>" — full identification + traversable
parent link, body intact.
Parent: PLAN-942.
|
||
|
|
2e4a815d0c |
feat(mcp): cmdhelp-derived tool registry + shell-out dispatch (TASK-945) (#335)
* feat(mcp): cmdhelp-derived tool registry + shell-out dispatch (TASK-945)
The strategic centerpiece of PLAN-942: walk the cmdhelp Document built
from `pad`'s cobra tree and register every leaf as an MCP tool, with
shell-out dispatch back to the running binary. New pad commands (or
new flags) extend the MCP surface for free — no hand-mapping ~73
commands.
- internal/mcp/registry.go — Register() walks cmdhelp.Document, picks
leaves, applies a curated DefaultExcludes (db ops, auth, init,
agent install/update, server lifecycle, completion, edit, watch,
workspace lifecycle), builds an MCP Tool per leaf with input schema
derived from cmdhelp Arg/Flag types. Snake-case names: "item create"
→ "item_create".
- internal/mcp/dispatch.go — ExecDispatcher shells out to the pad
binary; BuildCLIArgs is a pure function that translates the JSON
args into a CLI invocation (positionals → flags → workspace
injection → --format json default). JSON stdout is surfaced as
StructuredContent for rich client rendering.
- internal/mcp/workspace.go — WorkspaceState (RWMutex-protected) +
pad_set_workspace built-in tool. Empty string clears the session
default; missing arg returns IsError without mutating state.
- cmd/pad/mcp.go — wire registry into `pad mcp serve` startup; build
the cmdhelp Document from cmd.Root(), resolve the running binary
via os.Executable, seed workspace from --workspace flag.
- 23 new unit tests across registry / dispatch / workspace files
(race-detector clean) covering: leaf identification, exclusion
prefix suppression, snake-case naming, pure CLI arg translation
(positionals + bool presence form + repeatable args & flags +
workspace/format injection), exec dispatcher (binary missing,
stdout capture, non-zero exit), workspace state mutation, and
end-to-end pad_set_workspace handler contract.
Live smoke (real binary, real stdio):
- tools/list returns 66 tools — pad_set_workspace + item_create
present, db_backup + mcp_serve correctly excluded.
- tools/call pad_set_workspace updates session state, then
auth_whoami shells out and returns structured JSON.
Parent: PLAN-942.
* fix(mcp): forward --url root flag + drop unwired --stdin (Codex round 1)
Two findings from Codex review of #335:
P1: --url root persistent flag was not forwarded to dispatched
subprocesses. If an MCP client launches `pad --url X mcp serve`,
every tool call ran against the default URL instead of X. Fixed by
adding RootFlags map[string]string to RegistryOptions; cmd/pad/mcp.go
captures urlFlag at startup and threads it through. BuildCLIArgs
now also takes a rootFlags map and injects each entry when not in
input (empty values skipped, agent value wins on collision).
P2: MCP tool schemas exposed `--stdin` flags but ExecDispatcher
never piped the agent's stdin to the subprocess. Calling e.g.
`item_create {stdin: true}` would block on EOF and create empty
content. The `--content` flag covers the same semantic via JSON
args, which IS wired. Hide stdin from the MCP surface (buildTool
filters out flagsHiddenFromMCP) AND drop it defensively in
BuildCLIArgs in case an agent's stale schema cache passes it.
Tests added (4 new + 2 updated):
- BuildCLIArgs: stdin dropped defensively, root flags injected,
empty root flag skipped, agent value wins over root flag.
- buildTool: omits stdin from input schema.
- Dispatch handler: forwards root flags through to CLI args.
Existing TestBuildCLIArgs_BoolPresenceForm rewritten to use
`dry-run` flag (since stdin is now filtered).
Live verified: `pad mcp serve` tools/list shows item_create with
content+10 other flags, no stdin. Round-trip preserved.
Parent: PLAN-942.
|
||
|
|
9905a83134 |
feat(mcp): pad mcp serve skeleton on stdio (TASK-944) (#333)
Stand up internal/mcp + the cobra `pad mcp serve` subcommand. v1 is
handshake-only — the server completes initialize and stays alive over
stdio, advertising tool capability with an empty registry. TASK-945
fills that registry from `pad help --format json`.
- New internal/mcp package wraps mark3labs/mcp-go's stdio transport;
graceful shutdown on EOF / SIGINT / SIGTERM / ctx-cancel.
- New cmd/pad/mcp.go registers `pad mcp` as a top-level cobra group
with the `serve` subcommand wired to internal/mcp.NewServer.
- 4 unit tests: NewServer construction, real initialize round-trip
(asserts serverInfo.name + version), fallback version locked,
graceful shutdown on ctx-cancel.
Live smoke: `echo '<initialize>' | pad mcp serve` returns
`serverInfo:{name:"pad-mcp",version:...}` with `tools:{listChanged:true}`.
cmdhelp emits the new command tree at `pad help mcp serve --format json`.
Parent: PLAN-942.
|
||
|
|
cfda4463e8 |
feat(cmdhelp): tests + golden contract + drift validator (TASK-938) (#332)
* feat(cmdhelp): tests + golden contract + drift validator (TASK-938)
The verification layer that turns cmdhelp v0.1 from "implementation"
into "stable contract." Three categories of tests, all running in
`go test ./...`:
1. Schema validation (cmdhelp.schema.json as CI gate)
- internal/cmdhelp/schema_test.go — synthetic tree's emitted JSON
validates after static walk, after dynamic resolution, and after
a no-workspace fallback.
- cmd/pad/cmdhelp_real_test.go — the REAL pad cobra tree's emitted
JSON validates against the published schema. Future regressions
caught: types outside the closed vocabulary, non-numeric exit_code
keys, flag names violating propertyNames, malformed cmdhelp_version.
2. Drift-prevention contract (spec §6 / §11 Q5)
- internal/cmdhelp/example_validation.go — ValidateExamples walks
every example's `cmd` string, tokenizes with shellSplit, resolves
non-flag tokens against the live cobra tree, and asserts every
--flag exists on the resolved command (or any ancestor for
persistent / inherited flags). Negate-flag form (`--no-cache`)
is recognized via the negation rule from spec §5.3.
- shellSplit handles double/single quotes, backslash escape, and
stops at unquoted pipeline boundaries (|, ;, &, >, <) so the
validator only checks the first command in a pipeline.
- ValidateBoolArity asserts no bool flag appears in valued form
(--flag=value) anywhere in its examples (spec §5.3).
- cmd/pad/cmdhelp_real_test.go runs both validators against the
real pad tree as CI gates.
- Negative tests in internal/cmdhelp/example_validation_test.go
prove the validator catches: typo'd flag (--priorty), unknown
command path, valued-form bool flag.
3. Capabilities form equivalence (spec §8)
- cmd/pad/cmdhelp_real_test.go — both forms (help --capabilities
and --cmdhelp-capabilities fallback) produce byte-identical
output. Side-effect-free guarantee verified by passing garbage
args alongside the fallback flag.
Refactors enabling the tests:
- cmd/pad/main.go: extract newRootCmd() so tests can build the real
cobra tree without running it. main() body shrinks to two lines.
- cmd/pad/main.go: extract handleCmdhelpCapabilitiesFallback() so the
fallback's side-effect-free contract is directly assertable instead
of requiring a subprocess.
Parser improvements driven by real-pad-tree drift findings:
- parseExamplesFromLong: strip same-line `# comment` annotations so
`pad foo --bar # one item's attachments` doesn't pollute Examples.
stripCommentIndex is quote-aware (# inside "..." or '...' is literal).
- main.go (github cmd): the Long had annotations on example lines
separated only by spaces (no `#`), which was malformed input. Fixed
to use `#` separators — caught by the drift validator on first run.
New deps:
- github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 — Go JSON Schema
validator supporting draft 2020-12 (matches the cmdhelp schema's $schema).
New helpers in internal/cmdhelp/:
- FindAndCompileSchema(startDir) walks up to locate
schema/cmdhelp.schema.json and returns a compiled schema. Reusable
by any consumer that wants to validate cmdhelp documents.
End-to-end on real binary:
- pad help --format json → 100 commands, schema-valid.
- All examples in pad's emitted output resolve against the live tree
(zero drift findings).
- pad help --capabilities byte-identical to pad --cmdhelp-capabilities.
- Adding a typo'd flag in any cobra Long block in cmd/pad MUST break
TestRealPadTree_ExampleDriftValidator. Verified by the negative
TestValidateExamples_DetectsTypoFlag.
make check clean. All 53 cmdhelp + cmd/pad tests pass.
Parent: PLAN-930.
* fix(cmdhelp): pass full token stream to cobra.Find per Codex review (round 1)
Codex round 1 caught: ValidateExamples stopped collecting the command
path at the first flag, so an example like
pad --workspace foo item create task --priority high
resolved to root, not `item create`. That meant `--priority` was
checked against root's flag set (where it doesn't exist) — false
positive — AND the validator silently missed any command-path drift
after a leading root flag.
Cobra's own Find walks the full token stream and uses each command's
flag definitions to skip flag/value pairs while matching subcommand
names. Pass tokens[1:] directly to root.Find — let cobra handle the
interleaving correctly.
New test:
- TestValidateExamples_FlagBeforeSubcommandResolvesToCorrectTarget —
flag-before-subcommand resolves to the leaf and accepts leaf flags.
|
||
|
|
76c9d5aae5 |
feat(cmdhelp): parse Examples blocks from cobra Long as fallback (TASK-939) (#331)
The original TASK-939 ask was to migrate every cobra command's
"Examples:" block from Long into the dedicated Example field. Pad has
102 cobra commands; manually migrating each is a hundreds-of-lines
change with high regression risk and zero user-visible improvement
(cobra renders "Examples:" sections in Long identically to the Example
field — the difference is only machine-readability).
Higher-leverage approach: enrich the cmdhelp emitter to fall back to
parsing Long when the Example field is empty. One small, testable change
in internal/cmdhelp unlocks examples in cmdhelp output for every command
that already has an "Examples:" block — without touching any of the 102
command sites. Structural migration becomes optional polish (HT-941).
Implementation:
- internal/cmdhelp/json.go gains parseExamplesFromLong: locate a
stand-alone "Examples:" / "Example:" header, collect indented
invocation lines until blank-then-prose or end. Comment lines (`#`)
are dropped from the block. The header regex is anchored to a line
on its own (`^\s*Examples?:\s*$`) so prose containing the word
"Examples" doesn't trigger the fallback.
- buildCommand() prefers cmd.Example when set; falls back to
parseExamplesFromLong(cmd.Long) when Example is empty. Tests assert
precedence so future migrations to the Example field win cleanly.
- cmd/pad/main.go: completion command gets a dedicated Example field
(one of the few that didn't have an "Examples:" block at all). Demos
the migration pattern HT-941 will sweep across the rest.
Result on the real binary:
pad help --format json → before: 0/100 commands have examples
after: 24/100 commands have examples
pad help item create --format json → 4 examples (vs 0 before)
pad help completion --format json → 4 examples (from Example field)
The remaining ~76 commands genuinely lack an Examples: block in Long
(or are group commands that don't need examples). HT-941 captures the
sweep work needed to get those to 100%.
Tests:
- 7 new tests for parseExamplesFromLong in internal/cmdhelp/json_test.go:
basic block extraction, no-header (Usage: != Examples:), variant
headers (singular/plural, indented), empty/malformed inputs, stops
at unindented prose, drops comment lines.
- 2 new end-to-end tests via Build():
- falls back to Long when Example is empty
- prefers Example field when both set (precedence)
- All 39 existing cmdhelp tests + 16 routing tests still pass.
- make check clean.
Follow-up: HT-941 ("Migrate cobra Long Examples blocks to dedicated
Example fields") captures the structural sweep — broken into per-group
PRs (auth/*, agent/*, server/* etc.) so it can be done incrementally
without blocking PLAN-930.
Parent: PLAN-930.
|
||
|
|
0439c1bf3d |
feat(cmdhelp): --capabilities discovery flag + --cmdhelp-capabilities fallback (TASK-937) (#330)
Implements the cmdhelp v0.1 §8 capability bit so wrappers can detect support without trial and error. - pad help --capabilities → cmdhelp/0.1: text, md, json, llm - pad --cmdhelp-capabilities → same line (spec §8 fallback form) Both forms: - Single line on stdout (terminated by newline only). - Side-effect-free: no logging, no network, no config writes, no auth challenge. Verified by running with no workspace context (cwd /tmp, no auth) — still emits the line and exits 0. - Exit 0 on success. - Format: cmdhelp/<MAJOR>.<MINOR>: <comma-separated formats>. Why both forms: Spec §8 lists `<cmd> help --capabilities` as preferred and `<cmd> --cmdhelp-capabilities` as a fallback for CLIs whose `help` subcommand is overloaded. Pad's `help` is not overloaded, but supporting both forms costs nothing and lets wrappers and harnesses choose whichever convention they prefer — TASK-938 will assert equivalence between them. The fallback is handled in main() before cobra parsing so it really is side-effect-free: it doesn't even reach config.Load() or the detect-workspace path. A simple os.Args scan + early return. Files: - internal/cmdhelp/json.go — new CapabilityLine(formats) helper that produces the spec-format string. Caller passes the format set so different binaries can advertise different surfaces; the helper preserves caller order (spec §8 says order isn't significant). - cmd/pad/help_cmdhelp.go — adds --capabilities to helpCmd; new padCmdhelpFormats constant ["text","md","json","llm"]; short-circuit in RunE before any other logic runs. - cmd/pad/main.go — pre-args scan handles --cmdhelp-capabilities fallback before rootCmd.Execute(). Tests: - TestCapabilityLine_FormatExact — exact string match. - TestCapabilityLine_HonorsCallerOrderAndSet — preserves caller order. - TestHelpCmd_CapabilitiesExactString — exact-byte assertion on the output of `padtest help --capabilities` including the trailing newline. - TestHelpCmd_CapabilitiesShortCircuits — verifies --capabilities wins over --format / --depth / extra args (spec §8 side-effect rule). - All 35 existing cmdhelp tests + 14 routing tests still pass. - make check clean. End-to-end on real binary: pad help --capabilities → "cmdhelp/0.1: text, md, json, llm" exit 0 pad --cmdhelp-capabilities → same cd /tmp && pad help --capabilities → still works, no auth needed pad help item --capabilities --format json --depth 0 → short-circuits Parent: PLAN-930. |
||
|
|
e6fd25322e |
feat(cmdhelp): dynamic enum resolution + workspace context (TASK-936) (#329)
* feat(cmdhelp): dynamic enum resolution + workspace context (TASK-936)
The killer differentiator from the cmdhelp v0.1 spec — splice live
workspace facts into help output so an LLM asking "what collections
exist?" gets the real answer rather than a generic "any string".
Files:
- internal/cmdhelp/dynamic.go (new) — Resolver type with Apply method.
ArgEnumSources / FlagEnumSources map names to enum_source identifiers;
Sources maps enum_source to a fetcher func. Apply walks the Document,
stamps enum_source on matching args/flags, populates Enum from the
fetcher, and sets doc.Context.Workspace. Per-Apply caching keeps each
source func to ≤1 invocation regardless of how many commands need it.
- internal/cmdhelp/json.go — added Options.Resolver; Build calls
Resolver.Apply after the static walk, so callers that inspect Build
output as either pre- or post-resolution still work.
- cmd/pad/help_cmdhelp.go — newDynamicResolver constructs a Resolver
bound to the runtime: workspace from DetectWorkspace, server URL from
config, three sources (collections, roles, members). Returns nil when
no workspace is detected so help still works outside any workspace.
cmdhelpOptions takes target so Binary derives from root.Name() instead
of hardcoded "pad" (preserves test-tree bindings for synthetic roots).
Pad-side bindings (matches `pad item create --help`'s existing context):
arg collection → dynamic:pad collection list
flag role → dynamic:pad role list
flag assign → dynamic:pad workspace members
End-to-end on the real binary (inside docapp workspace):
pad help item create --format json
→ args[0].collection: type=enum, enum=[ideas,conventions,...,roadmap],
enum_source="dynamic:pad collection list"
→ flags.role: enum=[planner,implementer,reviewer]
→ flags.assign: enum=[dave]
→ context.workspace="docapp"
pad help --format md → "## Workspace context\n- workspace: `docapp`"
Outside any workspace (cd /tmp; pad help item create --format json):
→ collection arg: type=string, no enum, no enum_source (graceful fallback)
→ context: null
→ output still validates against schema/cmdhelp.schema.json
Fail-safe behavior:
- newDynamicResolver returns nil on any config/detection error → static doc.
- Per-source fetcher errors are caught inside Apply → enum_source still
announced on the binding arg/flag, but Enum is left empty. The help
command MUST NOT fail because dynamic facts can't be fetched.
- Existing Enum values from alternation/ValidArgs are preserved
(resolver only fills the gap, never overwrites authoritative spec).
Tests:
- 10 dynamic-resolver tests in internal/cmdhelp/dynamic_test.go
covering: arg + flag enum population, context population, per-source
caching across multiple commands, graceful error handling, nil
resolver as no-op, existing-Enum preservation, global flag resolution,
unaffected commands left unchanged, end-to-end via Build().
- All 24 prior cmdhelp tests + 12 routing tests still green.
- make check clean (lint + go test + web build).
Out of scope (deferred):
- --capabilities discovery flag — TASK-937.
- Schema-validate live output in CI — TASK-938.
- Audit pad's existing commands' Examples — TASK-939.
Parent: PLAN-930.
* fix(cmdhelp): scope --role / --assign bindings per-command per Codex review (round 1)
Codex round 1 on PR #329 caught a real semantic bug: globally binding
--role to dynamic:pad role list was wrong because pad has two
unrelated --role flags:
pad workspace invite --role workspace role: owner|editor|viewer
pad item create --role <slug> agent role slug
pad item update --role <slug> agent role slug
Globally announcing agent-role slugs as the values for `pad workspace
invite --role` would mislead consumers (LLMs would suggest "planner"
when "owner" is expected; tab-completion would offer the wrong set).
Fix:
- Resolver gains CommandArgBindings and CommandFlagBindings
(map[path]map[name]source) — scoped to a specific command path.
Per-command bindings win over wildcard ArgEnumSources/FlagEnumSources
when both match.
- Helper methods argSource(path,name) / flagSource(path,name) own the
precedence rule so both args and flags use it consistently.
- newDynamicResolver in cmd/pad keeps `<collection>` as a wildcard
ArgEnumSources (universal — every <collection> in pad means a pad
collection), but moves --role and --assign into CommandFlagBindings
scoped to "item create", "item update", and "item list". `pad
workspace invite --role` is intentionally left without a binding.
- A header comment in newDynamicResolver enumerates every --role /
--assign site in the CLI and which one each binding targets, so a
future reviewer adding a new flag can see the rule at a glance.
End-to-end on the real binary:
pad help item create --format json
→ flags.role: type=enum, enum=[planner,implementer,reviewer], enum_source=...
pad help workspace invite --format json
→ flags.role: type=string (untouched). ✓
New tests:
- TestResolver_Apply_PerCommandBindingScoped — explicitly mirrors the
Codex finding: two commands both have a `role` flag, only the bound
command resolves. workspace-invite-style isolation regression test.
- TestResolver_Apply_PerCommandWinsOverWildcard — precedence: when
both wildcard and per-command match, per-command wins.
- TestResolver_Apply_PerCommandArgBindings — same precedence rule
for positional args.
All 33 cmdhelp tests + 12 routing tests still green; make check clean.
* fix(cmdhelp): bind item list --role to agent roles per Codex review (round 2)
Codex round 2 caught that item list --role was still unbound — I missed
it in round 1's grep because the variable name is `&roleFilter` rather
than `&roleFlag`. Pad has 4 --role flags total:
pad workspace invite --role workspace role (NOT bound)
pad item create --role agent role slug (bound)
pad item update --role agent role slug (bound)
pad item list --role agent role filter (now bound)
Fix: extend CommandFlagBindings["item list"] to include the same
itemRoleAssign map as item create/update, so all three item subcommands
that reference an agent role get the dynamic binding.
Added a `grep` recipe in the comment so a future maintainer adding a
new --role / --assign site can find every existing one in one shot
(both `&roleFlag` and `&roleFilter` style declarations).
End-to-end on real binary (inside docapp workspace):
pad help item list --format json
→ flags.role: enum=[planner,implementer,reviewer], enum_source set ✓
→ flags.assign: enum=[dave], enum_source set ✓
make check clean.
|
||
|
|
5e93abe552 |
feat(cmdhelp): implement --format md emitter (TASK-935) (#328)
Adds internal/cmdhelp/md.go that renders the Document built in TASK-934
as markdown with the predictable section order from cmdhelp v0.1 §6.
Replaces the markdown stub in cmd/pad/help_cmdhelp.go so `pad help
--format md` (and the `--llm` alias) produce real output.
Section order per command (spec §6):
## `binary path`
summary / description (when distinct from summary)
### Synopsis — fenced usage line, reconstructed from args + flags
### Arguments — table with name | type | required | description
### Flags — table with flag | type | default | description
### Stdin — when Stdin.Accepted is true
### Examples — fenced bash blocks, drawn from same canonical
example set as JSON (spec §6 same-source rule)
### Output — text_template + json_schema_ref when populated
### Exit codes — table when ExitCodes is populated
### See also — bullet list of related command paths
Top-level YAML frontmatter:
cmdhelp_version, binary, version, generated (RFC3339, UTC).
Now is overridable via Options.Now for snapshot-test stability.
Top-level structure: `# binary` heading, summary, optional homepage,
optional `## Workspace context` (populated in TASK-936), `## Global flags`
table, then per-command sections sorted by path for determinism.
Synopsis reconstruction uses the structured Args from Build() (rather
than cobra.UseLine) so JSON and MD stay driven by the same parsed data
— the variadic `<ref>...` and alternation enums from TASK-934 carry
through naturally.
Pipes in flag/arg descriptions are escaped (`\|`) so they don't break
markdown table grids.
cmd/pad/help_cmdhelp.go: emitCmdhelpMarkdown stub replaced with a call
into cmdhelp.EmitMarkdown. --depth/--all threaded through MaxDepth
identically to the JSON path.
Tests:
- 15 markdown emitter tests in internal/cmdhelp/md_test.go covering
frontmatter (presence + timestamp injectability), per-command
section order, synopsis reconstruction (incl. variadic + alternation),
global-flag dedup, fenced-bash examples, hidden-thing exclusion,
deterministic ordering, Stdin/Output/ExitCodes/SeeAlso sections,
Workspace context, table-pipe escaping.
- TestHelpCmd_FormatMarkdownStubError replaced by
TestHelpCmd_FormatMarkdownEmits (asserts frontmatter + structural
markers for both md and llm).
- TestHelpCmd_FormatLLMAliasRoutesToMarkdown replaced by
TestHelpCmd_FormatLLMIsAliasForMD (asserts md and llm produce
byte-identical output modulo the timestamp).
End-to-end on the real binary:
- pad help --format md emits valid markdown with all sections.
- pad help --format llm produces byte-identical output (after
timestamp normalization).
- pad help item create --format md scopes correctly.
- make check clean.
Out of scope (deferred):
- Dynamic Workspace context population — TASK-936.
- --capabilities discovery flag — TASK-937.
- Schema-validation + golden-file tests in CI — TASK-938.
- Examples populated for all pad commands (still in cobra Long for now)
— TASK-939.
Parent: PLAN-930.
|
||
|
|
eecc683ab0 |
feat(cmdhelp): implement --format json emitter (TASK-934) (#327)
* feat(cmdhelp): implement --format json emitter (TASK-934) Adds internal/cmdhelp package that walks the cobra command tree and emits a cmdhelp v0.1 Document conforming to schema/cmdhelp.schema.json. Wires it into cmd/pad/help_cmdhelp.go so `pad help --format json` is no longer a stub. Files: - internal/cmdhelp/types.go — Document/Command/Arg/Flag/Stdin/Stdout/ ExitCode/Example structs mirroring the schema. ExitCode implements custom MarshalJSON for the string-or-object union (spec §5.2). - internal/cmdhelp/json.go — Build() walks target's subtree; EmitJSON() serializes to indented JSON. Type mapping covers pflag's full type space, including slice/array→repeatable. Hidden commands and flags filtered. Cobra's auto-installed --help flag suppressed. Zero-default values suppressed to keep output compact. argRE parses positional arg placeholders from cobra Use strings, filtering [flags]/[options]/ [command] cobra conventions. parseExamples splits cmd.Example by newline, drops blanks and # comments. MaxDepth maps to spec §4 semantics: 0 = subcommand list, 1 = + grandchildren, -1 = unlimited. - cmd/pad/help_cmdhelp.go — replaces emitCmdhelpJSON stub with a call into the package; threads --depth and --all through MaxDepth (--all overrides --depth). Verification: - 17 emitter tests in internal/cmdhelp/json_test.go covering envelope, global-flag emission, hidden-thing exclusion, positional arg parsing, pflag type mapping, zero-default suppression, example parsing, description-vs-summary, command-path key shape, MaxDepth semantics, target-subtree scoping, JSON validity, version pattern, ExitCode union marshaling, parseExamples filtering. - 11 cmd/pad routing tests still pass; TestHelpCmd_FormatJSONStubError replaced by TestHelpCmd_FormatJSONEmits which validates the structure. - End-to-end: `pad help --format json` on the real binary emits 100 commands across the full tree; output validates against schema/cmdhelp.schema.json (verified with python jsonschema). - `pad help item --format json` correctly limits output to 29 commands in the item subtree (homepage and other top-level metadata still populated from root). - `pad help --format json --depth 0` correctly emits 15 immediate children of root, no grandchildren. - make check clean (lint + go test + web build). Out of scope (deferred): - Examples: pad's existing commands embed examples in Long rather than using cobra's Example field. The emitter correctly reads Example; TASK-939 will normalize the pad-side commands to populate it. - Dynamic enum injection (workspace-aware enums): TASK-936. - --capabilities discovery flag: TASK-937. - Schema-validation of live output in CI: TASK-938. Parent: PLAN-930. * fix(cmdhelp): handle alternation, variadic, and ValidArgs in Use parser per Codex review (round 1) Codex round 1 on PR #327 flagged that parseArgs missed two real cobra Use-string idioms in pad's command tree: 1. `completion [bash|zsh|fish|powershell]` — alternation in brackets. The old regex only allowed `[a-zA-Z0-9_./-]+` inside brackets, so the `|` made the whole token unmatched and the shell arg disappeared from the emitted JSON. Consumers asking "what does completion take?" got nothing. 2. `item bulk-update [--status X] <ref>...` — variadic ellipsis. Old regex didn't capture trailing `...`, so the arg was emitted but without `repeatable: true`. Consumers couldn't tell that <ref> may be passed multiple times. Fixes: - argRE now allows full-bracket content (`[^<>]+` / `[^\[\]]+`) and captures an optional trailing `...` group. - parseArgs takes *cobra.Command (not just Use string) so it can read cmd.ValidArgs and attach those values as the first arg's enum when set. This covers `Use: "completion [shell]"` + `ValidArgs: [...]` where the allowed values only live on the cobra struct. - Alternation `<a|b|c>` / `[a|b|c]` produces an enum-typed arg with the values as `Enum`. When Use carries no semantic name (only the alternation), the arg name is synthesized as "value". - New validArgName check rejects embedded flag-like fragments such as `[--status X]` and prose with whitespace/punctuation that the broader regex would otherwise capture from idiosyncratic Use strings. - ValidArgs entries strip cobra's tab-separated completion descriptions before becoming enum values. New tests: - TestBuild_VariadicArgsRepeatable — `<ref>...` → repeatable=true. - TestBuild_AlternationProducesEnum — `[bash|zsh|fish|powershell]` → enum with values in source order. - TestBuild_ValidArgsFillsEnumOnNamedArg — Use says `[shell]`, ValidArgs carries the values → enum-typed arg named `shell`. - TestBuild_EmbeddedFlagFragmentsFiltered — `[--status X]` does not leak as a positional arg. Verified on the real binary: - `pad help completion --format json` now emits the shell enum. - `pad help item bulk-update --format json` now marks <ref> repeatable. - `pad help --format json` still validates against the schema (100 cmds). - `make check` clean. |
||
|
|
5e27989ab8 |
feat(attachment): add pad attachment view|show|list CLI surfaces (IDEA-898) (#321)
* feat(attachment): add `pad attachment view|show|list` CLI surfaces (IDEA-898) Agents and CLI users had no first-class way to fetch attachment bytes through the API: the only path to read an `` reference was to read the raw blob out of `~/.pad/attachments/<storage_key>`, which bypasses workspace ACLs, doesn't work on Pad Cloud / remote / Postgres deployments, skips the variant pipeline (TASK-872 / TASK-879 / TASK-880), and breaks when storage moves to S3. Three new subcommands wrap the existing REST endpoints: - `pad attachment view <id> [-o path]` — agent-friendly: with no `-o`, fetches to a fresh OS temp directory using the stored filename and prints just the absolute path on stdout (so `$(pad attachment view <id>)` composes cleanly into shell pipelines). Reuses `download`'s atomic temp-then-rename pattern via a shared helper. - `pad attachment show <id>` — HEAD-based metadata only; surfaces MIME, size, filename, ETag, Last-Modified. - `pad attachment list [--item REF] [--category X] [--attached|--unattached] [--collection ID] [--sort ...] [--limit N] [--offset N]` — workspace list. The `--item REF` flag resolves a TASK-5-style ref to a UUID client-side and passes it to a new `item_id` query param on the list endpoint (server side: AttachmentListFilters.ItemID, ~6 lines in the store + 1 in the handler). Skill update: `skills/pad/SKILL.md` gains a "Working with attachments" subsection plus a CLI Reference entry, both ending in the hard rule that agents must NEVER read directly from `~/.pad/attachments/`. * style(cli): gofmt AttachmentListParams field alignment CI's golangci-lint v2 flagged this with the gofmt formatter (configured with simplify: true in .golangci.yml). The contiguous Sort/Limit/Offset block at the end of the struct needs uniform column alignment — gofmt considers the doc comment above Sort attached to that field rather than a block separator, so the three int/string fields get aligned together. Verified locally with `golangci-lint run --timeout=5m ./...` (v2.11.4 to match CI) — 0 issues. Local make lint only runs `go vet ./...` and `golangci-lint` wasn't installed, which is why this slipped through; filing a separate follow-up to mirror the CI checks in the local workflow. |
||
|
|
f8ed3e10a7 |
fix(search): explicit selection on Enter + numeric go-to (BUG-864, BUG-910) (#320)
* fix(search): require explicit selection on Enter; add bare-number go-to (BUG-864, BUG-910) The command palette had two related issues: - BUG-864: Pressing Enter armed the first search result automatically — the user could close the modal and navigate without ever pressing an arrow key. selectedIdx now starts at -1 and only advances on ArrowDown/ArrowUp. - BUG-910: Typing a bare number (e.g. "843") returned no results because parseItemRef requires PREFIX-NUMBER and FTS doesn't index item_number. Backend (internal/store): - Add parseItemNumber() helper alongside parseItemRef. - In Search(), add a bare-numeric direct-lookup path that mirrors the existing ref-lookup block but without a collection prefix filter. item_number is unique per workspace (idx_items_workspace_number) so this resolves to at most one direct hit, prepended with rank=-1000. Frontend (CommandPalette.svelte): - selectedIdx defaults to -1; reset to -1 (not 0) on modal open and after every search. - Enter on a non-numeric query is a no-op unless the user has arrow-selected. - Numeric queries are a deliberate exception: Enter on a bare-number query flushes the debounce, navigates directly to the matching item, and lets the search palette double as a quick "go to item N" jump. Tests: - TestSearch_BareNumericQueryFindsItemByNumber covers the new path. - TestParseItemNumber covers helper edge cases. * fix(search): exclude direct hits from FTS WHERE to keep pagination correct Codex review (round 1) on PR #320: > Numeric direct hits are appended before the FTS query, but the later > pagination only removes duplicates after SQL LIMIT/OFFSET. If item #2 > also matches FTS for query "2" through its title/content, that > duplicate consumes an FTS slot, so page 1 can return fewer than `limit` > results and later pages can repeat/skip rows. Hoist the direct-hit (ref + numeric) snapshot to before the FTS query is built, then append `AND i.id NOT IN (...)` to both the SELECT and COUNT FTS queries. After a successful count, add refCount back so SearchResponse.Total still reflects the full result set (since FTS itself no longer counts those rows). The flaw also applied to the pre-existing parseItemRef path; this fix covers both. The post-LIMIT dedup loop is now defense-in-depth. New test TestSearch_BareNumericQueryDedupsAgainstFTS guards the case: an item whose title/content literally contains its own item_number (so it matches both the direct lookup and FTS) appears exactly once in Results and Total counts it exactly once. * fix(search): paginate direct hits properly across workspaces Codex review (round 2) on PR #320: > P1: Bare numeric direct hits break pagination in global search. > item_number is only unique per workspace, so q=1 with WorkspaceIDs > spanning N workspaces returns N direct hits — all appended without > being sliced to Limit. limit=1 with three workspaces each having #1 > returns three results on page 0, and offset=1 drops all direct hits > then returns FTS rows instead of the second direct hit. The same flaw applied to the pre-existing parseItemRef path: the global search "TASK-5" can match TASK-5 in multiple workspaces. Fix: - Add deterministic ORDER BY i.workspace_id, i.id to both ref and bare- numeric direct-hit lookups so pagination is stable across pages. - Replace the offset==0/offset>0 branching pagination with a uniform slice: directStart = min(Offset, refCount); directEnd = min(Offset+Limit, refCount); results = results[directStart:directEnd]; ftsLimit = Limit - directConsumed; ftsOffset = max(Offset - refCount, 0). This honours (offset, limit) whether direct hits, FTS, or both fill the page. Total stays correct because the FTS count was already excluding direct hits (round-1 fix) and we add refCount back unconditionally. New test TestSearch_BareNumericQueryPaginatesAcrossWorkspaces creates three workspaces each with item #1 and verifies that limit=1 with offsets 0/1/2 returns three different direct hits in stable order, and limit=10 returns all three. * chore: gofmt — column alignment in struct field declarations CI Go (SQLite) lint failed on two files: - internal/store/store_test.go (TestParseItemNumber, this PR's new test) — unaligned column widths and inconsistent comment spacing. - internal/config/config.go (drive-by) — pre-existing alignment regression in the Config struct that snuck in via an earlier landed PR; included here because it blocks merge. No semantic changes — `gofmt -w` only. |
||
|
|
10309fc599 |
fix(config): read PUBLIC_URL for emailed link generation (BUG-899) (#318)
* fix(config): read PUBLIC_URL for emailed link generation (BUG-899) The Pad Cloud deployment binds pad to 0.0.0.0 (Dockerfile, k8s configmap, pad-cloud's docker-compose) and never set PAD_URL on the pad service, so cfg.BaseURL() fell through to "http://0.0.0.0:7777" — that string ended up in password-reset (and invite + share-link + admin-invitation) emails and was unreachable to recipients. Adds a PUBLIC_URL env var read by the server only (does not flip CLI to remote mode the way PAD_URL does — PUBLIC_URL is a generic env var name commonly set in unrelated deployment contexts). Stored in a separate Config.PublicURL field consulted by BaseURL() as a fallback after URL. Resolution order in BaseURL(): PAD_URL > PUBLIC_URL > host:port. Also logs a WARN at server startup if the resolved base URL has an unspecified bind-all host (0.0.0.0, ::, [::]) — a backstop that would have caught BUG-899 the first time email went out. Tests cover the precedence ladder, mode-not-flipping, PAD_URL-beats- PUBLIC_URL, and the BUG-899 repro shape (Host=0.0.0.0 with no URL set yields the broken http://0.0.0.0 URL). Companion change in pad-cloud/docker-compose.yml passes PUBLIC_URL through to the pad service so the Cloud deployment stops shipping broken email links. Parent: BUG-899 (TASK-908). * fix(config): keep PUBLIC_URL out of IsConfigured() per Codex review (round 2) PUBLIC_URL was setting LoadedFromEnv = true, which IsConfigured() consults to decide whether the CLI has explicit configuration. A generic PUBLIC_URL in the environment (very common name) would have made any host appear "configured" to the CLI and skipped the not-configured / setup branch — the exact footgun the separate-field design was supposed to avoid. PUBLIC_URL is purely a server-side fact; LoadedFromEnv is purely a CLI affordance. Stop conflating them. Adds a focused regression test pinning the IsConfigured() invariant. * fix(config): split PublicLinkBaseURL from BaseURL per Codex review (round 3) Round 2's BaseURL fall-through to PublicURL leaked PUBLIC_URL into ~20 CLI-client call sites (cli.NewClientFromURL(cfg.BaseURL()) patterns across cmd/pad/main.go, init.go, server_info.go, configure.go) — same footgun the separate-field design was meant to avoid: a developer with a host-level PUBLIC_URL set for unrelated reasons would have their CLI silently route requests to that URL instead of the local server. Restore BaseURL() to its original CLI-only contract (URL > host:port). Add PublicLinkBaseURL() with the URL > PublicURL > host:port ladder that's used at exactly the two server-side call sites that build emailed-link targets: - cmd/pad/main.go:279 srv.SetBaseURL(cfg.PublicLinkBaseURL()) - cmd/pad/main.go:464 email.NewSender(..., cfg.PublicLinkBaseURL()) Tests pin both contracts: BaseURL() ignores PublicURL even when set; PublicLinkBaseURL() honors the precedence ladder. PAD_URL still wins in both, preserving back-compat. * fix(config): drop public_url toml tag to prevent CLI persistence per Codex review (round 4) Round 3 left PublicURL serializable to ~/.pad/config.toml via toml: "public_url". A CLI user who runs `pad init` or `pad configure` on a host where PUBLIC_URL is set for unrelated reasons would end up with that URL persisted into their config file, surviving any later unset of the env var and contaminating server-side emailed link generation indefinitely (server reads ~/.pad/config.toml on the next boot). Switch the field to toml:"-". PUBLIC_URL is a deployment-time fact (env var / docker-compose / k8s); operators who want a config-file equivalent already have `url` (the PAD_URL path), which serializes properly. Adds a regression test pinning that Save() never writes PublicURL to the file. |