fix: require a configured audience for agent hook consumers - #27497
fix: require a configured audience for agent hook consumers#27497ibetitsmike wants to merge 86 commits into
Conversation
… dispatch deadline
…ate support Add generic chat state and query capabilities used by lifecycle hook integration: - chatstate: EditMessage accepts caller-provided suffix messages that insert after the replacement in the same transaction, transitions can carry a typed error kind, and FailIdle moves an idle chat to the error state. - database: UpdateChatMessageContentByID rewrites a message's content while preserving the search_tsv backfill marker, and InsertChat accepts an optional caller-provided ID.
Allow FinishError from a waiting chat instead of adding a separate transition. Callers encode the error payload, matching the existing FinishError contract.
…hine Add Tx.UpdateMessageContent so content rewrites happen inside a ChatMachine.Update transaction, where the revision trigger stamps the allocated snapshot version, and document the constraint on the query.
UpdateChatMessageContentByID now filters by chat_id and deleted, and returns the affected row count so Tx.UpdateMessageContent fails on stale, deleted, or cross-chat message IDs instead of silently rewriting another chat's history.
Dispatch lifecycle events from chatd to a configured consumer and apply the responses, gated by the agent-lifecycle-hooks experiment: - Events: session_start, user_prompt_submit, pre_tool_use, post_tool_use, pre_compact, post_compact, and stop. - user_prompt_submit dispatches once per submission at admission and folds its effects into the stored prompt as typed message parts: user parts, then model-only hook context, then a user-visible hook notice. Hook context is stripped from client-facing conversions and hook notices are excluded from model prompts. - pre_tool_use allow can override tool input; deny produces a synthetic denied tool result carrying returned model context. - Dispatch is fail-closed: failures reject the triggering request or move the chat to the error state in the same transaction as the affected step. - Successful pre_tool_use decisions are cached in process memory so same-process recovery can reuse them; correctness never depends on the cache. - Add chat-hook-url, chat-hook-secret, chat-hook-timeout, and chat-hook-enabled deployment options with startup validation.
Hook dispatch is disabled without a URL, so a deployment that sets CODER_CHAT_HOOK_TIMEOUT without using hooks must not fail startup validation.
…ures Follows the chatstate fold of FailIdle into FinishError. The handler gates on the waiting status so a dispatch failure for a queued send never parks a running chat.
…alls Hook effects such as pre_tool_use model context can persist rows between an assistant tool call and its result rows. Hoist matching result rows found before the next assistant message back next to the call so the prompt does not inject a synthetic interrupted result ahead of the real one.
The agenthooks SDK dropped Request.Decode; tests now unmarshal the typed payload with a small generic helper. Also corrects the hook secret comment: the SDK, not go-jose, enforces the 32-byte minimum.
Remove the process-local hook decision cache and the streamed-step preflight. Every non-provider-executed tool call now dispatches pre_tool_use at execution time and is validated from that response; Coder never reuses an earlier decision on the consumer's behalf.
…obbering newer server state
acceptServerChatStatus armed a resync that applied the currently cached chatRecord.status immediately, so a failed send or edit could replace a live websocket "running" with a stale REST "waiting" and make shouldApplyMessagePart drop assistant parts. The resync now waits for the chat query's dataUpdatedAt to advance past the value captured when it was armed. Object identity does not work here because TanStack Query structural sharing preserves the chatRecord reference when a refetch returns value-equal data, which would leave the resync armed forever and never apply an unchanged status. Also covers the cross-chat send guard with an interaction story and drops a store test that restated the guard instead of exercising it.
A status or error event arriving after acceptServerChatStatus armed the resync but before the invalidated chat query resolved was overwritten by the older REST status, which could strand the turn and make shouldApplyMessagePart drop the retry's assistant deltas. The resync now captures the server status version when armed and skips the overwrite when the websocket advanced it in the meantime.
The send and edit failure paths armed a resync from the render that started the request, so a rejection arriving after the user navigated away captured the previous chat's dataUpdatedAt. The newly active chat's higher cached timestamp then satisfied the freshness check at once, overwriting a websocket-delivered status and clearing the websocket-authoritative guard. acceptServerChatStatus now ignores calls whose chat is no longer the active one, which covers both failure paths at their single shared entry point.
Docs previewCheck off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here. |
The SDK handler derived the expected audience from the incoming request, so a caller controlling the request URI, the Host header, or forwarding headers could satisfy the audience check with a token minted for a different listener. NewHTTPHandler now takes the audience Coder is configured to dispatch to and compares the aud claim against it, and a handler built without an audience rejects every request. WithTrustForwardedHeaders and the request-derived audience path are removed with it.
…one lock The remembered-decision lookup, the policy decision, and the store each took the mutex separately, so concurrent duplicate deliveries of the same tool_use_id could both miss the cache, both decide, and overwrite each other's entry. decidePreToolUse now performs all three under one lock.
Consumers now compare the aud claim against a configured audience instead of one derived from the request, so the forwarded-header guidance no longer applies and the reference consumer needs --audience.
bd64647 to
c4820d9
Compare
|
@codex review |
|
Codex Review: Didn't find any major issues. Nice work! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
00e0f1a to
dc86e9b
Compare
|
Closing: this change is now folded into the base PRs of the stack, so it no longer needs a separate PR. The three consumer SDK and reference server files moved into #27401, and the
|
Problem
agenthooks.NewHTTPHandlerderived the expected audience from the incoming request (URL,Host, optionallyX-Forwarded-*). Every input to that derivation is caller-controlled, so a party holding a hook token minted for a different listener could satisfy the audience check by sending an absolute-form request URI. The audience claim then constrained nothing.The reference consumer also had a dedup race: the remembered-decision lookup, the policy decision, and the store each took the mutex separately, so concurrent duplicate deliveries of the same
tool_use_idcould both miss the cache and overwrite each other.Change
NewHTTPHandler(secret, expectedAudience, hooks, opts...)compares theaudclaim against a configured value. A handler built with an empty audience rejects every request, so an unset value cannot become a bypass.WithTrustForwardedHeaders,requestAudience, and the forwarded-header parsing that only existed to support the derivation.scripts/agenthooks-servergains a required--audience/CODER_AGENTHOOKS_AUDIENCE. The bind address is not a usable audience: it may be a wildcard or ephemeral port, and behind a proxy the signed audience is the proxy URL.decidePreToolUseresolves lookup, decision, and store under one lock.codersdk/x/is experimental and has no non-test callers ofNewHTTPHandler, so the signature break is contained.Tests
TestHTTPHandlerRejectsRequestDerivedAudiencedrives the handler with an absolute-form request that poisonsURL.Scheme,URL.Host, andHostat once. Verified red against the old derivation (200) and green with the fix (400).TestHTTPHandlerWithoutAudienceRejectsEveryRequestpins the fail-closed empty case.testAudiencewhile still posting toserver.URL, which is exactly the decoupling this change introduces. The four end-to-end tests incoderd/exp_chats_hooks_test.gousehttptest.NewUnstartedServerso the handler can be configured with the real listener address that Coder signs.go test ./codersdk/x/agenthooks/... ./coderd/x/agenthooks/... -race, thecoderdhook suite,make lint, andpnpm run lint-docspass.