Skip to content

feat(webhook): resolve the login this box acts as, so "@self" can mute it (#261) - #271

Merged
lionello merged 4 commits into
masterfrom
feat/261-webhook-self
Aug 19, 2026
Merged

feat(webhook): resolve the login this box acts as, so "@self" can mute it (#261)#271
lionello merged 4 commits into
masterfrom
feat/261-webhook-self

Conversation

@defangdevs

@defangdevs defangdevs commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Closes #261.

Reworked after review. The first version added services.agent-box.webhook.self. Lio's objection is right and decisive: the login is not known at deploy time — it is a property of whatever token the environment carries, and a declared value goes stale silently the moment that token is swapped. The identity is now derived from the token, at runtime. Nothing is declared in Nix.

Motivation

local-webhook resolves "@self" in a subscription's ignoreSenders against $LOCAL_WEBHOOK_SELF (webhook.py 0.13.0, entry_forwards). agent-box set that variable nowhere — not in agent-box-webhook-<user>.service, not in the per-session environment — so SELF was "", the name was falsy and the loop fell through: nothing was muted.

modules/src/webhook-spawn.sh seeds every dispatched hook-* session's filter with exactly ignoreSenders: ["@self"], and the CLI documents @self as $LOCAL_WEBHOOK_SELF. Both promised an echo mute that could not fire. Harmless while no channel notification reached a live session at all (#257); with #260 merged it is real — the session that wrote this PR was told about its own issue_comment and issues.edited deliveries as it worked.

What this does

New agent-box-webhook-self (shipped on PATH with the rest of the webhook self-service) answers one question — who does this box act as? — from the token that is actually present:

  • resolves with gh api user, so it follows the same precedence every other tool on the box does ($GH_TOKEN, $GITHUB_TOKEN, then gh's stored credentials);
  • caches to $LOCAL_WEBHOOK_STATE_DIR/self.env keyed by a fingerprint of the token (never the token). Same token → cache hit, no network. Different token → re-resolves immediately, which is the case a declared option gets silently wrong;
  • an explicit LOCAL_WEBHOOK_SELF (e.g. set in the env store) is echoed back untouched, no lookup, cache left alone;
  • unresolvable (no token, no network, no gh) → the last known login if there is one, otherwise nothing and exit 1, with the reason on stderr. A failed attempt is stamped, and that stamp is honored only under --throttled — which env-exec passes, because a session must not wait on a network timeout to start. A person asking directly always gets a real attempt; without that split, the very first session on a token-less box stamps a failure at boot and silences every later call for an hour (this is what the second CI round caught).

Two readers consume the file, which is why it is an env-file — systemd and shell agree on the format, so there is no second parser:

  • env-exec.sh runs the resolver right after it exports the env store — the one process that holds the token — so every session gets LOCAL_WEBHOOK_SELF. That is what the session's own webhook peer reads, and the peer is what filters session deliveries.
  • the receiver unit loads it with EnvironmentFile=-…/self.env, for standing watches and the ownership probe. Optional by design: absent before anything resolves, and on a box whose user never gives the agent a token. Loaded at start, so a re-resolved login reaches the daemon at its next restart; sessions pick it up as they respawn.

The receiver still holds no credential and makes no network call — it reads a login, not a token.

User-visible and security effects

  • Nothing resolved → variable unset → exactly today's behavior.
  • With an identity, non-CI events whose sender.login matches stop reaching subscriptions that ask for the mute. CI outcomes stay exempt, so a muted box does not go blind to its own failing runs.
  • self.env holds a login and a fingerprint, mode 0644, in the user's own state dir. No token is written anywhere new.
  • One gh api user per token change, on the session-start path, capped by timeout 8 and the retry stamp.

Coverage

tests/webhook.nix stubs gh (the VM has no route to GitHub) and drives the real path for everything else:

  • boot with no token resolves nothing, and the session environment says so — no LOCAL_WEBHOOK_SELF at all;
  • first resolve writes self.env (0644, login + # fp=) and calls gh exactly once; a second run with gh gone from PATH still answers from cache without calling it;
  • a different token re-resolves to the new login; an explicit LOCAL_WEBHOOK_SELF wins without a lookup and leaves the cache alone; an unresolvable token falls back to the last known login and stamps the attempt;
  • the session picks it up through env-exec (tmux show-environment), and the receiver through its EnvironmentFile (read from /proc/$MAINPID/environ, since systemctl show -p Environment lists Environment= only);
  • end to end, with the login taken from the running daemon rather than a literal: an issues.opened from that login is accepted at the ingress (200) and dropped at the filter, while the same event from someone is delivered. Non-CI on purpose — CI outcomes override an ignore list by design, so they cannot show whether it resolved.

Checks run (aarch64, natively)

  • nix run .#assemble — regenerated modules/agent-box.nix, committed
  • nix build .#checks.aarch64-linux.module-generated-up-to-date — "modules/agent-box.nix is up to date"
  • nix build .#checks.aarch64-linux.{multi-user,module-single-file,webhook-route,download-route} — pass
  • nix run .#update-golden — diff is the new payload, the two receiver units' EnvironmentFile, env-exec, the agent units' PATH, and the CLI help
  • bin/check-testscript.sh tests/webhook.nix (driver ty + ruff --select F) — "All checks passed!"
  • the resolver's behavior matrix (cache hit / token change / explicit override / offline fallback / retry window / --refresh / bogus login rejected) exercised directly against a stub gh before pushing
  • the previous push's CI ran the webhook VM test green end to end; its only red was sessions, the known master failure (sessions VM test: pre-#270 session-cli.sh leaked TMUX_TMPDIR from #265's programs.tmux #267, fixed by fix(session-cli): stop trusting an inherited TMUX_TMPDIR or a silent kill (#268) #270)

Follow-up

None required on the host: with a token already in the environment, the first session start after this deploys resolves the identity by itself.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Qn333Y2Y2STtKMR5pSx2zF

Comment thread modules/agent-box.nix.in Outdated
@defangdevs
defangdevs force-pushed the feat/261-webhook-self branch from 631cb4e to 7c3c641 Compare August 18, 2026 17:56
@defangdevs defangdevs changed the title feat(webhook): declare the login this box acts as, so "@self" can mute it (#261) feat(webhook): resolve the login this box acts as, so "@self" can mute it (#261) Aug 18, 2026
@defangdevs
defangdevs force-pushed the feat/261-webhook-self branch from e307907 to 3cb308f Compare August 18, 2026 18:05

@lionello lionello left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens with the cached "self" if I change the GitHub token in the environment and restart the task?

@defangdevs

Copy link
Copy Markdown
Owner Author

What happens with the cached "self" if I change the GitHub token in the environment and restart the task?

It re-resolves on the first session start after the change, with no manual step. That case is exactly why the cache is keyed the way it is.

self.env carries a fingerprint of the token, not an age:

# fp=229c7f7101e8c4ee
LOCAL_WEBHOOK_SELF=defangdevs

The fingerprint is sha256(GH_TOKEN + "\n" + GITHUB_TOKEN), truncated — the token itself is never written anywhere. A different token gives a different fingerprint, so the cached login stops matching and the resolver asks GitHub again instead of trusting it. The retry stamp is fingerprint-keyed too, so a lookup that failed under the OLD token cannot silence the new one.

Concretely, saving a new token on the settings page: the daemon writes ~/.config/agent-box/env and kills the tmux session, the supervisor restarts it, env-exec exports the new token and then runs the resolver, the fingerprint no longer matches, one gh api user runs, and the session comes up muting the new account. Verified on this box — the store here holds GH_TOKEN and gh auth status reports Logged in to github.com account defangdevs (GH_TOKEN), so the resolved answer follows that same token.

Two honest caveats:

  1. The receiver is one restart behind. It loads self.env as an EnvironmentFile, which systemd reads at unit start, so the daemon keeps the old login until it restarts (rebuild, reboot, or systemctl restart agent-box-webhook-<user>). Sessions are current immediately; standing watches and the ownership probe are not. Making the daemon re-read per delivery is a local-channels change, not one this repo can make — say the word and I will file it.
  2. A credential that is not in the environment is invisible to the fingerprint. If a box authenticates gh with gh auth login (a hosts.yml, no GH_TOKEN), swapping that account leaves both env vars empty, so the fingerprint does not move and the old login stays cached until agent-box-webhook-self --refresh. Not this box's shape — there is no hosts.yml here — but it is a real hole. Folding gh auth token into the fingerprint closes it for one extra local (no-network) gh call per session start; I left it out to keep the session-start path cheap. Happy to add it if you would rather have it airtight.

@defangdevs

Copy link
Copy Markdown
Owner Author

CI is green: run 32172055129, including the webhook VM test with the new leg (resolver behavior, the session's view through env-exec, the receiver's EnvironmentFile, and the mute itself dropping this box's own event while delivering someone else's).

Three rounds got there, and each red was a real defect rather than CI noise — recorded here so the diff is readable:

  1. "${FAKE_LOGIN:-box-bot}" in the stub was a Nix antiquotation, not shell — evaluation died before a VM booted.
  2. The retry stamp was read on every call, and the first call on a box is the session that starts at boot. On a box with no token that failure stamped, and every later call inherited the silence for an hour — including a person who had just fixed their token. The throttle is now opt-in (--throttled, which only env-exec passes), and a failed lookup states its reason on stderr instead of exiting silently.
  3. The session assertion watched tmux show-environment, where the variable can never appear: that is the tmux session environment, and env-exec exports one level below it, into the agent's own process environment. It now runs the real wrapper from the unit and asks it what a session sees.

Ready for another look whenever you are.

defangdevs and others added 4 commits August 19, 2026 23:18
…e it

webhook.py resolves "@self" in a subscription's ignoreSenders against
$LOCAL_WEBHOOK_SELF, and agent-box set that variable nowhere: not in the
receiver unit, not in the per-session environment. SELF was always "",
so the entry matched nobody and the echo mute that webhook-spawn.sh seeds
into every dispatched hook-* session was inert (issue #261).

The identity cannot be declared at deploy time. It is a property of
whatever GitHub token the user's environment carries, and that token
arrives — and can be swapped for a different account — at runtime,
through the settings page or `agent-box-session env`. A configured login
would go stale silently the moment the token changed.

So agent-box-webhook-self derives it from the token instead, and caches
it keyed by a FINGERPRINT of that token: an unchanged token never costs a
network call, a changed one re-resolves at once. env-exec.sh runs it just
after it exports the env store — the one process that holds the token —
so every session gets LOCAL_WEBHOOK_SELF, which is what the session's own
webhook peer reads when it filters deliveries. The receiver loads the same
file as an EnvironmentFile, for standing watches and the ownership probe.

Explicit beats derived: LOCAL_WEBHOOK_SELF set in the env store is echoed
back untouched and no lookup runs. Nothing resolved leaves the variable
unset, exactly as before. CI outcomes still override an ignore list, so a
muted box does not go blind to its own failing runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn333Y2Y2STtKMR5pSx2zF
"${FAKE_LOGIN:-box-bot}" inside the testScript's Nix indented string is an
antiquotation, so evaluation died on "undefined variable 'box-bot'" before
any VM booted. Escaped as ''${...}, which is how the shell fragment was
meant to reach the guest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn333Y2Y2STtKMR5pSx2zF
The retry stamp was read on every invocation, and the first invocation on a
box is the session that starts at boot — which on a box with no token (or no
network) fails and stamps. Every later call for that same token then inherited
the silence for an hour, including a person running the command right after
fixing the token, and including the VM test, where it made the resolver exit 1
with no output and no reason.

The throttle belongs to the automatic caller, so it is now opt-in: env-exec
passes --throttled (a session must not wait on a network timeout to start),
and a direct call always makes a real attempt. A failed lookup also says WHY
on stderr now — the caller silences gh's own stderr, so without it "no token",
"no gh" and "GitHub unreachable" were one indistinguishable silence.

Test: the stub gh moves into the agent's own HOME with an absolute
interpreter, and is asserted runnable AS the agent before the resolver uses
it, so a broken stub can no longer masquerade as a broken resolver. Both
throttle behaviors are asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn333Y2Y2STtKMR5pSx2zF
The session assertion watched `tmux show-environment` for LOCAL_WEBHOOK_SELF,
where it can never appear: that reports the tmux SESSION environment, which
holds what the supervisor passed to `new-session -e`. env-exec exports into
the agent's own process environment, one level below.

So it now runs the real wrapper — read out of the agent unit's
AGENT_BOX_ENV_EXEC, and asserted to be the one the pane was started with —
and asks it what a session would see. Deterministic, and it drops an agent
unit restart from the test.

Three states are covered: nothing resolved yet (unset), a resolved identity
(the cache), and an env-store LOCAL_WEBHOOK_SELF, which beats the resolved
one and is restored to the resolved one when removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn333Y2Y2STtKMR5pSx2zF
@defangdevs
defangdevs force-pushed the feat/261-webhook-self branch from 209a0fd to 293b55b Compare August 19, 2026 23:19
@lionello
lionello merged commit 1eb78ef into master Aug 19, 2026
1 check passed
@lionello
lionello deleted the feat/261-webhook-self branch August 19, 2026 23:54
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Agent-Box Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

LOCAL_WEBHOOK_SELF is never set, so the "@self" echo mute every seeded filter carries is inert

2 participants