feat: publish per-org base plugin with embedded hooks credentials#2395
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 14b9dee The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
Collaborator
🚀 Preview Environment (PR #2395)Preview URL: https://pr-2395.dev.getgram.ai
Gram Preview Bot |
caadbdc to
855d10b
Compare
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>
This reverts commit 106b046.
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
There was a problem hiding this comment.
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.
…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>
…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.
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.
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>
chase-crumbaugh
approved these changes
Apr 30, 2026
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>
chase-crumbaugh
approved these changes
Apr 30, 2026
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_KEYandGRAM_PROJECT_SLUGenv 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
baseplugin 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 abase/andbase-cursor/directory per publish, eachcontaining
.{platform}-plugin/plugin.json,hooks.json(registers thescript against every Gram-supported event), and
hook.sh(bash POST withthe bearer token embedded). Base plugin is listed first in each
marketplace.json; README adds a "Required" callout and per-platformteam-admin
requiredconfig blocks.impl.go:PublishPluginsnow mints two API keys per publish — oneconsumer-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-...vsplugins-hooks-...) so admins can tell them apart inthe dashboard.
Dashboard (
client/dashboard/src/pages/hooks/HooksSetupDialog.tsx):PublishedRepoPanelat the top of the Setup Hooks dialog. Rendersonly 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:116to the sameSecurity(ByKey, ProjectSlug, Scope("hooks"))block as Cursor, drophasOptionalPluginAuthand the Redis-session fallback inrecordHook, andbufferHookbecomes deletable. Left a comment inclaude_hooks.go:185-196flagging that follow-up.Test plan
server/internal/plugins/..., including 3new (
_EmitsBasePlugin,_BaseHookScriptContainsAPIKey,_BaseListedFirstInMarketplace) and 2 updated for the two-key behavior(
_CreatesAPIKeyWithCorrectScope,_RePublishCreatesAdditionalKey)automated coverage; needs manual bench test
marketplace UI
HooksSetupDialogfor anorg with
GetPublishStatus.connected = true