feat(webhook): per-user webhook receiver, on by default (#101) - #146
Merged
Conversation
Ships the local-channels local-webhook plugin as a per-user webhook channel so
a session gets TOLD when CI finishes or a review lands, instead of looping on
`gh pr checks` + sleep — which is slow and burns context re-reading state.
- A receiver-only daemon per web user (agent-box-webhook-<user>) runs
webhook.mjs in RECEIVER_ONLY mode, owning a socket-activated UNIX ingress
(0660 <user>:caddy, same isolation model as the settings socket) and fanning
HMAC-verified deliveries to that user's sessions over IPC. One stable
endpoint per box, independent of which sessions happen to be alive.
- Caddy reverse-proxies the UNAUTHENTICATED public path /<user>/webhook to that
socket (GitHub can't basic-auth; webhook.mjs's per-source HMAC is the trust
boundary). Emitted before /<user>/* and with no basic_auth. A bad-signature
401 cannot feed the fail2ban jail: its failregex also requires an
Authorization header, which no webhook sender sends.
- New agent-box-webhook CLI on every agent's PATH: `setup` mints the HMAC
secret and prints the URL to register, `subscribe/unsubscribe/ls/status`
delegate to webhook.mjs's own CLI (one implementation of the TTL/renew
semantics), `url` prints the endpoint. This is what makes the feature usable
from codex and shell sessions, not just claude.
- Sessions get LOCAL_WEBHOOK_SESSION/STATE_DIR/PORT=0 via `tmux new-session
-e`, so the vars land in the SESSION environment and are inherited by
anything the agent runs — which is why the CLI needs no arguments.
- AGENTS.md grows a "Getting told, instead of polling" section, and
AGENT_BOX_WEBHOOK_URL joins AGENT_BOX_URL in the agent unit's environment.
ON BY DEFAULT (was opt-in), because an opt-in needing a root /etc/nixos edit
plus a rebuild — which the agent user cannot do — would be discovered by
nobody. It fails closed: with no sources.json every POST is answered 404, and
a configured source rejects anything unsigned with 401, so a box where nobody
ran `agent-box-webhook setup` accepts nothing. No-op without web.enable.
Fixes in the draft wiring, found by checking against a live claude:
- enabledPlugins is an OBJECT keyed "<plugin>@<marketplace>", not a list of
{marketplace, plugin} records. The old seed would never have enabled the
plugin. Verified that with only extraKnownMarketplaces + enabledPlugins
seeded, a fresh session clones the marketplace, populates the plugin cache
and connects the MCP server with no /plugin prompt.
- The pin is now builtins.fetchurl of the single local-webhook/webhook.mjs from
raw.githubusercontent — the same host and mechanism agent-box.nix itself is
deployed with (issue #51), so an IPv6-only box reaches it via NAT64 with no
unpack step. The plugin is one dependency-free file; claude fetches the
marketplace repo itself at runtime.
- The conditional Caddy block moved out of terminalCaddyBlock into its own
webhookCaddyBlock: a column-0 interpolation inside an indented string resets
what Nix strips, which had flattened that whole block's indentation.
Tests: new tests/webhook.nix VM test (socket perms and ownership, the
unauthenticated path beside a still-401ing vhost, 404-before-setup, HMAC
accept/reject, IPC fan-out into a stand-in session peer applying its own
filter, and the discovery surface) plus a cheap webhook-route check asserting
the real generated Caddyfile routes the path first and without basic_auth.
Both wired into CI.
Verified locally: full system closure builds with the feature on; generated
units, Caddyfile, AGENTS.md and CLI all inspected from a real build; the CLI
exercised end-to-end against webhook.mjs; and daemon -> HMAC verify -> IPC
fan-out -> channel notification confirmed by hand (bad signature -> 401). The
VM test itself could not run on the dev box (no /dev/kvm), so CI runs it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
defangdevs
force-pushed
the
feat/issue-101-local-channels
branch
from
July 30, 2026 11:23
4c2d314 to
bb7525f
Compare
defangdevs
added a commit
to defangdevs/local-channels
that referenced
this pull request
Jul 30, 2026
The MCP tools (webhook_subscribe / _unsubscribe / _subscriptions) only exist
inside a Claude Code session that loaded the plugin, so a Codex session, a
plain shell, or a wrapper script had no way to manage subscriptions. Add a
one-shot CLI over the same code:
webhook.mjs subscribe TOPIC [--note T] [--ttl H] [--renew-on-event]
[--ignore-sender L]...
webhook.mjs unsubscribe TOPIC
webhook.mjs ls | status
It is a thin shim over callTool(), so CLI and tool paths cannot drift on
TTL/renew semantics, and it binds nothing: argv-driven means no stdio loop, no
per-PID IPC peer socket (which would claim then unlink the socket of whatever
PID it reused) and never the ingress, so it is safe next to the daemon and any
number of live sessions. Argument errors exit 2, in-band tool errors exit 1.
Also delete the gh-webhook alias directory and its marketplace entry: it existed
only for sessions launched with the pre-2026-07-15 --channels plugin:gh-webhook
flag baked into a nixos-defang-ca generation, and that box is gone. New
consumers (agent-box, defangdevs/agent-box#146) start clean on local-webhook.
Verified end-to-end: receiver daemon on a unix-socket ingress -> HMAC verify
(bad signature -> 401) -> IPC fan-out -> channel notification in a live session
peer, routed by a subscription written through the new CLI; MCP initialize /
tools/list / tools/call unchanged.
Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
Co-authored-by: defangdevs <defangdevs@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
defangdevs/local-channels#3 (local-webhook 0.7.0: CLI mode, gh-webhook alias deleted) is merged, so pin its squash-merge commit on main instead of the PR branch commit. Same webhook.mjs content, so the hash is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
The transient systemd-run unit gets systemd's stock PATH (/usr/local/sbin:...:/bin), which on NixOS has no `sleep`. With sleep not found the pipe closed immediately, webhook.mjs read EOF on stdin — its signal that the session it serves is gone — and exited before registering its IPC socket, so the fan-out assertion timed out on a peer that was never alive. Use /run/current-system/sw/bin for both sh and sleep (the pattern the memory-protection test already uses), and assert the peer unit is active right after starting it so a startup failure reports itself instead of surfacing 30 seconds later as a missing socket. Everything before this point passed in CI: unit/socket creation with nobody setting webhook.enable, 0660 agent:caddy perms, the discovery surface, the seeded plugin settings, 404-before-setup next to a 401 on /agent/, setup writing the 0600 secret, and subscribe writing the per-session filter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
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.
Closes #101. Ships the local-channels
local-webhookplugin as a per-user webhook channel, so an agent session gets told when CI finishes or a review lands instead of looping ongh pr checks+sleep— which is slow and burns context re-reading state it already had.Prerequisite defangdevs/local-channels#3 is merged (local-webhook 0.7.0: CLI mode;
gh-webhookalias deleted — its only consumer is gone).webhook.revpins that merge commit.What a session sees
Three discovery paths, so no session has to be told this exists:
agent-box-webhookon every agent's PATH (and only when there's an endpoint to talk about).AGENTS.md, which leads with prefer this over polling.webhook_subscribe/_unsubscribe/_subscriptionsas MCP tools. The CLI and the tools share one subscription list, so codex and shell sessions aren't second-class.Design
agent-box-webhook-<user>runswebhook.mjswithLOCAL_WEBHOOK_RECEIVER_ONLY=1, owning a socket-activated UNIX ingress at/run/agent-box-webhook/<user>.sock, bound0660 <user>:caddyby a.socketunit — same isolation model as the settings socket (Settings daemon is unauthenticated on localhost: any local user can tamper with another agent's keys #49). No MCP session of its own, so the box's one endpoint stays up regardless of which sessions exist.handle /<user>/webhook*reverse-proxies to that socket without basic_auth — GitHub can't authenticate, sowebhook.mjs's per-source HMAC-SHA256 check is the trust boundary. Emitted before/<user>/*. A bad-signature401can't feed the fail2ban jail: its failregex also requires anAuthorizationheader, which no webhook sender sends.~/.claude/settings.json; each session getsLOCAL_WEBHOOK_SESSION/STATE_DIR/PORT=0throughtmux new-session -e, so the vars land in the session environment and are inherited by anything the agent runs — which is why the CLI needs no arguments. Subscriptions never leak between sessions.builtins.fetchurlof the singlelocal-webhook/webhook.mjsfrom raw.githubusercontent — the same host and mechanismagent-box.nixitself is deployed with (Deploy regression: module no longer single-file — 1-click first boot and self-update break on master #51), so an IPv6-only box reaches it via NAT64 with no unpack step.Default ON (changed from the draft)
An opt-in needing a root
/etc/nixosedit plus a rebuild — which the agent user cannot do — would be discovered by nobody. It fails closed: with nosources.jsonevery POST is answered404, and a configured source rejects anything unsigned with401, so a box where nobody ranagent-box-webhook setupaccepts nothing at all. No-op withoutweb.enable.webhook.enable = falseremoves the daemon, socket and public path entirely.The README's security-model bullet claiming every path on the vhost sits behind the login is corrected — this is the one deliberate exception, and it says why.
Bugs found in the draft wiring (checked against a live claude)
enabledPluginsis an object keyed"<plugin>@<marketplace>", not a list of{marketplace, plugin}records. The draft's seed would never have enabled the plugin. Verified that with onlyextraKnownMarketplaces+enabledPluginsseeded, a fresh session clones the marketplace, populates the plugin cache and connects the MCP server with no/pluginprompt — and that the three tools are then reachable in a session.sha256"placeholder" was actually correct for the old tarball pin — confirmed by a real fetch of both revs before switching to the single-file pin.webhookCaddyBlock, composed through the existingindenthelper.Tests
tests/webhook.nix(new VM test, wired into CI): units exist with nobody settingwebhook.enable; socket is0660 agent:caddy; the public path answers while/agent/still401s;404before setup;setupwrites a 0600 secret and mergessources.json; bad/missing signature →401, unknown source →404, correct HMAC →200(and a bare POST → default source); the daemon fans out to a stand-in session peer — the samewebhook.mjson stdio that claude runs — which prints the channel notification with the subscription note echoed, and stays silent for a repo nobody subscribed to; plus the discovery surface (CLI on PATH,AGENT_BOX_WEBHOOK_URL, per-sessionLOCAL_WEBHOOK_SESSION, seeded plugin settings).webhook-route(cheap eval check, wired into CI): the real generated Caddyfile routes the path to the ingress socket, before the catch-all, with nobasic_authanywhere in that handle's body.Verified locally on this box: full system closure builds with the feature on; generated units / Caddyfile /
AGENTS.md/ CLI all inspected from a real build; the CLI exercised end-to-end; and daemon → HMAC verify → IPC fan-out → channel notification confirmed by hand (bad signature →401). The VM test itself could not run here — this box has no/dev/kvm(nested EC2) — so CI is the first place it executes.🤖 Generated with Claude Code
https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN