From fb9caa9f2def33f142437176662bed3e02bdc4d2 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 1 Sep 2026 18:43:02 +0000 Subject: [PATCH 1/6] feat(webhook): a standing watch can name its own agent profile (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last open step of #321. AGENT_BOX_HOOK_PROFILE picks ONE worker for every dispatched hook-* session on the box. What it cannot say is "cheap triage for new issues, something that can actually fix things for a red build" — the spawn wrapper could not tell two watches on one repo apart, because every LOCAL_WEBHOOK_SPAWN_* variable it receives describes the EVENT, not the watch that matched it. local-webhook 0.25.0 (defangdevs/local-channels#51, this pin) adds the one that does: LOCAL_WEBHOOK_SPAWN_CONFIG, an opaque map carried on the subscription. So: agent-box-webhook subscribe OWNER/REPO --deliver-to subagent \ --when '{"any":[{"path":"action","in":["opened"]}]}' \ --profile cheap-triage - agent-box-webhook subscribe --profile NAME translates to webhook.py's --spawn-config profile=NAME, the same way --claim translates to --include. --profile= clears it on a re-subscribe. Refused on a session subscription (it spawns nothing) and on a name that could escape into a file path; a profile that does not exist YET is only a warning, because subscribing before creating it is a legitimate order to do things in. - webhook-spawn.sh takes the watch's profile over both box-wide settings, and validates it exactly as before: an unusable name is reported and ignored, never a dropped delivery. - --preamble has no delivery, so it reads the watch out of filter.dispatch.json instead. The settings panel prints that preamble, and a panel naming the box-wide worker for a watch that overrides it would be a lie the operator only discovers from a session that already started (#292). The daemon now passes the state dir and stamps that file into the preamble cache key, so a re-subscribe re-renders. - webhook-policy-apply.sh leaves spawnConfig alone, so --profile survives a receiver restart on a governed watch. Deliberate, and now said in the comment: a profile is runtime data, which is why AGENT_BOX_HOOK_PROFILE has no NixOS option beside it either. Checks: all 24 aarch64 flake checks green (module-generated-up-to-date, golden-snapshot, backend-parity, one-spec-both-backends, webhook-route, webhook-claim, webhook-panel-state, profile-panel, runtime-profile, ...) plus python3 tests/test_agentbox.py (77). tests/webhook.nix gains ~150 lines: the CLI writing spawnConfig.profile and it coming back out of `ls`, the watch beating the box-wide setting, an entry with no profile still falling back to it, --preamble naming each, --profile= clearing, and both refusals. That file is x86-only here, so it was eval-checked (drvPath) and its testScript extracted, compiled and ruff-linted. Behaviour also driven against the real built wrappers on this box, which is where the failure paths were confirmed: a garbage LOCAL_WEBHOOK_SPAWN_CONFIG, a corrupt dispatch file, a missing profile and a "../escape" name all fall back to the box default and say so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01THnqn1ALGFfof9qg7dArtV --- README.md | 13 ++ modules/agent-box.nix | 147 +++++++++++++++-- modules/agent-box.nix.in | 14 +- modules/src/default-agents.md | 6 +- modules/src/settings-daemon.py | 14 +- modules/src/webhook-cli.sh | 57 ++++++- modules/src/webhook-policy-apply.sh | 11 +- modules/src/webhook-spawn.sh | 45 ++++- .../vm/etc/agent-box-guides/AGENTS.agent.md | 6 +- .../web/etc/agent-box-guides/AGENTS.agent.md | 6 +- .../agent-box-settings/bin/agent-box-settings | 14 +- .../bin/agent-box-webhook-policy-apply | 11 +- .../bin/agent-box-webhook-spawn | 45 ++++- .../agent-box-webhook/bin/agent-box-webhook | 57 ++++++- .../etc/agent-box-guides/AGENTS.agent.md | 6 +- .../etc/agent-box-guides/AGENTS.robot.md | 6 +- tests/webhook.nix | 156 ++++++++++++++++++ 17 files changed, 577 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 09760676..3c9549ef 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,19 @@ the spawn wrapper's own `--preamble`, so there is one copy of the answer). A renamed or deleted profile is reported and ignored — a delivery is never dropped over it. +That setting is box-wide. A single watch names its own worker, which beats it: + +```bash +agent-box-webhook subscribe OWNER/REPO --deliver-to subagent \ + --when '{"any":[{"path":"action","in":["opened"]}]}' \ + --profile cheap-triage --note "standing watch: new issues" +``` + +So one repo can triage new issues cheaply and put something stronger on a red +build. The choice is stored on the subscription itself (`spawnConfig.profile`, +local-webhook 0.25.0) and shown by `agent-box-webhook ls`; `--profile ''` +clears it. + **New user or new session?** A user is the trust boundary; a session is a unit of work, and sessions of one user are *not* isolated from each other. The decision rule, the measurements behind it, and what "1 user = 1 diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 40b5d3df..b92b2052 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -460,7 +460,11 @@ let profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane @@ -3605,7 +3609,7 @@ esac [--deliver-to session|subagent] [--renew-on-event] [--ignore-sender LOGIN]... [--when JSON] [--drop JSON] - [--claim SPEC]... + [--claim SPEC]... [--profile NAME] agent-box-webhook unsubscribe TOPIC [--deliver-to session|subagent] agent-box-webhook ls agent-box-webhook status @@ -3710,6 +3714,22 @@ esac --claim and --include are mutually exclusive; write --include yourself only for rules --claim cannot express. + --profile NAME (subagent watches only) names the agent profile the sessions + THIS watch spawns start on — a harness, a model, an effort level, an appended + system prompt (agent-box-profile ls). It beats the box-wide + AGENT_BOX_HOOK_PROFILE, so one repo can triage new issues cheaply and put + something stronger on a red build: + + agent-box-webhook subscribe OWNER/REPO --deliver-to subagent \ + --when '{"any":[{"path":"action","in":["opened"]}]}' \ + --profile cheap-triage --note "standing watch: new issues" + + It is stored on the subscription as spawnConfig.profile (webhook.py's + --spawn-config), so `agent-box-webhook ls` shows it. --profile ''' clears it. + A profile that does not exist when an event arrives is reported and ignored, + and the session starts on the box default — a delivery is never dropped for + a renamed profile. + --when / --drop attach payload rules to the subscription: deliver (or spawn) ONLY events matching --when, never those matching --drop. Rules are JSON — {"any"/"all": [...]} over {"path": "a.b.c", "in"/"notIn": [values]} @@ -4058,7 +4078,7 @@ esac ensure_state if [ "''${1:-}" != "-h" ] && [ "''${1:-}" != "--help" ]; then deliver_to=session; have_when=0; have_drop=0; topic=""; want="" - have_include=0; claims="" + have_include=0; claims=""; profile=""; have_profile=0 # Filter --claim out of the argument list as we scan it: webhook.py has # never heard of that flag, so it is translated to --include below and # must not survive into the exec. Rotate idiom — take from the front, @@ -4083,6 +4103,7 @@ esac exit 2 fi claims="$claims $a"; want=""; continue ;; + (profile) profile="$a"; have_profile=1; want=""; continue ;; esac want="" set -- "$@" "$a"; continue @@ -4100,6 +4121,13 @@ esac exit 2 fi claims="$claims ''${a#--claim=}"; continue ;; + # Filtered out like --claim, and for the same reason: webhook.py + # spells this --spawn-config profile=NAME, and the agent-box word for + # the thing is "profile" (issue #321). Empty is legal — it clears the + # profile on a re-subscribe — so, unlike --claim, only the missing + # VALUE is an error. + --profile) want=profile; continue ;; + --profile=*) profile="''${a#--profile=}"; have_profile=1; continue ;; --*) ;; *) [ -n "$topic" ] || topic="$a" ;; esac @@ -4109,6 +4137,35 @@ esac echo "agent-box-webhook: --$want needs a value" >&2 exit 2 fi + if [ "$have_profile" = 1 ]; then + # A session subscription spawns nothing, so a profile on one would be a + # setting no code path reads. webhook.py refuses it too; saying it here + # names the flag the caller actually typed. + if [ "$deliver_to" != subagent ]; then + echo "agent-box-webhook: --profile only applies to a standing watch —" \ + "it names the worker a spawned session starts on, and a session" \ + "subscription spawns nothing; add --deliver-to subagent" >&2 + exit 2 + fi + case "$profile" in + ("") set -- "$@" --no-spawn-config ;; + (*[!A-Za-z0-9_-]*) + echo "agent-box-webhook: --profile '$profile' is not a valid profile" \ + "name (A-Za-z0-9_-)" >&2 + exit 2 ;; + (*) + # A warning, never a refusal: profiles are runtime data, and + # subscribing a watch before creating its profile is a legitimate + # order to do things in. The spawn falls back to the box default + # and says so if the profile is still missing when an event lands. + if [ ! -r "$HOME/.config/agent-box/profiles/$profile.env" ]; then + echo "agent-box-webhook: no profile '$profile' on this box yet" \ + "(agent-box-profile ls) — subscribing anyway; a match starts" \ + "the box default until you create it" >&2 + fi + set -- "$@" --spawn-config "profile=$profile" ;; + esac + fi if [ -n "$claims" ]; then if [ "$have_include" = 1 ]; then echo "agent-box-webhook: pass --claim OR --include, not both —" \ @@ -5472,14 +5529,52 @@ fi # source of "". [ -z "$hook_profile" ] || [ -n "$hook_profile_source" ] \ || hook_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" + +# The WATCH's own answer, which beats both box-wide settings above — the last +# step of issue #321. AGENT_BOX_HOOK_PROFILE names ONE worker for every +# dispatched session on the box; a watch carrying spawnConfig.profile is naming +# the worker for its own events only, and the narrower answer is the one whoever +# wrote it meant. That is what lets cheap triage handle new issues while a red +# build starts something that can actually fix it. +# +# It arrives in LOCAL_WEBHOOK_SPAWN_CONFIG (local-channels 0.25.0), the one +# variable the dispatcher fills from the matched subscription rather than from +# the event. Older receivers set nothing, and an entry with no config sets `{}`, +# so both read as "no watch-level answer" and the box-wide setting stands. +# +# --preamble has no delivery and so no variable: the settings page asks what a +# named TOPIC would start, so that mode reads the watch straight out of the +# dispatch file. Same precedence either way, because the page must not advertise +# a worker the spawn would not use. +watch_config="" +if [ -n "''${LOCAL_WEBHOOK_SPAWN_CONFIG:-}" ]; then + watch_config="$LOCAL_WEBHOOK_SPAWN_CONFIG" +elif [ "''${1:-}" = "--preamble" ] && [ -n "''${2:-}" ] && [ -n "''${LOCAL_WEBHOOK_STATE_DIR:-}" ]; then + # Best effort, like every other read here: no file, bad JSON or no such topic + # all mean "this watch names no profile", never a failed render. + watch_config=$("$JQ" -r --arg t "$2" \ + '[(.topics // [])[] | select(type == "object" and (.topic // "") == $t)][0] + | (if type == "object" then (.spawnConfig // {}) else {} end) | tojson' \ + "$LOCAL_WEBHOOK_STATE_DIR/filter.dispatch.json" 2>/dev/null) || watch_config="" +fi +if [ -n "$watch_config" ]; then + watch_profile=$("$JQ" -r 'if type == "object" then (.profile // empty) else empty end' \ + <<<"$watch_config" 2>/dev/null) || watch_profile="" + if [ -n "$watch_profile" ]; then + hook_profile="$watch_profile" + hook_profile_source="this watch's own spawnConfig.profile" + fi +fi + if [ -n "$hook_profile" ]; then # Checked HERE, not left to `agent-box-session add --profile`, which exits 2 # on an unknown profile: that exit would drop the batch, and the events do # not come back. Name charset first (it reaches a file path), then the file. case "$hook_profile" in (*[!A-Za-z0-9_-]*) - echo "agent-box-webhook-spawn: AGENT_BOX_HOOK_PROFILE '$hook_profile' is not a" \ - "valid profile name; starting this session on the box default" >&2 + echo "agent-box-webhook-spawn: profile '$hook_profile' (from" \ + "$hook_profile_source) is not a valid profile name; starting this" \ + "session on the box default" >&2 hook_profile_source="$hook_profile_source — IGNORED, not a valid profile name" hook_profile="" ;; @@ -5556,7 +5651,8 @@ environment come from that profile ($hook_profile_source) — read it back with printf '%s\n' "No agent profile is set, so a match starts the box default \ harness. Pick a worker for every LATER hook session with: agent-box-profile \ set triage HARNESS=claude MODEL=sonnet EFFORT=low && agent-box-session env set \ -AGENT_BOX_HOOK_PROFILE triage" +AGENT_BOX_HOOK_PROFILE triage — or for THIS watch alone, re-subscribe it with \ +'agent-box-webhook subscribe TOPIC --deliver-to subagent --profile triage'." fi if [ -n "$hook_args_source" ]; then printf 'The arguments after the agent come from %s.\n' "$hook_args_source" @@ -6048,8 +6144,15 @@ fi # For every entry whose topic is declared: the declaration REPLACES the # managed fields (the payload predicates, ignoreSenders, note) wholesale — # partial merges would let config and state drift apart. Runtime fields -# (ttlHours, renewOnEvent, timestamps) stay the entry's own. Bare-string -# topics normalize to the object form webhook.py itself writes. +# (ttlHours, renewOnEvent, spawnConfig, timestamps) stay the entry's own. +# Bare-string topics normalize to the object form webhook.py itself writes. +# +# spawnConfig is on that list deliberately (issue #321): it names an agent +# PROFILE, and a profile is runtime data a user creates with +# agent-box-profile — a declared value could name one that does not exist on +# the box, which is the same reason AGENT_BOX_HOOK_PROFILE has no NixOS option +# beside it. So `agent-box-webhook subscribe --profile` survives a receiver +# restart on a governed watch, rather than being reverted by a rebuild. # # local-webhook 0.19.0 renamed the two predicates when -> include and # drop -> exclude (local-channels#294). It still ACCEPTS the old names on @@ -9042,9 +9145,19 @@ in the marketplace itself and tracks its default branch — this pin only governs the copy the box runs. - 0.23.0 (this pin, issue #380) retired the built-in failures-only CI + 0.25.0 (this pin, issue #321) lets a dispatch entry carry + `spawnConfig`, a flat map of strings the receiver hands to the spawn + command in `LOCAL_WEBHOOK_SPAWN_CONFIG`. Every other + `LOCAL_WEBHOOK_SPAWN_*` variable describes the EVENT, so two watches + on one repo used to be indistinguishable to webhook-spawn.sh; this + one describes the WATCH, which is what `agent-box-webhook subscribe + --profile NAME` writes and what lets a watch name the agent profile + its own sessions start on. AGENT_BOX_HOOK_PROFILE stays the box-wide + fallback. + + 0.23.0 (issue #380) retired the built-in failures-only CI brake for rule-less dispatch entries: a `--deliver-to subagent` - watch now MUST carry its own `when`/`drop` rules or webhook_subscribe + watch MUST carry its own `when`/`drop` rules or webhook_subscribe refuses to create it. `ignoreSenders` also became a pure sender mute with no CI-outcome carve-out. webhook-cli.sh (see subscribeCmd below) fills in a default `when` for a rule-less GitHub subagent @@ -11630,7 +11743,8 @@ def webhook_unsubscribe(key, topic, dispatch): def hook_args_stamp(): - """(mtime_ns, size) of the env file and of the profiles directory. + """(mtime_ns, size) of the env file, the profiles directory and the + dispatch filter file. Part of hook_preamble's cache key. The dispatch script reports the hook-session arguments that file can override (#292), so its output is @@ -11644,9 +11758,14 @@ def hook_args_stamp(): profile still exists decides whether the watch uses it at all. Creating or removing one does not touch the env file, and every write goes through a rename inside this directory, so its mtime moves on both. + + The dispatch filter file joins them because a watch can now name its own + profile (spawnConfig.profile, issue #321): the report reads that file, so + re-subscribing a watch with a different --profile has to re-render. Every + write to it is an atomic rename, so the mtime moves. """ stamps = [] - for path in (ENV_FILE, PROFILES_DIR): + for path in (ENV_FILE, PROFILES_DIR, os.path.join(webhook_state_dir(), "filter.dispatch.json")): try: info = os.stat(path) except OSError: @@ -11678,6 +11797,10 @@ def hook_preamble(topic, note, stamp): try: proc = subprocess.run( [HOOK_SPAWN_CMD, "--preamble", topic, note], + # The script reads the dispatch file to find THIS watch's own + # profile (#321); under socket activation nothing else puts the + # state dir in this daemon's environment. + env=dict(os.environ, LOCAL_WEBHOOK_STATE_DIR=webhook_state_dir()), check=False, capture_output=True, text=True, diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index 790098c7..1e18e46a 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -1940,9 +1940,19 @@ in the marketplace itself and tracks its default branch — this pin only governs the copy the box runs. - 0.23.0 (this pin, issue #380) retired the built-in failures-only CI + 0.25.0 (this pin, issue #321) lets a dispatch entry carry + `spawnConfig`, a flat map of strings the receiver hands to the spawn + command in `LOCAL_WEBHOOK_SPAWN_CONFIG`. Every other + `LOCAL_WEBHOOK_SPAWN_*` variable describes the EVENT, so two watches + on one repo used to be indistinguishable to webhook-spawn.sh; this + one describes the WATCH, which is what `agent-box-webhook subscribe + --profile NAME` writes and what lets a watch name the agent profile + its own sessions start on. AGENT_BOX_HOOK_PROFILE stays the box-wide + fallback. + + 0.23.0 (issue #380) retired the built-in failures-only CI brake for rule-less dispatch entries: a `--deliver-to subagent` - watch now MUST carry its own `when`/`drop` rules or webhook_subscribe + watch MUST carry its own `when`/`drop` rules or webhook_subscribe refuses to create it. `ignoreSenders` also became a pure sender mute with no CI-outcome carve-out. webhook-cli.sh (see subscribeCmd below) fills in a default `when` for a rule-less GitHub subagent diff --git a/modules/src/default-agents.md b/modules/src/default-agents.md index cd956435..3c0c72b9 100644 --- a/modules/src/default-agents.md +++ b/modules/src/default-agents.md @@ -108,7 +108,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/modules/src/settings-daemon.py b/modules/src/settings-daemon.py index 7f5f5ea2..a1934fdd 100644 --- a/modules/src/settings-daemon.py +++ b/modules/src/settings-daemon.py @@ -1325,7 +1325,8 @@ def webhook_unsubscribe(key, topic, dispatch): def hook_args_stamp(): - """(mtime_ns, size) of the env file and of the profiles directory. + """(mtime_ns, size) of the env file, the profiles directory and the + dispatch filter file. Part of hook_preamble's cache key. The dispatch script reports the hook-session arguments that file can override (#292), so its output is @@ -1339,9 +1340,14 @@ def hook_args_stamp(): profile still exists decides whether the watch uses it at all. Creating or removing one does not touch the env file, and every write goes through a rename inside this directory, so its mtime moves on both. + + The dispatch filter file joins them because a watch can now name its own + profile (spawnConfig.profile, issue #321): the report reads that file, so + re-subscribing a watch with a different --profile has to re-render. Every + write to it is an atomic rename, so the mtime moves. """ stamps = [] - for path in (ENV_FILE, PROFILES_DIR): + for path in (ENV_FILE, PROFILES_DIR, os.path.join(webhook_state_dir(), "filter.dispatch.json")): try: info = os.stat(path) except OSError: @@ -1373,6 +1379,10 @@ def hook_preamble(topic, note, stamp): try: proc = subprocess.run( [HOOK_SPAWN_CMD, "--preamble", topic, note], + # The script reads the dispatch file to find THIS watch's own + # profile (#321); under socket activation nothing else puts the + # state dir in this daemon's environment. + env=dict(os.environ, LOCAL_WEBHOOK_STATE_DIR=webhook_state_dir()), check=False, capture_output=True, text=True, diff --git a/modules/src/webhook-cli.sh b/modules/src/webhook-cli.sh index a0ab6f97..2c13d16f 100644 --- a/modules/src/webhook-cli.sh +++ b/modules/src/webhook-cli.sh @@ -24,7 +24,7 @@ usage: agent-box-webhook subscribe TOPIC [--note TEXT] [--ttl HOURS] [--deliver-to session|subagent] [--renew-on-event] [--ignore-sender LOGIN]... [--when JSON] [--drop JSON] - [--claim SPEC]... + [--claim SPEC]... [--profile NAME] agent-box-webhook unsubscribe TOPIC [--deliver-to session|subagent] agent-box-webhook ls agent-box-webhook status @@ -129,6 +129,22 @@ reports through commit statuses, you stay exposed on that one shape. --claim and --include are mutually exclusive; write --include yourself only for rules --claim cannot express. +--profile NAME (subagent watches only) names the agent profile the sessions +THIS watch spawns start on — a harness, a model, an effort level, an appended +system prompt (agent-box-profile ls). It beats the box-wide +AGENT_BOX_HOOK_PROFILE, so one repo can triage new issues cheaply and put +something stronger on a red build: + + agent-box-webhook subscribe OWNER/REPO --deliver-to subagent \ + --when '{"any":[{"path":"action","in":["opened"]}]}' \ + --profile cheap-triage --note "standing watch: new issues" + +It is stored on the subscription as spawnConfig.profile (webhook.py's +--spawn-config), so `agent-box-webhook ls` shows it. --profile '' clears it. +A profile that does not exist when an event arrives is reported and ignored, +and the session starts on the box default — a delivery is never dropped for +a renamed profile. + --when / --drop attach payload rules to the subscription: deliver (or spawn) ONLY events matching --when, never those matching --drop. Rules are JSON — {"any"/"all": [...]} over {"path": "a.b.c", "in"/"notIn": [values]} @@ -477,7 +493,7 @@ case "$cmd" in ensure_state if [ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ]; then deliver_to=session; have_when=0; have_drop=0; topic=""; want="" - have_include=0; claims="" + have_include=0; claims=""; profile=""; have_profile=0 # Filter --claim out of the argument list as we scan it: webhook.py has # never heard of that flag, so it is translated to --include below and # must not survive into the exec. Rotate idiom — take from the front, @@ -502,6 +518,7 @@ case "$cmd" in exit 2 fi claims="$claims $a"; want=""; continue ;; + (profile) profile="$a"; have_profile=1; want=""; continue ;; esac want="" set -- "$@" "$a"; continue @@ -519,6 +536,13 @@ case "$cmd" in exit 2 fi claims="$claims ${a#--claim=}"; continue ;; + # Filtered out like --claim, and for the same reason: webhook.py + # spells this --spawn-config profile=NAME, and the agent-box word for + # the thing is "profile" (issue #321). Empty is legal — it clears the + # profile on a re-subscribe — so, unlike --claim, only the missing + # VALUE is an error. + --profile) want=profile; continue ;; + --profile=*) profile="${a#--profile=}"; have_profile=1; continue ;; --*) ;; *) [ -n "$topic" ] || topic="$a" ;; esac @@ -528,6 +552,35 @@ case "$cmd" in echo "agent-box-webhook: --$want needs a value" >&2 exit 2 fi + if [ "$have_profile" = 1 ]; then + # A session subscription spawns nothing, so a profile on one would be a + # setting no code path reads. webhook.py refuses it too; saying it here + # names the flag the caller actually typed. + if [ "$deliver_to" != subagent ]; then + echo "agent-box-webhook: --profile only applies to a standing watch —" \ + "it names the worker a spawned session starts on, and a session" \ + "subscription spawns nothing; add --deliver-to subagent" >&2 + exit 2 + fi + case "$profile" in + ("") set -- "$@" --no-spawn-config ;; + (*[!A-Za-z0-9_-]*) + echo "agent-box-webhook: --profile '$profile' is not a valid profile" \ + "name (A-Za-z0-9_-)" >&2 + exit 2 ;; + (*) + # A warning, never a refusal: profiles are runtime data, and + # subscribing a watch before creating its profile is a legitimate + # order to do things in. The spawn falls back to the box default + # and says so if the profile is still missing when an event lands. + if [ ! -r "$HOME/.config/agent-box/profiles/$profile.env" ]; then + echo "agent-box-webhook: no profile '$profile' on this box yet" \ + "(agent-box-profile ls) — subscribing anyway; a match starts" \ + "the box default until you create it" >&2 + fi + set -- "$@" --spawn-config "profile=$profile" ;; + esac + fi if [ -n "$claims" ]; then if [ "$have_include" = 1 ]; then echo "agent-box-webhook: pass --claim OR --include, not both —" \ diff --git a/modules/src/webhook-policy-apply.sh b/modules/src/webhook-policy-apply.sh index 65867d97..e924d5c2 100644 --- a/modules/src/webhook-policy-apply.sh +++ b/modules/src/webhook-policy-apply.sh @@ -15,8 +15,15 @@ fi # For every entry whose topic is declared: the declaration REPLACES the # managed fields (the payload predicates, ignoreSenders, note) wholesale — # partial merges would let config and state drift apart. Runtime fields -# (ttlHours, renewOnEvent, timestamps) stay the entry's own. Bare-string -# topics normalize to the object form webhook.py itself writes. +# (ttlHours, renewOnEvent, spawnConfig, timestamps) stay the entry's own. +# Bare-string topics normalize to the object form webhook.py itself writes. +# +# spawnConfig is on that list deliberately (issue #321): it names an agent +# PROFILE, and a profile is runtime data a user creates with +# agent-box-profile — a declared value could name one that does not exist on +# the box, which is the same reason AGENT_BOX_HOOK_PROFILE has no NixOS option +# beside it. So `agent-box-webhook subscribe --profile` survives a receiver +# restart on a governed watch, rather than being reverted by a rebuild. # # local-webhook 0.19.0 renamed the two predicates when -> include and # drop -> exclude (local-channels#294). It still ACCEPTS the old names on diff --git a/modules/src/webhook-spawn.sh b/modules/src/webhook-spawn.sh index 9aa8b4cc..652bdc1e 100644 --- a/modules/src/webhook-spawn.sh +++ b/modules/src/webhook-spawn.sh @@ -181,14 +181,52 @@ fi # source of "". [ -z "$hook_profile" ] || [ -n "$hook_profile_source" ] \ || hook_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" + +# The WATCH's own answer, which beats both box-wide settings above — the last +# step of issue #321. AGENT_BOX_HOOK_PROFILE names ONE worker for every +# dispatched session on the box; a watch carrying spawnConfig.profile is naming +# the worker for its own events only, and the narrower answer is the one whoever +# wrote it meant. That is what lets cheap triage handle new issues while a red +# build starts something that can actually fix it. +# +# It arrives in LOCAL_WEBHOOK_SPAWN_CONFIG (local-channels 0.25.0), the one +# variable the dispatcher fills from the matched subscription rather than from +# the event. Older receivers set nothing, and an entry with no config sets `{}`, +# so both read as "no watch-level answer" and the box-wide setting stands. +# +# --preamble has no delivery and so no variable: the settings page asks what a +# named TOPIC would start, so that mode reads the watch straight out of the +# dispatch file. Same precedence either way, because the page must not advertise +# a worker the spawn would not use. +watch_config="" +if [ -n "${LOCAL_WEBHOOK_SPAWN_CONFIG:-}" ]; then + watch_config="$LOCAL_WEBHOOK_SPAWN_CONFIG" +elif [ "${1:-}" = "--preamble" ] && [ -n "${2:-}" ] && [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ]; then + # Best effort, like every other read here: no file, bad JSON or no such topic + # all mean "this watch names no profile", never a failed render. + watch_config=$("$JQ" -r --arg t "$2" \ + '[(.topics // [])[] | select(type == "object" and (.topic // "") == $t)][0] + | (if type == "object" then (.spawnConfig // {}) else {} end) | tojson' \ + "$LOCAL_WEBHOOK_STATE_DIR/filter.dispatch.json" 2>/dev/null) || watch_config="" +fi +if [ -n "$watch_config" ]; then + watch_profile=$("$JQ" -r 'if type == "object" then (.profile // empty) else empty end' \ + <<<"$watch_config" 2>/dev/null) || watch_profile="" + if [ -n "$watch_profile" ]; then + hook_profile="$watch_profile" + hook_profile_source="this watch's own spawnConfig.profile" + fi +fi + if [ -n "$hook_profile" ]; then # Checked HERE, not left to `agent-box-session add --profile`, which exits 2 # on an unknown profile: that exit would drop the batch, and the events do # not come back. Name charset first (it reaches a file path), then the file. case "$hook_profile" in (*[!A-Za-z0-9_-]*) - echo "agent-box-webhook-spawn: AGENT_BOX_HOOK_PROFILE '$hook_profile' is not a" \ - "valid profile name; starting this session on the box default" >&2 + echo "agent-box-webhook-spawn: profile '$hook_profile' (from" \ + "$hook_profile_source) is not a valid profile name; starting this" \ + "session on the box default" >&2 hook_profile_source="$hook_profile_source — IGNORED, not a valid profile name" hook_profile="" ;; @@ -265,7 +303,8 @@ environment come from that profile ($hook_profile_source) — read it back with printf '%s\n' "No agent profile is set, so a match starts the box default \ harness. Pick a worker for every LATER hook session with: agent-box-profile \ set triage HARNESS=claude MODEL=sonnet EFFORT=low && agent-box-session env set \ -AGENT_BOX_HOOK_PROFILE triage" +AGENT_BOX_HOOK_PROFILE triage — or for THIS watch alone, re-subscribe it with \ +'agent-box-webhook subscribe TOPIC --deliver-to subagent --profile triage'." fi if [ -n "$hook_args_source" ]; then printf 'The arguments after the agent come from %s.\n' "$hook_args_source" diff --git a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md index 1a414a22..77cbe507 100644 --- a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md @@ -123,7 +123,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md index c5c795b0..ddadacd9 100644 --- a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md @@ -123,7 +123,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/tests/golden/web/payloads/agent-box-settings/bin/agent-box-settings b/tests/golden/web/payloads/agent-box-settings/bin/agent-box-settings index 71e00bd9..3381e181 100644 --- a/tests/golden/web/payloads/agent-box-settings/bin/agent-box-settings +++ b/tests/golden/web/payloads/agent-box-settings/bin/agent-box-settings @@ -1657,7 +1657,8 @@ def webhook_unsubscribe(key, topic, dispatch): def hook_args_stamp(): - """(mtime_ns, size) of the env file and of the profiles directory. + """(mtime_ns, size) of the env file, the profiles directory and the + dispatch filter file. Part of hook_preamble's cache key. The dispatch script reports the hook-session arguments that file can override (#292), so its output is @@ -1671,9 +1672,14 @@ def hook_args_stamp(): profile still exists decides whether the watch uses it at all. Creating or removing one does not touch the env file, and every write goes through a rename inside this directory, so its mtime moves on both. + + The dispatch filter file joins them because a watch can now name its own + profile (spawnConfig.profile, issue #321): the report reads that file, so + re-subscribing a watch with a different --profile has to re-render. Every + write to it is an atomic rename, so the mtime moves. """ stamps = [] - for path in (ENV_FILE, PROFILES_DIR): + for path in (ENV_FILE, PROFILES_DIR, os.path.join(webhook_state_dir(), "filter.dispatch.json")): try: info = os.stat(path) except OSError: @@ -1705,6 +1711,10 @@ def hook_preamble(topic, note, stamp): try: proc = subprocess.run( [HOOK_SPAWN_CMD, "--preamble", topic, note], + # The script reads the dispatch file to find THIS watch's own + # profile (#321); under socket activation nothing else puts the + # state dir in this daemon's environment. + env=dict(os.environ, LOCAL_WEBHOOK_STATE_DIR=webhook_state_dir()), check=False, capture_output=True, text=True, diff --git a/tests/golden/web/payloads/agent-box-webhook-policy-apply/bin/agent-box-webhook-policy-apply b/tests/golden/web/payloads/agent-box-webhook-policy-apply/bin/agent-box-webhook-policy-apply index 05370e7a..8b4a178e 100644 --- a/tests/golden/web/payloads/agent-box-webhook-policy-apply/bin/agent-box-webhook-policy-apply +++ b/tests/golden/web/payloads/agent-box-webhook-policy-apply/bin/agent-box-webhook-policy-apply @@ -17,8 +17,15 @@ fi # For every entry whose topic is declared: the declaration REPLACES the # managed fields (the payload predicates, ignoreSenders, note) wholesale — # partial merges would let config and state drift apart. Runtime fields -# (ttlHours, renewOnEvent, timestamps) stay the entry's own. Bare-string -# topics normalize to the object form webhook.py itself writes. +# (ttlHours, renewOnEvent, spawnConfig, timestamps) stay the entry's own. +# Bare-string topics normalize to the object form webhook.py itself writes. +# +# spawnConfig is on that list deliberately (issue #321): it names an agent +# PROFILE, and a profile is runtime data a user creates with +# agent-box-profile — a declared value could name one that does not exist on +# the box, which is the same reason AGENT_BOX_HOOK_PROFILE has no NixOS option +# beside it. So `agent-box-webhook subscribe --profile` survives a receiver +# restart on a governed watch, rather than being reverted by a rebuild. # # local-webhook 0.19.0 renamed the two predicates when -> include and # drop -> exclude (local-channels#294). It still ACCEPTS the old names on diff --git a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn index e3cce8cb..a197bc24 100644 --- a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn +++ b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn @@ -432,14 +432,52 @@ fi # source of "". [ -z "$hook_profile" ] || [ -n "$hook_profile_source" ] \ || hook_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" + +# The WATCH's own answer, which beats both box-wide settings above — the last +# step of issue #321. AGENT_BOX_HOOK_PROFILE names ONE worker for every +# dispatched session on the box; a watch carrying spawnConfig.profile is naming +# the worker for its own events only, and the narrower answer is the one whoever +# wrote it meant. That is what lets cheap triage handle new issues while a red +# build starts something that can actually fix it. +# +# It arrives in LOCAL_WEBHOOK_SPAWN_CONFIG (local-channels 0.25.0), the one +# variable the dispatcher fills from the matched subscription rather than from +# the event. Older receivers set nothing, and an entry with no config sets `{}`, +# so both read as "no watch-level answer" and the box-wide setting stands. +# +# --preamble has no delivery and so no variable: the settings page asks what a +# named TOPIC would start, so that mode reads the watch straight out of the +# dispatch file. Same precedence either way, because the page must not advertise +# a worker the spawn would not use. +watch_config="" +if [ -n "${LOCAL_WEBHOOK_SPAWN_CONFIG:-}" ]; then + watch_config="$LOCAL_WEBHOOK_SPAWN_CONFIG" +elif [ "${1:-}" = "--preamble" ] && [ -n "${2:-}" ] && [ -n "${LOCAL_WEBHOOK_STATE_DIR:-}" ]; then + # Best effort, like every other read here: no file, bad JSON or no such topic + # all mean "this watch names no profile", never a failed render. + watch_config=$("$JQ" -r --arg t "$2" \ + '[(.topics // [])[] | select(type == "object" and (.topic // "") == $t)][0] + | (if type == "object" then (.spawnConfig // {}) else {} end) | tojson' \ + "$LOCAL_WEBHOOK_STATE_DIR/filter.dispatch.json" 2>/dev/null) || watch_config="" +fi +if [ -n "$watch_config" ]; then + watch_profile=$("$JQ" -r 'if type == "object" then (.profile // empty) else empty end' \ + <<<"$watch_config" 2>/dev/null) || watch_profile="" + if [ -n "$watch_profile" ]; then + hook_profile="$watch_profile" + hook_profile_source="this watch's own spawnConfig.profile" + fi +fi + if [ -n "$hook_profile" ]; then # Checked HERE, not left to `agent-box-session add --profile`, which exits 2 # on an unknown profile: that exit would drop the batch, and the events do # not come back. Name charset first (it reaches a file path), then the file. case "$hook_profile" in (*[!A-Za-z0-9_-]*) - echo "agent-box-webhook-spawn: AGENT_BOX_HOOK_PROFILE '$hook_profile' is not a" \ - "valid profile name; starting this session on the box default" >&2 + echo "agent-box-webhook-spawn: profile '$hook_profile' (from" \ + "$hook_profile_source) is not a valid profile name; starting this" \ + "session on the box default" >&2 hook_profile_source="$hook_profile_source — IGNORED, not a valid profile name" hook_profile="" ;; @@ -516,7 +554,8 @@ environment come from that profile ($hook_profile_source) — read it back with printf '%s\n' "No agent profile is set, so a match starts the box default \ harness. Pick a worker for every LATER hook session with: agent-box-profile \ set triage HARNESS=claude MODEL=sonnet EFFORT=low && agent-box-session env set \ -AGENT_BOX_HOOK_PROFILE triage" +AGENT_BOX_HOOK_PROFILE triage — or for THIS watch alone, re-subscribe it with \ +'agent-box-webhook subscribe TOPIC --deliver-to subagent --profile triage'." fi if [ -n "$hook_args_source" ]; then printf 'The arguments after the agent come from %s.\n' "$hook_args_source" diff --git a/tests/golden/web/payloads/agent-box-webhook/bin/agent-box-webhook b/tests/golden/web/payloads/agent-box-webhook/bin/agent-box-webhook index de890ae4..a720ba7d 100644 --- a/tests/golden/web/payloads/agent-box-webhook/bin/agent-box-webhook +++ b/tests/golden/web/payloads/agent-box-webhook/bin/agent-box-webhook @@ -26,7 +26,7 @@ usage: agent-box-webhook subscribe TOPIC [--note TEXT] [--ttl HOURS] [--deliver-to session|subagent] [--renew-on-event] [--ignore-sender LOGIN]... [--when JSON] [--drop JSON] - [--claim SPEC]... + [--claim SPEC]... [--profile NAME] agent-box-webhook unsubscribe TOPIC [--deliver-to session|subagent] agent-box-webhook ls agent-box-webhook status @@ -131,6 +131,22 @@ reports through commit statuses, you stay exposed on that one shape. --claim and --include are mutually exclusive; write --include yourself only for rules --claim cannot express. +--profile NAME (subagent watches only) names the agent profile the sessions +THIS watch spawns start on — a harness, a model, an effort level, an appended +system prompt (agent-box-profile ls). It beats the box-wide +AGENT_BOX_HOOK_PROFILE, so one repo can triage new issues cheaply and put +something stronger on a red build: + + agent-box-webhook subscribe OWNER/REPO --deliver-to subagent \ + --when '{"any":[{"path":"action","in":["opened"]}]}' \ + --profile cheap-triage --note "standing watch: new issues" + +It is stored on the subscription as spawnConfig.profile (webhook.py's +--spawn-config), so `agent-box-webhook ls` shows it. --profile '' clears it. +A profile that does not exist when an event arrives is reported and ignored, +and the session starts on the box default — a delivery is never dropped for +a renamed profile. + --when / --drop attach payload rules to the subscription: deliver (or spawn) ONLY events matching --when, never those matching --drop. Rules are JSON — {"any"/"all": [...]} over {"path": "a.b.c", "in"/"notIn": [values]} @@ -479,7 +495,7 @@ case "$cmd" in ensure_state if [ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ]; then deliver_to=session; have_when=0; have_drop=0; topic=""; want="" - have_include=0; claims="" + have_include=0; claims=""; profile=""; have_profile=0 # Filter --claim out of the argument list as we scan it: webhook.py has # never heard of that flag, so it is translated to --include below and # must not survive into the exec. Rotate idiom — take from the front, @@ -504,6 +520,7 @@ case "$cmd" in exit 2 fi claims="$claims $a"; want=""; continue ;; + (profile) profile="$a"; have_profile=1; want=""; continue ;; esac want="" set -- "$@" "$a"; continue @@ -521,6 +538,13 @@ case "$cmd" in exit 2 fi claims="$claims ${a#--claim=}"; continue ;; + # Filtered out like --claim, and for the same reason: webhook.py + # spells this --spawn-config profile=NAME, and the agent-box word for + # the thing is "profile" (issue #321). Empty is legal — it clears the + # profile on a re-subscribe — so, unlike --claim, only the missing + # VALUE is an error. + --profile) want=profile; continue ;; + --profile=*) profile="${a#--profile=}"; have_profile=1; continue ;; --*) ;; *) [ -n "$topic" ] || topic="$a" ;; esac @@ -530,6 +554,35 @@ case "$cmd" in echo "agent-box-webhook: --$want needs a value" >&2 exit 2 fi + if [ "$have_profile" = 1 ]; then + # A session subscription spawns nothing, so a profile on one would be a + # setting no code path reads. webhook.py refuses it too; saying it here + # names the flag the caller actually typed. + if [ "$deliver_to" != subagent ]; then + echo "agent-box-webhook: --profile only applies to a standing watch —" \ + "it names the worker a spawned session starts on, and a session" \ + "subscription spawns nothing; add --deliver-to subagent" >&2 + exit 2 + fi + case "$profile" in + ("") set -- "$@" --no-spawn-config ;; + (*[!A-Za-z0-9_-]*) + echo "agent-box-webhook: --profile '$profile' is not a valid profile" \ + "name (A-Za-z0-9_-)" >&2 + exit 2 ;; + (*) + # A warning, never a refusal: profiles are runtime data, and + # subscribing a watch before creating its profile is a legitimate + # order to do things in. The spawn falls back to the box default + # and says so if the profile is still missing when an event lands. + if [ ! -r "$HOME/.config/agent-box/profiles/$profile.env" ]; then + echo "agent-box-webhook: no profile '$profile' on this box yet" \ + "(agent-box-profile ls) — subscribing anyway; a match starts" \ + "the box default until you create it" >&2 + fi + set -- "$@" --spawn-config "profile=$profile" ;; + esac + fi if [ -n "$claims" ]; then if [ "$have_include" = 1 ]; then echo "agent-box-webhook: pass --claim OR --include, not both —" \ diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md index f117bf3e..2ef8f84b 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md @@ -160,7 +160,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md index f117bf3e..2ef8f84b 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md @@ -160,7 +160,11 @@ plainly rather than handing it back. profile. Profile env is convenience, not isolation: every session of this user can read it out of /proc. A standing webhook watch hands its work to a profile through `agent-box-session env set AGENT_BOX_HOOK_PROFILE NAME`, - which is the only way to pick the harness a dispatched hook-* session runs. + which is how the harness a dispatched hook-* session runs gets picked at + all. That setting is box-wide; one watch picks its OWN worker with + `agent-box-webhook subscribe TOPIC --deliver-to subagent --profile NAME`, + which beats it. So cheap triage can take new issues while a red build + starts something that can fix it. ## Slash commands: type them into your own pane diff --git a/tests/webhook.nix b/tests/webhook.nix index f0a5c6c1..29b983f6 100644 --- a/tests/webhook.nix +++ b/tests/webhook.nix @@ -1265,6 +1265,162 @@ machine.succeed( f"sudo -u agent env HOME=/home/agent agent-box-session rm {ghosted}" ) + + # --- issue #321, last step: the WATCH names its own profile ------------- + # AGENT_BOX_HOOK_PROFILE above is box-wide: every dispatched session on the + # box gets one worker. What it cannot say is "cheap triage for new issues, + # something that can actually fix things for a red build" — two watches on + # one repo reached the spawn wrapper as the same six strings, all of them + # about the EVENT. local-webhook 0.25.0 adds the seventh, about the WATCH. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " agent-box-profile set boxwide HARNESS=claude MODEL=sonnet" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " agent-box-profile set watchbot HARNESS=codex MODEL=gpt-5.6 EFFORT=low" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " agent-box-session env set AGENT_BOX_HOOK_PROFILE boxwide" + ) + # --profile is agent-box's word for it; webhook.py stores it as + # spawnConfig.profile, so the CLI translates rather than teaching the bus a + # new concept. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/watch-profile" + " --deliver-to subagent --profile watchbot" + " --note 'standing watch: its own worker'" + ) + machine.succeed( + "jq -e '[.topics[] | select(.topic ==" + " \"github:defangdevs/watch-profile\")][0].spawnConfig.profile" + " == \"watchbot\"'" + " /home/agent/.local/state/local-webhook/filter.dispatch.json" + ) + # ...and it comes back out of the listing, where an operator looks. + listing = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main agent-box-webhook ls" + ) + assert "watchbot" in listing, listing + + # The watch beats the box-wide setting: both are in force here, and the + # session starts on the WATCH's harness. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SPAWN_SOURCE=github" + " LOCAL_WEBHOOK_SPAWN_KEY=defangdevs/watch-profile" + " LOCAL_WEBHOOK_SPAWN_TOPIC=github:defangdevs/watch-profile" + " LOCAL_WEBHOOK_SPAWN_CONFIG='{\"profile\": \"watchbot\"}'" + f" {sw}/sh -c 'echo hi | {spawn_cmd}'" + ) + per_watch = machine.succeed( + "jq -r '.sessions | keys[]" + " | select(startswith(\"hook-defangdevs-watch-profile-\"))'" + " /home/agent/.config/agent-box/sessions.json" + ).strip() + machine.succeed( + f"jq -e '.sessions[\"{per_watch}\"].profile == \"watchbot\"" + f" and .sessions[\"{per_watch}\"].agent == \"codex\"'" + " /home/agent/.config/agent-box/sessions.json" + ) + machine.succeed( + f"sudo -u agent env HOME=/home/agent agent-box-session rm {per_watch}" + ) + # An entry that names no profile leaves the box-wide setting standing, so + # the new field never quietly disables the old one. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SPAWN_SOURCE=github" + " LOCAL_WEBHOOK_SPAWN_KEY=defangdevs/no-watch-profile" + " LOCAL_WEBHOOK_SPAWN_TOPIC=github:defangdevs/no-watch-profile" + " LOCAL_WEBHOOK_SPAWN_CONFIG={}" + f" {sw}/sh -c 'echo hi | {spawn_cmd}'" + ) + fellback = machine.succeed( + "jq -r '.sessions | keys[]" + " | select(startswith(\"hook-defangdevs-no-watch-profile-\"))'" + " /home/agent/.config/agent-box/sessions.json" + ).strip() + machine.succeed( + f"jq -e '.sessions[\"{fellback}\"].profile == \"boxwide\"'" + " /home/agent/.config/agent-box/sessions.json" + ) + machine.succeed( + f"sudo -u agent env HOME=/home/agent agent-box-session rm {fellback}" + ) + + # --preamble has no delivery, so it reads the watch out of the dispatch + # file. The settings page prints exactly this, and a page naming the + # box-wide worker for a watch that overrides it would be a lie the operator + # only finds out about from a session that already started (#292). + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + f" {spawn_cmd} --preamble github:defangdevs/watch-profile" + ) + assert "agent profile watchbot" in launch, launch + assert "spawnConfig.profile" in launch, launch + # A topic with no watch-level profile still reports the box-wide one. + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + f" {spawn_cmd} --preamble github:defangdevs/no-watch-profile" + ) + assert "agent profile boxwide" in launch, launch + + # --profile= (empty) clears it on a re-subscribe; two adjacent single + # quotes cannot be written in this Nix string, hence the = form. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/watch-profile" + " --deliver-to subagent --profile=" + ) + machine.succeed( + "jq -e '[.topics[] | select(.topic ==" + " \"github:defangdevs/watch-profile\")][0] | has(\"spawnConfig\") | not'" + " /home/agent/.local/state/local-webhook/filter.dispatch.json" + ) + + # Refused where it would do nothing: a session subscription spawns no + # session, so a profile on one is a setting nothing reads. + machine.fail( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/watch-profile --profile watchbot" + ) + # ...and a name that could escape into a file path is refused outright, + # unlike a merely-absent profile, which is a warning. + machine.fail( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/watch-profile" + " --deliver-to subagent --profile ../escape" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook unsubscribe defangdevs/watch-profile" + " --deliver-to subagent" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent agent-box-profile rm watchbot" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent agent-box-profile rm boxwide" + ) machine.succeed( "sudo -u agent env HOME=/home/agent" " agent-box-session env rm AGENT_BOX_HOOK_PROFILE" From b450ef643ccd333174af2592b3c48361786255bd Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 1 Sep 2026 19:11:56 +0000 Subject: [PATCH 2/6] fix(webhook): pin jq for --preamble, and keep the box-wide profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from CodeRabbit on #488, all real. **jq was not on the settings daemon's PATH.** --preamble has two callers and they do not share a PATH: the receiver unit's has jq, and the settings unit FORCES one of the daemon, coreutils, findutils, gnugrep, gnused and systemd. My new dispatch-file lookup runs there, every jq use in that script is guarded, so a missing binary was silent — the watch panel reported the box-wide worker for a watch that overrides it. Reproduced against the built wrapper with a jq-free PATH: it printed "agent profile boxwide" where jq on PATH printed "agent profile watchbot". jq is now pinned as AGENT_BOX_JQ_BIN, the way flock and the env store already are, which also repairs the same latent hole in the pre-existing hookSessionArgs report. **An unusable watch profile cost the box-wide one.** The watch's name displaced the box-wide value BEFORE validation, so validation cleared both and the session fell all the way to the raw box default. One typo in one watch's --profile silently downgraded every event it matched. Now each candidate is validated before it may displace the next: watch first, box-wide second, box default last, and every rejection is named in the journal and in --preamble. **A non-string profile was stringified.** `.profile // empty` turns {"profile": 5} into "5". webhook.py drops a non-string on read so no delivery carries one, but --preamble reads the hand-editable dispatch file directly. The jq now requires type == "string". Tests: all 24 aarch64 checks green, 77 native. tests/webhook.nix gains the box-wide fallback, the non-string case, and — for the jq pin — an assertion that the SETTINGS PAGE itself names a watch's profile, which is the only place that exercises the daemon's own forced PATH. testScript compiled and ruff-linted; behaviour driven against the built wrapper for all three. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01THnqn1ALGFfof9qg7dArtV --- modules/agent-box.nix | 105 ++++++++++++------ modules/agent-box.nix.in | 6 + modules/src/webhook-spawn.sh | 99 +++++++++++------ .../bin/agent-box-webhook-spawn | 105 ++++++++++++------ tests/webhook.nix | 43 +++++++ 5 files changed, 256 insertions(+), 102 deletions(-) diff --git a/modules/agent-box.nix b/modules/agent-box.nix index b92b2052..75af963e 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -5086,6 +5086,12 @@ exit "$rc" # (util-linux) is not on it, and the hook-session cap check has to hold # the registry lock through the add it decides on. export AGENT_BOX_FLOCK_BIN=${pkgs.util-linux}/bin/flock + # --preamble is run by the SETTINGS DAEMON, whose unit forces a PATH + # without jq (the receiver unit's PATH has it, but that is the other + # caller). Every jq use in this script is guarded, so an unfound binary + # would report the wrong worker rather than fail — pin it like the two + # above instead. + export AGENT_BOX_JQ_BIN=${pkgs.jq}/bin/jq # Which agent a match really starts. The spawn calls `agent-box-session # add` with no --agent, so it is the box default — and --preamble has to # NAME it, because that is the half of "what does this watch launch" the @@ -5118,10 +5124,18 @@ exit "$rc" export AGENT_BOX_HOOK_SESSION_ARGS=${lib.escapeShellArg (builtins.toJSON cfg.webhook.hookSessionArgs)} '' + '' set -eu -# jq/coreutils/agent-box-session resolve from the webhook daemon unit's -# PATH (issue #154, Phase 2) — this script only ever runs as that unit's +# coreutils/agent-box-session resolve from the webhook daemon unit's PATH +# (issue #154, Phase 2) — that unit is what runs this script as its # LOCAL_WEBHOOK_SPAWN_CMD child. -JQ=jq +# +# jq is pinned instead, the AGENT_BOX_*_BIN way the flock and env-store +# dependencies already are, because --preamble has a SECOND caller with a +# different PATH: the settings daemon, whose unit forces a PATH of the daemon +# itself plus coreutils/findutils/gnugrep/gnused/systemd. A bare `jq` there is +# not found, and every use of it in this script is guarded (`|| …`, or an +# `if`), so the failure would be silent — the watch panel would quietly report +# the wrong worker and the wrong arguments. +JQ="''${AGENT_BOX_JQ_BIN:-jq}" # The session registry — where it lives, how it is locked and how it is # rewritten — is one file every shell writer splices in (issue #254). This one # only LOCKS: the write is done by the `agent-box-session add` it execs into, @@ -5508,16 +5522,16 @@ fi # could name a profile that does not exist on the box. An unusable value is # reported and IGNORED below rather than failing the spawn — a webhook # delivery must never be dropped because a profile was renamed. -hook_profile="''${AGENT_BOX_HOOK_PROFILE:-}" -hook_profile_source="" +box_profile="''${AGENT_BOX_HOOK_PROFILE:-}" +box_profile_source="" # Read with the env store's own parser (issue #212), never a fifth copy of the # KEY=value loop: the file may hold a multi-line value now, and only the one # parser knows where such an entry ends. Only these two keys are read, so # nothing else in the file reaches this process. if [ -r "$hook_args_file" ]; then if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_PROFILE); then - hook_profile=$val - hook_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" + box_profile=$val + box_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" fi if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_SESSION_ARGS); then AGENT_BOX_HOOK_SESSION_ARGS=$val @@ -5527,8 +5541,8 @@ fi # A value that did not come from the env file was exported into this process # (a hand-run script, or a unit environment): say so rather than reporting a # source of "". -[ -z "$hook_profile" ] || [ -n "$hook_profile_source" ] \ - || hook_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" +[ -z "$box_profile" ] || [ -n "$box_profile_source" ] \ + || box_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" # The WATCH's own answer, which beats both box-wide settings above — the last # step of issue #321. AGENT_BOX_HOOK_PROFILE names ONE worker for every @@ -5546,6 +5560,7 @@ fi # named TOPIC would start, so that mode reads the watch straight out of the # dispatch file. Same precedence either way, because the page must not advertise # a worker the spawn would not use. +watch_profile="" watch_config="" if [ -n "''${LOCAL_WEBHOOK_SPAWN_CONFIG:-}" ]; then watch_config="$LOCAL_WEBHOOK_SPAWN_CONFIG" @@ -5558,35 +5573,57 @@ elif [ "''${1:-}" = "--preamble" ] && [ -n "''${2:-}" ] && [ -n "''${LOCAL_WEBHO "$LOCAL_WEBHOOK_STATE_DIR/filter.dispatch.json" 2>/dev/null) || watch_config="" fi if [ -n "$watch_config" ]; then - watch_profile=$("$JQ" -r 'if type == "object" then (.profile // empty) else empty end' \ + # `.profile` must be a STRING. webhook.py drops a non-string value when it + # reads the filter file, so a delivery never carries one — but --preamble + # reads that file directly, and the file is documented as hand-editable, so + # `{"profile": 5}` would otherwise render as the profile "5". + watch_profile=$("$JQ" -r \ + 'if type == "object" and (.profile | type) == "string" then .profile else empty end' \ <<<"$watch_config" 2>/dev/null) || watch_profile="" - if [ -n "$watch_profile" ]; then - hook_profile="$watch_profile" - hook_profile_source="this watch's own spawnConfig.profile" - fi fi -if [ -n "$hook_profile" ]; then - # Checked HERE, not left to `agent-box-session add --profile`, which exits 2 - # on an unknown profile: that exit would drop the batch, and the events do - # not come back. Name charset first (it reaches a file path), then the file. - case "$hook_profile" in - (*[!A-Za-z0-9_-]*) - echo "agent-box-webhook-spawn: profile '$hook_profile' (from" \ - "$hook_profile_source) is not a valid profile name; starting this" \ - "session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, not a valid profile name" - hook_profile="" - ;; - (*) - if [ ! -r "$HOME/.config/agent-box/profiles/$hook_profile.env" ]; then - echo "agent-box-webhook-spawn: no such profile '$hook_profile'" \ - "(agent-box-profile ls); starting this session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, no such profile" - hook_profile="" - fi - ;; +# ''' when NAME can be launched, else why not. Checked HERE, not left to +# `agent-box-session add --profile`, which exits 2 on an unknown profile: that +# exit would drop the batch, and the events do not come back. Charset first, +# because the name reaches a file path. +profile_problem() { + case "$1" in + (*[!A-Za-z0-9_-]*) printf 'not a valid profile name'; return 0 ;; esac + [ -r "$HOME/.config/agent-box/profiles/$1.env" ] || printf 'no such profile' +} + +# Most specific first, and each candidate is validated BEFORE it is allowed to +# displace the next one. An unusable watch profile must not cost the box-wide +# one: that would make naming a profile on one watch quietly downgrade every +# event it matches from the operator's chosen worker to the raw box default, +# for a typo in a field the box-wide setting has nothing to do with. +hook_profile="" +hook_profile_source="" +hook_profile_ignored="" +try_profile() { + [ -n "$1" ] || return 0 + [ -z "$hook_profile" ] || return 0 + why=$(profile_problem "$1") + if [ -z "$why" ]; then + hook_profile="$1" + hook_profile_source="$2" + return 0 + fi + echo "agent-box-webhook-spawn: ignoring profile '$1' from $2 — $why" >&2 + hook_profile_ignored="''${hook_profile_ignored:+$hook_profile_ignored; }$2 — IGNORED, $why" +} + +try_profile "$watch_profile" "this watch's own spawnConfig.profile" +try_profile "$box_profile" "$box_profile_source" +if [ -n "$hook_profile" ]; then + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on profile" \ + "'$hook_profile' ($hook_profile_source) instead" >&2 +else + hook_profile_source="$hook_profile_ignored" + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on the box default" >&2 fi extra=() diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index 1e18e46a..ad95aa5d 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -753,6 +753,12 @@ let # (util-linux) is not on it, and the hook-session cap check has to hold # the registry lock through the add it decides on. export AGENT_BOX_FLOCK_BIN=${pkgs.util-linux}/bin/flock + # --preamble is run by the SETTINGS DAEMON, whose unit forces a PATH + # without jq (the receiver unit's PATH has it, but that is the other + # caller). Every jq use in this script is guarded, so an unfound binary + # would report the wrong worker rather than fail — pin it like the two + # above instead. + export AGENT_BOX_JQ_BIN=${pkgs.jq}/bin/jq # Which agent a match really starts. The spawn calls `agent-box-session # add` with no --agent, so it is the box default — and --preamble has to # NAME it, because that is the half of "what does this watch launch" the diff --git a/modules/src/webhook-spawn.sh b/modules/src/webhook-spawn.sh index 652bdc1e..06824df6 100644 --- a/modules/src/webhook-spawn.sh +++ b/modules/src/webhook-spawn.sh @@ -1,8 +1,16 @@ set -eu -# jq/coreutils/agent-box-session resolve from the webhook daemon unit's -# PATH (issue #154, Phase 2) — this script only ever runs as that unit's +# coreutils/agent-box-session resolve from the webhook daemon unit's PATH +# (issue #154, Phase 2) — that unit is what runs this script as its # LOCAL_WEBHOOK_SPAWN_CMD child. -JQ=jq +# +# jq is pinned instead, the AGENT_BOX_*_BIN way the flock and env-store +# dependencies already are, because --preamble has a SECOND caller with a +# different PATH: the settings daemon, whose unit forces a PATH of the daemon +# itself plus coreutils/findutils/gnugrep/gnused/systemd. A bare `jq` there is +# not found, and every use of it in this script is guarded (`|| …`, or an +# `if`), so the failure would be silent — the watch panel would quietly report +# the wrong worker and the wrong arguments. +JQ="${AGENT_BOX_JQ_BIN:-jq}" # The session registry — where it lives, how it is locked and how it is # rewritten — is one file every shell writer splices in (issue #254). This one # only LOCKS: the write is done by the `agent-box-session add` it execs into, @@ -160,16 +168,16 @@ fi # could name a profile that does not exist on the box. An unusable value is # reported and IGNORED below rather than failing the spawn — a webhook # delivery must never be dropped because a profile was renamed. -hook_profile="${AGENT_BOX_HOOK_PROFILE:-}" -hook_profile_source="" +box_profile="${AGENT_BOX_HOOK_PROFILE:-}" +box_profile_source="" # Read with the env store's own parser (issue #212), never a fifth copy of the # KEY=value loop: the file may hold a multi-line value now, and only the one # parser knows where such an entry ends. Only these two keys are read, so # nothing else in the file reaches this process. if [ -r "$hook_args_file" ]; then if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_PROFILE); then - hook_profile=$val - hook_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" + box_profile=$val + box_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" fi if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_SESSION_ARGS); then AGENT_BOX_HOOK_SESSION_ARGS=$val @@ -179,8 +187,8 @@ fi # A value that did not come from the env file was exported into this process # (a hand-run script, or a unit environment): say so rather than reporting a # source of "". -[ -z "$hook_profile" ] || [ -n "$hook_profile_source" ] \ - || hook_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" +[ -z "$box_profile" ] || [ -n "$box_profile_source" ] \ + || box_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" # The WATCH's own answer, which beats both box-wide settings above — the last # step of issue #321. AGENT_BOX_HOOK_PROFILE names ONE worker for every @@ -198,6 +206,7 @@ fi # named TOPIC would start, so that mode reads the watch straight out of the # dispatch file. Same precedence either way, because the page must not advertise # a worker the spawn would not use. +watch_profile="" watch_config="" if [ -n "${LOCAL_WEBHOOK_SPAWN_CONFIG:-}" ]; then watch_config="$LOCAL_WEBHOOK_SPAWN_CONFIG" @@ -210,35 +219,57 @@ elif [ "${1:-}" = "--preamble" ] && [ -n "${2:-}" ] && [ -n "${LOCAL_WEBHOOK_STA "$LOCAL_WEBHOOK_STATE_DIR/filter.dispatch.json" 2>/dev/null) || watch_config="" fi if [ -n "$watch_config" ]; then - watch_profile=$("$JQ" -r 'if type == "object" then (.profile // empty) else empty end' \ + # `.profile` must be a STRING. webhook.py drops a non-string value when it + # reads the filter file, so a delivery never carries one — but --preamble + # reads that file directly, and the file is documented as hand-editable, so + # `{"profile": 5}` would otherwise render as the profile "5". + watch_profile=$("$JQ" -r \ + 'if type == "object" and (.profile | type) == "string" then .profile else empty end' \ <<<"$watch_config" 2>/dev/null) || watch_profile="" - if [ -n "$watch_profile" ]; then - hook_profile="$watch_profile" - hook_profile_source="this watch's own spawnConfig.profile" - fi fi -if [ -n "$hook_profile" ]; then - # Checked HERE, not left to `agent-box-session add --profile`, which exits 2 - # on an unknown profile: that exit would drop the batch, and the events do - # not come back. Name charset first (it reaches a file path), then the file. - case "$hook_profile" in - (*[!A-Za-z0-9_-]*) - echo "agent-box-webhook-spawn: profile '$hook_profile' (from" \ - "$hook_profile_source) is not a valid profile name; starting this" \ - "session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, not a valid profile name" - hook_profile="" - ;; - (*) - if [ ! -r "$HOME/.config/agent-box/profiles/$hook_profile.env" ]; then - echo "agent-box-webhook-spawn: no such profile '$hook_profile'" \ - "(agent-box-profile ls); starting this session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, no such profile" - hook_profile="" - fi - ;; +# '' when NAME can be launched, else why not. Checked HERE, not left to +# `agent-box-session add --profile`, which exits 2 on an unknown profile: that +# exit would drop the batch, and the events do not come back. Charset first, +# because the name reaches a file path. +profile_problem() { + case "$1" in + (*[!A-Za-z0-9_-]*) printf 'not a valid profile name'; return 0 ;; esac + [ -r "$HOME/.config/agent-box/profiles/$1.env" ] || printf 'no such profile' +} + +# Most specific first, and each candidate is validated BEFORE it is allowed to +# displace the next one. An unusable watch profile must not cost the box-wide +# one: that would make naming a profile on one watch quietly downgrade every +# event it matches from the operator's chosen worker to the raw box default, +# for a typo in a field the box-wide setting has nothing to do with. +hook_profile="" +hook_profile_source="" +hook_profile_ignored="" +try_profile() { + [ -n "$1" ] || return 0 + [ -z "$hook_profile" ] || return 0 + why=$(profile_problem "$1") + if [ -z "$why" ]; then + hook_profile="$1" + hook_profile_source="$2" + return 0 + fi + echo "agent-box-webhook-spawn: ignoring profile '$1' from $2 — $why" >&2 + hook_profile_ignored="${hook_profile_ignored:+$hook_profile_ignored; }$2 — IGNORED, $why" +} + +try_profile "$watch_profile" "this watch's own spawnConfig.profile" +try_profile "$box_profile" "$box_profile_source" +if [ -n "$hook_profile" ]; then + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on profile" \ + "'$hook_profile' ($hook_profile_source) instead" >&2 +else + hook_profile_source="$hook_profile_ignored" + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on the box default" >&2 fi extra=() diff --git a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn index a197bc24..2b3fd0c4 100644 --- a/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn +++ b/tests/golden/web/payloads/agent-box-webhook-spawn/bin/agent-box-webhook-spawn @@ -4,6 +4,12 @@ # (util-linux) is not on it, and the hook-session cap check has to hold # the registry lock through the add it decides on. export AGENT_BOX_FLOCK_BIN=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-util-linux-2.42.2-bin/bin/flock +# --preamble is run by the SETTINGS DAEMON, whose unit forces a PATH +# without jq (the receiver unit's PATH has it, but that is the other +# caller). Every jq use in this script is guarded, so an unfound binary +# would report the wrong worker rather than fail — pin it like the two +# above instead. +export AGENT_BOX_JQ_BIN=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-jq-1.8.2-bin/bin/jq # Which agent a match really starts. The spawn calls `agent-box-session # add` with no --agent, so it is the box default — and --preamble has to # NAME it, because that is the half of "what does this watch launch" the @@ -21,10 +27,18 @@ export AGENT_BOX_ENVSTORE_BIN=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-agent- # currently set to anything. export AGENT_BOX_HOOK_ARGS_OPTION_NAME='the NixOS option services.agent-box.webhook.hookSessionArgs' set -eu -# jq/coreutils/agent-box-session resolve from the webhook daemon unit's -# PATH (issue #154, Phase 2) — this script only ever runs as that unit's +# coreutils/agent-box-session resolve from the webhook daemon unit's PATH +# (issue #154, Phase 2) — that unit is what runs this script as its # LOCAL_WEBHOOK_SPAWN_CMD child. -JQ=jq +# +# jq is pinned instead, the AGENT_BOX_*_BIN way the flock and env-store +# dependencies already are, because --preamble has a SECOND caller with a +# different PATH: the settings daemon, whose unit forces a PATH of the daemon +# itself plus coreutils/findutils/gnugrep/gnused/systemd. A bare `jq` there is +# not found, and every use of it in this script is guarded (`|| …`, or an +# `if`), so the failure would be silent — the watch panel would quietly report +# the wrong worker and the wrong arguments. +JQ="${AGENT_BOX_JQ_BIN:-jq}" # The session registry — where it lives, how it is locked and how it is # rewritten — is one file every shell writer splices in (issue #254). This one # only LOCKS: the write is done by the `agent-box-session add` it execs into, @@ -411,16 +425,16 @@ fi # could name a profile that does not exist on the box. An unusable value is # reported and IGNORED below rather than failing the spawn — a webhook # delivery must never be dropped because a profile was renamed. -hook_profile="${AGENT_BOX_HOOK_PROFILE:-}" -hook_profile_source="" +box_profile="${AGENT_BOX_HOOK_PROFILE:-}" +box_profile_source="" # Read with the env store's own parser (issue #212), never a fifth copy of the # KEY=value loop: the file may hold a multi-line value now, and only the one # parser knows where such an entry ends. Only these two keys are read, so # nothing else in the file reaches this process. if [ -r "$hook_args_file" ]; then if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_PROFILE); then - hook_profile=$val - hook_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" + box_profile=$val + box_profile_source="AGENT_BOX_HOOK_PROFILE in $hook_args_file" fi if val=$("$ENVSTORE" --file "$hook_args_file" get AGENT_BOX_HOOK_SESSION_ARGS); then AGENT_BOX_HOOK_SESSION_ARGS=$val @@ -430,8 +444,8 @@ fi # A value that did not come from the env file was exported into this process # (a hand-run script, or a unit environment): say so rather than reporting a # source of "". -[ -z "$hook_profile" ] || [ -n "$hook_profile_source" ] \ - || hook_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" +[ -z "$box_profile" ] || [ -n "$box_profile_source" ] \ + || box_profile_source="the AGENT_BOX_HOOK_PROFILE environment variable" # The WATCH's own answer, which beats both box-wide settings above — the last # step of issue #321. AGENT_BOX_HOOK_PROFILE names ONE worker for every @@ -449,6 +463,7 @@ fi # named TOPIC would start, so that mode reads the watch straight out of the # dispatch file. Same precedence either way, because the page must not advertise # a worker the spawn would not use. +watch_profile="" watch_config="" if [ -n "${LOCAL_WEBHOOK_SPAWN_CONFIG:-}" ]; then watch_config="$LOCAL_WEBHOOK_SPAWN_CONFIG" @@ -461,35 +476,57 @@ elif [ "${1:-}" = "--preamble" ] && [ -n "${2:-}" ] && [ -n "${LOCAL_WEBHOOK_STA "$LOCAL_WEBHOOK_STATE_DIR/filter.dispatch.json" 2>/dev/null) || watch_config="" fi if [ -n "$watch_config" ]; then - watch_profile=$("$JQ" -r 'if type == "object" then (.profile // empty) else empty end' \ + # `.profile` must be a STRING. webhook.py drops a non-string value when it + # reads the filter file, so a delivery never carries one — but --preamble + # reads that file directly, and the file is documented as hand-editable, so + # `{"profile": 5}` would otherwise render as the profile "5". + watch_profile=$("$JQ" -r \ + 'if type == "object" and (.profile | type) == "string" then .profile else empty end' \ <<<"$watch_config" 2>/dev/null) || watch_profile="" - if [ -n "$watch_profile" ]; then - hook_profile="$watch_profile" - hook_profile_source="this watch's own spawnConfig.profile" - fi fi -if [ -n "$hook_profile" ]; then - # Checked HERE, not left to `agent-box-session add --profile`, which exits 2 - # on an unknown profile: that exit would drop the batch, and the events do - # not come back. Name charset first (it reaches a file path), then the file. - case "$hook_profile" in - (*[!A-Za-z0-9_-]*) - echo "agent-box-webhook-spawn: profile '$hook_profile' (from" \ - "$hook_profile_source) is not a valid profile name; starting this" \ - "session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, not a valid profile name" - hook_profile="" - ;; - (*) - if [ ! -r "$HOME/.config/agent-box/profiles/$hook_profile.env" ]; then - echo "agent-box-webhook-spawn: no such profile '$hook_profile'" \ - "(agent-box-profile ls); starting this session on the box default" >&2 - hook_profile_source="$hook_profile_source — IGNORED, no such profile" - hook_profile="" - fi - ;; +# '' when NAME can be launched, else why not. Checked HERE, not left to +# `agent-box-session add --profile`, which exits 2 on an unknown profile: that +# exit would drop the batch, and the events do not come back. Charset first, +# because the name reaches a file path. +profile_problem() { + case "$1" in + (*[!A-Za-z0-9_-]*) printf 'not a valid profile name'; return 0 ;; esac + [ -r "$HOME/.config/agent-box/profiles/$1.env" ] || printf 'no such profile' +} + +# Most specific first, and each candidate is validated BEFORE it is allowed to +# displace the next one. An unusable watch profile must not cost the box-wide +# one: that would make naming a profile on one watch quietly downgrade every +# event it matches from the operator's chosen worker to the raw box default, +# for a typo in a field the box-wide setting has nothing to do with. +hook_profile="" +hook_profile_source="" +hook_profile_ignored="" +try_profile() { + [ -n "$1" ] || return 0 + [ -z "$hook_profile" ] || return 0 + why=$(profile_problem "$1") + if [ -z "$why" ]; then + hook_profile="$1" + hook_profile_source="$2" + return 0 + fi + echo "agent-box-webhook-spawn: ignoring profile '$1' from $2 — $why" >&2 + hook_profile_ignored="${hook_profile_ignored:+$hook_profile_ignored; }$2 — IGNORED, $why" +} + +try_profile "$watch_profile" "this watch's own spawnConfig.profile" +try_profile "$box_profile" "$box_profile_source" +if [ -n "$hook_profile" ]; then + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on profile" \ + "'$hook_profile' ($hook_profile_source) instead" >&2 +else + hook_profile_source="$hook_profile_ignored" + [ -z "$hook_profile_ignored" ] \ + || echo "agent-box-webhook-spawn: starting this session on the box default" >&2 fi extra=() diff --git a/tests/webhook.nix b/tests/webhook.nix index 29b983f6..16390a21 100644 --- a/tests/webhook.nix +++ b/tests/webhook.nix @@ -1376,6 +1376,30 @@ ) assert "agent profile boxwide" in launch, launch + # An unusable WATCH profile must not cost the box-wide one. It used to: + # the watch's name displaced the box-wide one BEFORE validation, so one + # typo in one watch's --profile downgraded every event it matched from the + # operator's chosen worker to the raw box default. + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SPAWN_CONFIG='{\"profile\": \"ghost\"}'" + f" {spawn_cmd} --preamble github:defangdevs/no-watch-profile" + ) + assert "agent profile boxwide" in launch, launch + assert "IGNORED, no such profile" not in launch, launch + # A non-string value is ignored rather than stringified into a name. + # webhook.py drops one when it reads the filter file, so no delivery + # carries it — but that file is documented as hand-editable, and + # --preamble reads it directly. + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SPAWN_CONFIG='{\"profile\": 5}'" + f" {spawn_cmd} --preamble github:defangdevs/no-watch-profile" + ) + assert "agent profile boxwide" in launch, launch + # --profile= (empty) clears it on a re-subscribe; two adjacent single # quotes cannot be written in this Nix string, hence the = form. machine.succeed( @@ -1827,8 +1851,27 @@ " LOCAL_WEBHOOK_SESSION=agent-main" " agent-box-webhook subscribe defangdevs/panel --note 'shown in the UI' --ttl 8" ) + # A standing watch with its own --profile, rendered by the DAEMON. The + # panel prints the spawn wrapper's --preamble, and that lookup reads the + # dispatch file with jq — which the settings unit's forced PATH does not + # carry (it is the daemon, coreutils, findutils, gnugrep, gnused, systemd). + # jq is pinned as AGENT_BOX_JQ_BIN for exactly this: every jq use in that + # script is guarded, so an unfound binary is silent, and the panel would + # name the box-wide worker for a watch that overrides it. + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " agent-box-profile set panelbot HARNESS=codex MODEL=gpt-5.6" + ) + machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + " LOCAL_WEBHOOK_SESSION=agent-main" + " agent-box-webhook subscribe defangdevs/panel-profile" + " --deliver-to subagent --profile panelbot" + ) page = machine.succeed(f"{settings_curl} {settings_page}") for want in [ + "agent profile panelbot", # the watch's own worker, via jq "github:defangdevs/panel", # this session's own subscription... "shown in the UI", # ...and the note saying why it exists 'data-fold="subs-main"', # folded under the session it delivers to From b830f319964c055a54d9b175f1fc98068beea2da Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 1 Sep 2026 19:18:24 +0000 Subject: [PATCH 3/6] fix(native): supply AGENT_BOX_JQ_BIN from the native backend too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend-parity caught what my local run did not: e9bb009 pinned jq in the module's webhookSpawn derivation only, so a native box's webhook-spawn.sh still resolved a bare `jq` — and its --preamble caller, the settings daemon, is exactly the PATH that does not have one. Native pins it in the /etc/agent-box/bin/agent-box-webhook-spawn wrapper it already generates for the same purpose (issue #471's hook_env), from the runtime profile that ships jq. My "all 24 checks green" on the previous commit was wrong, and worth recording why: the run was `nix build ... 2>&1 | tail -3; echo $?`, so the status reported was tail's, not nix's. Re-run without the pipe: NIX_BUILD_RC=0 over all 24, plus 77 native tests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01THnqn1ALGFfof9qg7dArtV --- bin/agentbox | 8 ++++++++ .../expected/etc/agent-box/bin/agent-box-webhook-spawn | 1 + 2 files changed, 9 insertions(+) diff --git a/bin/agentbox b/bin/agentbox index d1288469..f59e7e03 100755 --- a/bin/agentbox +++ b/bin/agentbox @@ -2666,6 +2666,14 @@ class Renderer: # the session CLI) has no flock on it. f"export AGENT_BOX_FLOCK_BIN=" f"{shlex.quote(f'{self.bin}/flock')}\n" + # webhook-spawn.sh reads the dispatch file with jq to find + # the watch's own agent profile (issue #321), and its + # --preamble caller is the settings daemon, whose PATH does + # not carry jq. Every jq use in that script is guarded, so + # an unfound binary reports the wrong worker instead of + # failing. Pinned here for the same reason the module pins + # it into webhookSpawn's own derivation. + f"export AGENT_BOX_JQ_BIN={shlex.quote(f'{self.bin}/jq')}\n" # Which agent a match starts, for --preamble's report: # the spawn calls `agent-box-session add` with no # --agent, so it is the box default. Unset, the report diff --git a/tests/native/expected/etc/agent-box/bin/agent-box-webhook-spawn b/tests/native/expected/etc/agent-box/bin/agent-box-webhook-spawn index eee8fb2b..272e5596 100755 --- a/tests/native/expected/etc/agent-box/bin/agent-box-webhook-spawn +++ b/tests/native/expected/etc/agent-box/bin/agent-box-webhook-spawn @@ -1,6 +1,7 @@ #!/bin/sh # Generated by `agentbox apply` — do not edit. export AGENT_BOX_FLOCK_BIN=@PROFILE@/bin/flock +export AGENT_BOX_JQ_BIN=@PROFILE@/bin/jq export AGENT_BOX_DEFAULT_AGENT=claude export AGENT_BOX_ENVSTORE_BIN=@PROFILE@/bin/agent-box-envstore export AGENT_BOX_HOOK_ARGS_OPTION_NAME='webhook.hookSessionArgs in @CONFIG@' From 28127c7abd8b40eacc5929c9cfcee247f18761a3 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 1 Sep 2026 19:35:16 +0000 Subject: [PATCH 4/6] docs(webhook): name the per-(key, spawnConfig) dispatch queue This commit used to also move the local-channels pin onto the 0.25.0 merge commit. master has since pinned 0.26.0 (789b374), which contains that merge, so the rebase drops the pin hunk and keeps only the prose it carried. The behaviour the prose describes is unchanged and still worth stating in the `rev` option's description: dispatch batches queue per (key, spawnConfig) rather than per key, so two watches on one repo cannot coalesce into a single spawn that hands one watch's events to the other's worker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rt3Sp5XwGZy1Uzto8qP4is --- modules/agent-box.nix | 5 ++++- modules/agent-box.nix.in | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 75af963e..205b0060 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -9190,7 +9190,10 @@ in one describes the WATCH, which is what `agent-box-webhook subscribe --profile NAME` writes and what lets a watch name the agent profile its own sessions start on. AGENT_BOX_HOOK_PROFILE stays the box-wide - fallback. + fallback. The same release queues dispatch batches per (key, + spawnConfig) rather than per key, so two watches on one repo cannot + coalesce into a single spawn that would hand one watch's events to + the other's worker. 0.23.0 (issue #380) retired the built-in failures-only CI brake for rule-less dispatch entries: a `--deliver-to subagent` diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index ad95aa5d..8a5ae2e4 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -1954,7 +1954,10 @@ in one describes the WATCH, which is what `agent-box-webhook subscribe --profile NAME` writes and what lets a watch name the agent profile its own sessions start on. AGENT_BOX_HOOK_PROFILE stays the box-wide - fallback. + fallback. The same release queues dispatch batches per (key, + spawnConfig) rather than per key, so two watches on one repo cannot + coalesce into a single spawn that would hand one watch's events to + the other's worker. 0.23.0 (issue #380) retired the built-in failures-only CI brake for rule-less dispatch entries: a `--deliver-to subagent` From eeeb5a615ae86ebbfee8700f255dc73ae7f6d160 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 1 Sep 2026 19:49:23 +0000 Subject: [PATCH 5/6] test(webhook): exercise both --preamble guards through the dispatch file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit on #488, and it is the right test to want. My two guard assertions set LOCAL_WEBHOOK_SPAWN_CONFIG, which is the DELIVERY input — but nothing sets that variable for the settings page. There, --preamble is handed a topic and goes and reads filter.dispatch.json itself, so a regression that stringified a numeric profile only in the file branch would have sailed past both. Both values now go into the dispatch file as a box could really hold them (it is documented as hand-editable), and the same two assertions run against the topic instead of the variable: numeric 5 and a non-existent name both leave the valid box-wide profile in force, and neither renders as "agent profile 5". The file is backed up and restored around the edit so the later panel assertions see the state they expect. Verified against the built wrapper first: with {"profile": 5} in the file --preamble reports boxwide, and with {"profile": "ghost"} it reports boxwide after naming what it ignored. NIX_BUILD_RC=0 over all 24 aarch64 checks, native tests 0, x86 webhook check still evaluates. testScript compiled and ruff-linted; the jq expression run standalone first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01THnqn1ALGFfof9qg7dArtV --- tests/webhook.nix | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/webhook.nix b/tests/webhook.nix index 16390a21..9c5d5f21 100644 --- a/tests/webhook.nix +++ b/tests/webhook.nix @@ -1267,6 +1267,7 @@ ) # --- issue #321, last step: the WATCH names its own profile ------------- + dispatch_file = "/home/agent/.local/state/local-webhook/filter.dispatch.json" # AGENT_BOX_HOOK_PROFILE above is box-wide: every dispatched session on the # box gets one worker. What it cannot say is "cheap triage for new issues, # something that can actually fix things for a red build" — two watches on @@ -1400,6 +1401,34 @@ ) assert "agent profile boxwide" in launch, launch + # Both guards again, this time through the path --preamble actually uses + # in production: the DISPATCH FILE. Nothing sets LOCAL_WEBHOOK_SPAWN_CONFIG + # for the settings page — it names a topic and the script goes and reads + # the watch — so a regression that stringified a numeric profile only in + # the file branch would sail past the two assertions above. The file is + # documented as hand-editable, so these are values a box can really hold. + machine.succeed(f"cp {dispatch_file} /tmp/dispatch.bak") + machine.succeed( + "jq '.topics += [" + " {topic: \"github:defangdevs/numeric-profile\", ttlHours: 0," + " include: {path: \"event\", in: [\"issues\"]}," + " spawnConfig: {profile: 5}}," + " {topic: \"github:defangdevs/ghost-watch\", ttlHours: 0," + " include: {path: \"event\", in: [\"issues\"]}," + " spawnConfig: {profile: \"ghost\"}}]'" + f" {dispatch_file} > /tmp/hand-edited.json" + ) + machine.succeed(f"sudo -u agent cp /tmp/hand-edited.json {dispatch_file}") + for topic in ("numeric-profile", "ghost-watch"): + launch = machine.succeed( + "sudo -u agent env HOME=/home/agent" + " LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook" + f" {spawn_cmd} --preamble github:defangdevs/{topic}" + ) + assert "agent profile boxwide" in launch, (topic, launch) + assert "agent profile 5" not in launch, (topic, launch) + machine.succeed(f"sudo -u agent cp /tmp/dispatch.bak {dispatch_file}") + # --profile= (empty) clears it on a re-subscribe; two adjacent single # quotes cannot be written in this Nix string, hence the = form. machine.succeed( From 7b75417e9fbbe10279c962114e046bb1be5356e0 Mon Sep 17 00:00:00 2001 From: defangdevs Date: Wed, 2 Sep 2026 00:42:46 +0000 Subject: [PATCH 6/6] docs(webhook): one paragraph names the pin, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase kept the 0.25.0 prose while taking master's 0.26.0 pin, so both release paragraphs claimed to be "this pin". The 0.26.0 one is right — the pinned webhook.py declares 0.26.0 — so the 0.25.0 paragraph keeps its issue reference and gives up the claim. CodeRabbit on PR #488. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rt3Sp5XwGZy1Uzto8qP4is --- modules/agent-box.nix | 2 +- modules/agent-box.nix.in | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 205b0060..671e59e1 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -9182,7 +9182,7 @@ in the marketplace itself and tracks its default branch — this pin only governs the copy the box runs. - 0.25.0 (this pin, issue #321) lets a dispatch entry carry + 0.25.0 (issue #321) lets a dispatch entry carry `spawnConfig`, a flat map of strings the receiver hands to the spawn command in `LOCAL_WEBHOOK_SPAWN_CONFIG`. Every other `LOCAL_WEBHOOK_SPAWN_*` variable describes the EVENT, so two watches diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index 8a5ae2e4..76307342 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -1946,7 +1946,7 @@ in the marketplace itself and tracks its default branch — this pin only governs the copy the box runs. - 0.25.0 (this pin, issue #321) lets a dispatch entry carry + 0.25.0 (issue #321) lets a dispatch entry carry `spawnConfig`, a flat map of strings the receiver hands to the spawn command in `LOCAL_WEBHOOK_SPAWN_CONFIG`. Every other `LOCAL_WEBHOOK_SPAWN_*` variable describes the EVENT, so two watches