Skip to content

feat(webhook): per-user webhook receiver, on by default (#101) - #146

Merged
defangdevs merged 3 commits into
masterfrom
feat/issue-101-local-channels
Jul 30, 2026
Merged

feat(webhook): per-user webhook receiver, on by default (#101)#146
defangdevs merged 3 commits into
masterfrom
feat/issue-101-local-channels

Conversation

@defangdevs

@defangdevs defangdevs commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Closes #101. Ships the local-channels local-webhook plugin as a per-user webhook channel, so an agent 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 it already had.

Prerequisite defangdevs/local-channels#3 is merged (local-webhook 0.7.0: CLI mode; gh-webhook alias deleted — its only consumer is gone). webhook.rev pins that merge commit.

What a session sees

agent-box-webhook setup                     # one-time: mint HMAC secret, print URL
agent-box-webhook subscribe OWNER/REPO --note "PR 146: waiting on CI"
agent-box-webhook ls

Three discovery paths, so no session has to be told this exists:

  1. agent-box-webhook on every agent's PATH (and only when there's an endpoint to talk about).
  2. A "Getting told, instead of polling" section in the seeded AGENTS.md, which leads with prefer this over polling.
  3. In Claude Code sessions, webhook_subscribe / _unsubscribe / _subscriptions as MCP tools. The CLI and the tools share one subscription list, so codex and shell sessions aren't second-class.

Design

  • Daemon: agent-box-webhook-<user> runs webhook.mjs with LOCAL_WEBHOOK_RECEIVER_ONLY=1, owning a socket-activated UNIX ingress at /run/agent-box-webhook/<user>.sock, bound 0660 <user>:caddy by a .socket unit — 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.
  • Caddy: handle /<user>/webhook* reverse-proxies to that socket without basic_auth — GitHub can't authenticate, so webhook.mjs's per-source HMAC-SHA256 check is the trust boundary. Emitted before /<user>/*. A bad-signature 401 can't feed the fail2ban jail: its failregex also requires an Authorization header, which no webhook sender sends.
  • Sessions: the plugin is registered + enabled non-interactively via seeded ~/.claude/settings.json; each session gets LOCAL_WEBHOOK_SESSION/STATE_DIR/PORT=0 through 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. Subscriptions never leak between sessions.
  • Pin: builtins.fetchurl of the single local-webhook/webhook.mjs from raw.githubusercontent — the same host and mechanism agent-box.nix itself 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/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 at all. No-op without web.enable. webhook.enable = false removes 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)

  • enabledPlugins is 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 only extraKnownMarketplaces + enabledPlugins seeded, a fresh session clones the marketplace, populates the plugin cache and connects the MCP server with no /plugin prompt — and that the three tools are then reachable in a session.
  • The 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.
  • The conditional Caddy block flattened the terminal block's indentation. A column-0 interpolation inside a Nix indented string resets what gets stripped, so the whole per-user block was emitted 8 columns off. Moved into its own webhookCaddyBlock, composed through the existing indent helper.

Tests

  • tests/webhook.nix (new VM test, wired into CI): units exist with nobody setting webhook.enable; socket is 0660 agent:caddy; the public path answers while /agent/ still 401s; 404 before setup; setup writes a 0600 secret and merges sources.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 same webhook.mjs on 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-session LOCAL_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 no basic_auth anywhere 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

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
defangdevs force-pushed the feat/issue-101-local-channels branch from 4c2d314 to bb7525f Compare July 30, 2026 11:23
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
@defangdevs defangdevs changed the title feat(webhook): per-user local-webhook receiver daemon (#101) feat(webhook): per-user webhook receiver, on by default (#101) Jul 30, 2026
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
@defangdevs
defangdevs merged commit 5650b33 into master Jul 30, 2026
1 check passed
@defangdevs
defangdevs deleted the feat/issue-101-local-channels branch July 30, 2026 11:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ship local-channels with agent-box (webhooks into agent sessions)

3 participants