Skip to content

feat: publish per-org base plugin with embedded hooks credentials#2395

Merged
bradcypert merged 23 commits into
mainfrom
bradcypert/plugins-hooks-base
Apr 30, 2026
Merged

feat: publish per-org base plugin with embedded hooks credentials#2395
bradcypert merged 23 commits into
mainfrom
bradcypert/plugins-hooks-base

Conversation

@bradcypert
Copy link
Copy Markdown
Contributor

@bradcypert bradcypert commented Apr 23, 2026

Why

Teams installing Gram-published plugins should get observability automatically.

Today, half the value of Gram (visibility into what AI tools are doing) is gated behind a fragile manual setup that team admins could accidentally skip — Cursor users have to hand-craft a SessionStart hook with GRAM_API_KEY and GRAM_PROJECT_SLUG env vars; Claude users go through a separate public marketplace plugin that requires its own credential discovery.

This PR makes observability default-on for any team using the GitHub-sync marketplace by shipping a per-org base plugin alongside the feature plugin seach org publishes. The base plugin's hook script bakes in a hooks-scoped API key, so installing the plugin is the only step required.

It also sidesteps the multi-plugin double-counting problem at the architectural level: hooks live in exactly one plugin per org, regardless of how many feature plugins a team member installs. No runtime dedup, no idempotency keys, no race conditions.

What changed

Server (server/internal/plugins/):

  • generate.go: emit a base/ and base-cursor/ directory per publish, each
    containing .{platform}-plugin/plugin.json, hooks.json (registers the
    script against every Gram-supported event), and hook.sh (bash POST with
    the bearer token embedded). Base plugin is listed first in each
    marketplace.json; README adds a "Required" callout and per-platform
    team-admin required config blocks.
  • impl.go: PublishPlugins now mints two API keys per publish — one
    consumer-scoped (MCP servers, existing) and one hooks-scoped (base plugin
    hook script, new) — and persists both atomically in the same transaction
    along with the github connection record. Key names distinguish the two
    (plugins-mcp-... vs plugins-hooks-...) so admins can tell them apart in
    the dashboard.

Dashboard (client/dashboard/src/pages/hooks/HooksSetupDialog.tsx):

  • Adds a PublishedRepoPanel at the top of the Setup Hooks dialog. Renders
    only when the org has a connected GitHub publish, pointing users at the
    simpler base-plugin install path with a link to their published repo. The
    manual setup sections below remain unchanged for orgs not using GitHub sync.

Migration path forward: once telemetry shows OTEL-only Claude traffic has dropped, flip design.go:116 to the same Security(ByKey, ProjectSlug, Scope("hooks")) block as Cursor, drop hasOptionalPluginAuth and the Redis-session fallback in recordHook, and bufferHook becomes deletable. Left a comment in claude_hooks.go:185-196 flagging that follow-up.

Test plan

  • All plugin tests pass: 14 in server/internal/plugins/..., including 3
    new (_EmitsBasePlugin, _BaseHookScriptContainsAPIKey,
    _BaseListedFirstInMarketplace) and 2 updated for the two-key behavior
    (_CreatesAPIKeyWithCorrectScope, _RePublishCreatesAdditionalKey)
  • Dashboard typecheck + lint clean
  • Verify hook.sh actually fires from a real Claude Code session — no
    automated coverage; needs manual bench test
  • Verify hook.sh fires from a real Cursor session — same
  • Verify the base plugin appears at the top of the Claude/Cursor admin
    marketplace UI
  • Verify the published repo URL surfaces in HooksSetupDialog for an
    org with GetPublishStatus.connected = true
image

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment Apr 30, 2026 8:54pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 23, 2026

🦋 Changeset detected

Latest commit: 14b9dee

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
server Minor
dashboard Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added the preview Spawn a preview environment label Apr 23, 2026
Base automatically changed from bradcypert/plugins-github-publishing to main April 24, 2026 15:21
@speakeasybot
Copy link
Copy Markdown
Collaborator

speakeasybot commented Apr 24, 2026

