feat(webhook): resolve the login this box acts as, so "@self" can mute it (#261) - #271
Conversation
631cb4e to
7c3c641
Compare
e307907 to
3cb308f
Compare
lionello
left a comment
There was a problem hiding this comment.
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.
The fingerprint is Concretely, saving a new token on the settings page: the daemon writes Two honest caveats:
|
|
CI is green: run 32172055129, including the Three rounds got there, and each red was a real defect rather than CI noise — recorded here so the diff is readable:
Ready for another look whenever you are. |
…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
209a0fd to
293b55b
Compare
Closes #261.
Motivation
local-webhookresolves"@self"in a subscription'signoreSendersagainst$LOCAL_WEBHOOK_SELF(webhook.py 0.13.0,entry_forwards). agent-box set that variable nowhere — not inagent-box-webhook-<user>.service, not in the per-session environment — soSELFwas"", the name was falsy and the loop fell through: nothing was muted.modules/src/webhook-spawn.shseeds every dispatchedhook-*session's filter with exactlyignoreSenders: ["@self"], and the CLI documents@selfas$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 ownissue_commentandissues.editeddeliveries 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: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);$LOCAL_WEBHOOK_STATE_DIR/self.envkeyed 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;LOCAL_WEBHOOK_SELF(e.g. set in the env store) is echoed back untouched, no lookup, cache left alone;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— whichenv-execpasses, 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.shruns the resolver right after it exports the env store — the one process that holds the token — so every session getsLOCAL_WEBHOOK_SELF. That is what the session's own webhook peer reads, and the peer is what filters session deliveries.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
sender.loginmatches 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.envholds a login and a fingerprint, mode 0644, in the user's own state dir. No token is written anywhere new.gh api userper token change, on the session-start path, capped bytimeout 8and the retry stamp.Coverage
tests/webhook.nixstubsgh(the VM has no route to GitHub) and drives the real path for everything else:LOCAL_WEBHOOK_SELFat all;self.env(0644, login +# fp=) and callsghexactly once; a second run withghgone fromPATHstill answers from cache without calling it;LOCAL_WEBHOOK_SELFwins without a lookup and leaves the cache alone; an unresolvable token falls back to the last known login and stamps the attempt;env-exec(tmux show-environment), and the receiver through itsEnvironmentFile(read from/proc/$MAINPID/environ, sincesystemctl show -p EnvironmentlistsEnvironment=only);issues.openedfrom that login is accepted at the ingress (200) and dropped at the filter, while the same event fromsomeoneis 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— regeneratedmodules/agent-box.nix, committednix 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}— passnix run .#update-golden— diff is the new payload, the two receiver units'EnvironmentFile,env-exec, the agent units' PATH, and the CLI helpbin/check-testscript.sh tests/webhook.nix(driverty+ruff --select F) — "All checks passed!"--refresh/ bogus login rejected) exercised directly against a stubghbefore pushingwebhookVM test green end to end; its only red wassessions, 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