fix(webhook): resolve a canonical turn scope for webhook agent actions#357
Merged
anconina merged 4 commits intoJul 26, 2026
Merged
Conversation
A webhook agent action dispatches straight to the executor, bypassing the orchestrator inbound pipeline that resolves turn identity for real channels. The executor requires a canonical turn scope and fail-closes without one, so every webhook-triggered turn aborted at step:"context-authority" before the agent ran. Resolve the turn identity in the webhook handler, as the gateway, cron, heartbeat and notification turn initiators already do, and thread its turn scope into the request context. The resolved identity is the single authority for the turn: its display session key and turn scope describe the same conversation, so the session view and the memory/context authority cannot diverge. The principal is bound to the operator-configured mapping (hashed for delimiter safety) and the rendered session key is JSON-encoded into the conversation identity, so a crafted payload can neither forge nor widen a conversation's authority even though the template interpolates request data. Per-subject conversation isolation is preserved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A turn that fail-closes before it records a session never reaches the session index, so `obs.explain` cannot distinguish a dead trigger from a mistyped reference. The verdict offered only "typo, expired, or older than the horizon", which sends an operator to re-check the reference while a real failure goes unexamined — the live cost when a whole webhook-driven pipeline was down and explain reported nothing but a missing ref. Name the pre-execution-abort possibility in the detail and add a next step that routes to the evidence such an abort DOES leave: the ERROR carrying its step and errorKind in the daemon log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… resolved
A webhook turn's delivery origin and turn scope are one authority downstream.
BackgroundTaskOriginSchema rejects the pair when they disagree ("delivery origin
must agree with the resolved turn authority"), which gates background-task
promotion and task extraction; the capability lease principal rejects it for a
jailed session.spawn; and the autonomy wiring drops the durable principal. A turn
built with a hand-made origin therefore starts and then fail-closes at each of
those seams.
Assert the observed context's origin agrees with its scope field by field AND
that the pair parses through the schema the background-task manager uses, so the
agreement is pinned where the wiring builds it rather than in a helper.
…ts turn The resolved turn identity described the conversation on a different channel than the delivery origin built beside it: the endpoint carried the control-plane origin kind and a JSON-wrapped conversation id, while the origin carried "webhook" and the rendered key. Every consumer that pairs the two rejects that turn — background task promotion and task extraction (BackgroundTaskOriginSchema), the capability lease principal for a jailed session.spawn, and the durable principal in the autonomy wiring — so a webhook turn cleared the executor precondition and then fail-closed at each of those seams. Name the webhook surface as its own origin kind and use the rendered session key as the endpoint's conversation id, then derive the delivery origin FROM the resolved endpoint, as the cron and durable-resume initiators do. The JSON wrapper is unnecessary for authority: the conversation reference digests each field length-delimited, so payload data cannot forge a field boundary — a rendered key carrying the session-key separator resolves to its own conversation. The endpoint now reads "webhook" everywhere an operator looks, and the session key keeps the rendered key as a substring.
anconina
force-pushed
the
fix/webhook-turn-scope-authority
branch
from
July 26, 2026 13:06
ac11065 to
4ca87af
Compare
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Description
Every webhook-triggered agent action fail-closes before the agent runs. The turn aborts with
step:"context-authority",errorKind:"precondition", and returns "The request could not beprepared safely because its conversation authority was unavailable." Observed in production on
1.0.55; still reproducible on 1.0.56.
Root cause — a layer mismatch, not a defect at the throw site. #344 made a canonical turn scope
a hard precondition in the executor:
Every turn initiator was updated to resolve a turn identity and thread its scope through — gateway
RPC/MCP, the OpenAI-compatible routes, cron, heartbeat, notification, task-extraction,
proactive-scheduler — except the webhook path. A webhook action hand-builds a
NormalizedMessageand callsgetExecutor().execute()directly, bypassing the orchestrator inboundpipeline that resolves identity for real channels, so nothing ever resolved a scope for it. Its
request context carried
tenantId,userId,sessionKey,agentId,trustLevelanddeliveryOrigin— but noturnScope.Real channel messages are unaffected (the inbound pipeline resolves their scope). Webhooks are
fully dead: no drive starts, and
action: "agent"mappings are inert.Fix. Resolve the turn identity in the webhook handler, as every sibling initiator already does,
and thread its scope into the request context. The resolved identity becomes the single authority
for the turn: its display session key and its turn scope describe the same conversation, so the
session view and the memory/context authority cannot diverge.
Security properties, chosen deliberately:
payload-controlled. A request body cannot forge or widen a conversation's authority even though
sessionKeytemplates interpolate request data.concatenated, so a crafted payload carrying the
:session-key delimiter cannot split theidentity and address another conversation.
trustLevelstaysguest.conversation, different subjects stay partitioned.
hintnaming the keys to check, emits thediagnostic:webhook_deliveredfailure event, and rejects. The endpoint's existingsuppressErrorkeeps the detached turn from surfacing an unhandled rejection, matching the documented contract
that agent turns never affect the response status.
Behavior change. Webhook sessions now display the fully-qualified resolved session key (the
rendered key appears inside it) with a
webhook--prefixed principal, instead of a hand-built<tenant>:agent:<agent>:webhook:<rendered>. This is precisely what makes the session view and theturn authority agree. Documented in
docs/reference/webhooks.mdx.Third and fourth commits — the delivery origin must name the same channel as the turn scope.
Review of the fix above found the resolved identity and the delivery origin built beside it
describing the conversation on different channels: the endpoint carried the
control-planeoriginkind with a JSON-wrapped conversation id, while the origin carried
"webhook"and the rendered key.The runtime treats those two as one authority and rejects the pair wherever they disagree, so the
turn cleared the executor precondition and then fail-closed further in:
BackgroundTaskOriginSchemarejects it with "delivery origin must agree with the resolved turnauthority" — that schema gates
backgroundTaskManager.promote()and task-extraction capture, so awebhook turn could not background a long-running tool.
resolveLeaseRequestPrincipalrejects it with "capability lease principal invalid" — a jailedsession.spawnis denied, which is the drive class this PR exists to enable.webhook turns.
The cron and durable-resume initiators derive the origin from the resolved endpoint for exactly
this reason. This branch now does the same: the webhook surface is named as its own origin kind, the
rendered session key becomes the endpoint's conversation id, and the origin is derived from that
endpoint. The JSON wrapper turned out to be unnecessary for authority — the conversation reference
digests every field length-delimited, so payload data cannot forge a field boundary, and a rendered
key carrying the session-key separator resolves to its own conversation. The endpoint now reads
webhookeverywhere an operator looks.Second commit — observability. Diagnosing this took a raw log grep, which it should not have. A
turn that aborts before recording a session never reaches the session index, so
obs.explainreturned
session_not_foundblaming "a typo, expired, or older than the horizon", andsystem-healthreported0 degradedwhile the entire pipeline was down — a healthy-looking reportfor a dead deployment. The verdict now names the pre-execution-abort possibility and points at the
evidence such an abort does leave: the ERROR carrying its
stepanderrorKindin the daemon log.Related Issue
N/A: found in production rather than filed — every ADO work-item webhook silently aborted before its
drive started. Happy to open a tracking issue if the linked-issue requirement for behavior/security
changes should apply here rather than the
N/Aescape.Type of Change
Checklist
current rebase base
pnpm validate) — see the note below; run on the pre-rebasecommit, with only pre-existing environment failures. After the follow-up commits:
docs:check,build(type-check) and the@comis/daemon(6601 tests) +@comis/orchestrator(1052 tests)projects pass on the rebased head, plus
test/architecture/generic-runtime-boundary.test.tsdocs/reference/webhooks.mdx)covered above and pinned by tests
On
pnpm validate:docs:check,build:clean,cycles,cycles:refsandlint:securitypass.test:coveragereports 60 failures across 15 files, all pre-existing and environmental, none ina package this diff touches: 14 files are real-
bwrapjail/sandbox/egress*.linux.test.tsneedinga privileged bwrap plus network egress, and
@comis/web src/views/security.test.tsfails 23/23 onTypeError: localStorage.removeItem is not a function. Verified by running the same files onunmodified
mainin a clean worktree — identical failures. This run predates the rebase ontocurrent
main; I re-ran only the targeted 94 tests after rebasing and can re-run the full gate onrequest.
RED Test Proof
1. The webhook turn carries no canonical turn scope —
packages/daemon/src/wiring/setup-gateway-routes.test.tsBefore the production patch:
After:
Tests 55 passed (55).The assertion encodes the exact production precondition — scope present and a constructible
conversation ref — so it fails for the same reason the live turn did.
2. The
session_not_foundverdict hides a pre-execution abort —packages/daemon/src/api/obs-handlers/obs-explain.test.tsBefore the production patch (source stashed, test kept):
After:
Tests 29 passed (29).3. New helper unit tests —
gateway-session-principal.test.ts: per-subject conversationisolation, a mapping-bound principal shared across subjects, mappings separated from each other and
from the
gateway-principal namespace, and a delimiter-bearing rendered key (azdo:1:peer:forged"])staying intact inside the conversation identity.
4. The delivery origin and the turn scope named different channels —
packages/daemon/src/wiring/setup-gateway-routes.test.tsBefore the production patch (the test commit is separate, so this RED state is reproducible from
e44eaf3alone):After:
Tests 56 passed (56). The assertion checks the pair field by field and parses it throughBackgroundTaskOriginSchema— the same schema the background-task manager uses — so it fails for thesame reason the downstream seams would.
Evidence and Residual Risk
/hooks/*webhook surface and the turnidentity it hands the executor.
failure was observed end-to-end: poller
POST → HTTP 200 {"received":true}, session…:webhook:azdo:<id>created, workspace and tools assembled,InputSecurityGuardpass, then thecontext-authorityERROR ~25ms in, with no tmux session, no branch and no PR as ground truth. Thecounterpart mechanism was confirmed live by driving the same task through
/api/chat, whoseexecuteAgentpath resolves a turn identity: it completed normally and produced a fully-qualifiedresolved session key, isolating the missing
turnScopeas the sole difference.unsafeDisableSandbox: trueon the livehost, provider
openai-codexvia OAuth, tenantdefault, existing~/.comisstate; unit tests onthe same host against the current rebase base.
action: "agent"with a templatedsessionKey).Preset mappings (gmail, github) are covered by unit tests only.
^webhook-[a-f0-9]{64}$, and the renderedkey appearing inside the resolved key) rather than pinned to an exact literal, so a future change
to conversation formatting would not fail these tests.
real schema, not by a live drive: the three downstream seams (background promotion, capability
lease principal, durable principal) are each verified through the predicate they enforce rather
than by exercising a webhook-triggered background task, jailed spawn and durable run live.
<tenant>:agent:<agent>:webhook:<rendered>session key will need updating; the rendered key isstill a substring, so
grep-by-subject keeps working.different subjects is tested), and the
deliver: truechannel-delivery branch of a mapping.