🚀 Preview Environment (PR #2395)

Preview URL: https://pr-2395.dev.getgram.ai

Component Status Details Updated (UTC)
✅ Database Ready Existing database reused 2026-04-30 20:59:28.
✅ Images Available Container images ready 2026-04-30 20:59:13.

Gram Preview Bot

Two production-breaking bugs in the per-org base plugin's hook script:

- The endpoint was /hooks/<platform>; the actual Goa-mounted routes
  are /rpc/hooks.claude and /rpc/hooks.cursor, so every install would
  have silently 404'd.
- hooks.cursor's design declares Security(ByKey, ProjectSlug, ...)
  and a Gram-Project header, but the script only sent Authorization.
  Cursor would have 401'd even after the path fix. Claude has no
  Security() block on its endpoint, so its script is unchanged.

Threads ProjectSlug through GenerateConfig and emits the header for
the cursor variant only. Three regression tests pin both behaviors.

Also fills exhaustruct's UserConfig: nil on the base plugin.json.

The reviewer's third concern (hook.sh executable bit) is already
handled — server/internal/thirdparty/github/repo.go:170 sets mode
100755 on any path ending in .sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three of the review's findings were real and fixable in this commit:

P1 #2: Cursor's hook endpoint reads Gram-Key, not Authorization (per
server/gen/http/hooks/server/encode_decode.go:261). The script
previously sent only Authorization, so Cursor would have 401'd. Also
removes the (Claude-only) Authorization header since Claude's design
declares no Security() block; sending it was misleading.

P2 #4: Cursor hook event names are not lowercased Claude events.
Splits BaseHookEvents into ClaudeBaseHookEvents and the actual
Cursor-native list (beforeSubmitPrompt, afterAgentResponse,
beforeMCPExecution, etc.) per server/design/hooks/design.go:58.
Drops the now-dead cursorHookEventName helper.

P2 #6: Hardcoded "base" / "base-cursor" directory names collide with
user plugins that legitimately use slug "base". Per the RFC, namespace
the base plugin per-org as <org-slug>-base / <org-slug>-base-cursor.
Exports ClaudeBaseSlug / CursorBaseSlug so tests can predict paths.

P1 #1 (wrong endpoint path) was already fixed in 2b71bcf. P1 #3
(Claude's embedded key was inert) is partially handled here — the
auth header is no longer emitted, so the script is honest about what
it sends — but the deeper architecture (Claude needs OTEL setup to
establish session metadata) is out of scope. P2 #5 (extra
hooks-scoped key vs reuse consumer key) is a design call surfaced
separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements review feedback Option B: extend Claude's design to mirror
Cursor's auth pattern so the base plugin can attribute hooks to an
org/project on its own, instead of relying on the OTEL logs endpoint
to seed Redis session metadata.

- design.go: Claude method gains Security(ByKey, ProjectSlug, "hooks"),
  ByKeyPayload/ProjectPayload, ByKeyHeader/ProjectHeader. Goa rename
  ClaudeHookPayload -> ClaudePayload propagated to all callers.
- claude_hooks.go: handler now reads authCtx and synthesizes
  SessionMetadata directly from it (preferring Redis-cached metadata
  when present so OTEL-supplied tags like UserEmail keep flowing).
- The Redis buffer-and-flush path is now dead — auth is mandatory, so
  every request has org/project on arrival. bufferHook removed and
  three obsolete tests deleted; flushPendingHooks kept as a no-op for
  any straggler keys from before this change.
- generate.go: hook script emits Gram-Key + Gram-Project for both
  platforms now that Claude requires them too.

BREAKING: existing unauthenticated Claude hook traffic (the
non-plugin OTEL flow that relied on Redis session lookup) will now
401. Migration path is plugin install OR adding the headers to the
out-of-band Claude config that posts to /rpc/hooks.claude.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bradcypert and others added 2 commits April 27, 2026 11:29
Replaces the reverted hard-cutover approach with a side-by-side flow:

- design.go: Claude method declares Gram-Key + Gram-Project as optional
  payload headers (no Security() block, so they're not required). Goa
  rename ClaudeHookPayload -> ClaudePayload propagated to call sites.
- claude_hooks.go: when both headers are present, the handler validates
  via s.auth.Authorize and uses the resulting auth context for org and
  project. When absent, it falls back to the existing Redis-session
  lookup (and bufferHook on miss) so the OTEL flow keeps working
  unchanged. Bad explicit auth returns 401 — we don't silently fall
  back to OTEL when a request tried to authenticate and got it wrong.
- recordHook prefers the auth context when set and only enriches with
  Redis-cached tags (UserEmail, ServiceName, ClaudeOrgID) when present.
- generate.go: hook script always emits Gram-Key + Gram-Project so
  plugin installs work; Claude requests without the headers (existing
  OTEL traffic) still reach the handler and use the fallback path.

Once all customers move to plugin-based attribution, switch Claude's
design to the same Security() block as Cursor and remove the fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The recently-added plugin attribution path in recordHook persists
hooks directly when an auth context is present, so the existing
buffer tests (which inherit a populated auth context from the test
setup) skip bufferHook entirely. Added an otelOnlyCtx helper that
clears the auth context to simulate the unauthenticated OTEL flow
and applied it to the three buffer-exercising tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the per-org base plugin in the UI so it's no longer hidden
inside the published GitHub repo.

- design.go: new downloadBasePlugin(platform) endpoint, session +
  project-slug auth, returns a flat ZIP installable via
  `claude --plugin-dir` or Cursor's import flow.
- impl.go: handler mints a fresh hooks-scoped key per download
  (named "plugins-hooks-download-..." to distinguish from the
  publish-bundled "plugins-hooks-..." keys in the API Keys page),
  persists it transactionally with an audit log entry, then renders
  the base plugin files. No GitHub connection required.
- generate.go: refactored generateClaude/CursorBasePlugin to delegate
  to ...InDir helpers; added Flat variants and a top-level
  GenerateBasePluginPackage(cfg, platform) used by the handler.
- Plugins.tsx: new "Observability hooks" page section with a single
  Download Base Plugin dropdown (Claude / Cursor) matching the
  pattern on PluginDetail. Status copy reflects whether the plugin
  is also shipping via the published marketplace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oks-base

# Conflicts:
#	client/sdk/.speakeasy/gen.lock
#	server/gen/hooks/service.go
#	server/gen/http/openapi3.json
#	server/internal/hooks/claude_hooks.go
#	server/internal/plugins/generate.go
#	server/internal/plugins/impl_test.go
@bradcypert bradcypert marked this pull request as ready for review April 28, 2026 18:15
@bradcypert bradcypert requested review from a team as code owners April 28, 2026 18:15
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

devin-ai-integration[bot]

This comment was marked as resolved.

…dex README section

- Add PostToolUseFailure to ClaudeBaseHookEvents so Claude actually invokes
  hook.sh for tool failures; otherwise the handler in claude_hooks.go never
  receives the event and failure telemetry is silently dropped.
- Restore the ### Codex installation section in the generated README that
  was lost in the base-plugin refactor; Codex packages and marketplace.json
  are still generated, so users need install instructions for them.
- Add regression tests for both: the registered events list, the rendered
  hooks.json, and the README's Codex section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

Comment thread server/internal/hooks/claude_hooks.go Outdated
Comment thread server/internal/hooks/claude_hooks.go
Comment thread server/internal/hooks/claude_hooks_test.go
Comment thread server/internal/plugins/generate.go Outdated
Comment thread server/internal/plugins/generate.go Outdated
…in response

Without ContentType("application/zip") on Response(StatusOK, ...) the generated OpenAPI
spec advertised application/json for the ZIP download, so SDK clients expected JSON
instead of a binary stream. Mirrors downloadPluginPackage.
devin-ai-integration[bot]

This comment was marked as resolved.

Renames the per-org observability plugin throughout — Goa method
(downloadBasePlugin → downloadObservabilityPlugin), Go identifiers
(ClaudeBaseSlug, ClaudeBaseHookEvents, generateClaudeBasePlugin*,
GenerateBasePluginPackage, etc.), plugin slug suffix (-base → -observability),
download filename, and dashboard UI copy. "Base" was opaque to readers; the
plugin's purpose is observability so the name now describes what it does.

The slug change is a breaking surface, but no one has installed the prior
slug yet — this is still an in-PR feature.

Includes regenerated Goa server, OpenAPI specs, and SDK.
devin-ai-integration[bot]

This comment was marked as resolved.

handleObservabilityDownload had try/finally with no catch, so a thrown
authFetch / blob / URL error reset the button but gave the user no
feedback. Add a catch that toasts the error and logs to console.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
recordHook's plugin branch persisted hooks synchronously using auth-derived
org/project, leaving user_email empty whenever OTEL Logs hadn't seeded Redis
yet. Claude hook payloads don't carry user.email (unlike Cursor's), so the
only source of user identity is the OTEL Logs sidechannel — bypassing the
buffer meant accepting permanently-empty user_email on those rows.

Drop the plugin branch and route both flows through the existing buffered
path. flushPendingHooks attributes them once /rpc/hooks.otel.logs lands.
handlePreToolUse's plugin branch is unchanged — synchronous shadow-MCP
guard enforcement is the actual value of plugin auth and stays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 15 additional findings in Devin Review.

Open in Devin Review

Comment thread server/internal/plugins/generate.go
…oks-base

# Conflicts:
#	client/sdk/.speakeasy/gen.lock
#	server/gen/http/openapi3.json
#	server/internal/hooks/claude_hooks.go
#	server/internal/hooks/session_capture.go
@bradcypert bradcypert merged commit 02712dc into main Apr 30, 2026
46 of 48 checks passed
@bradcypert bradcypert deleted the bradcypert/plugins-hooks-base branch April 30, 2026 21:30
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 30, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

preview Spawn a preview environment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants