Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ jobs:
- name: Check downloads route in generated Caddyfile
run: nix build -L .#checks.x86_64-linux.download-route && cat result

# Eval/build check (issue 101): the webhook ingress route exists, comes
# before the /<user>/* catch-all, and carries NO basic_auth — GitHub
# cannot authenticate, so an auth gate there silently drops deliveries.
- name: Check webhook route in generated Caddyfile
run: nix build -L .#checks.x86_64-linux.webhook-route && cat result

# Full closure build — the real "module is usable" proof.
- name: Build VM system closure
run: nix build -L .#checks.x86_64-linux.vm-closure
Expand Down Expand Up @@ -114,6 +120,12 @@ jobs:
# session, agent-box-session add/rm with no sudo and
# no rebuild, cgroup containment, root session
# manager page behind auth
# webhook (issue 101) the default-on webhook receiver —
# socket-activated 0660 <user>:caddy ingress, the
# unauthenticated public path beside a still-401ing
# vhost, HMAC accept/reject (404 before any secret
# exists), IPC fan-out into a session peer applying
# its own filter, and the discovery surface
- name: Run VM tests
run: |
nix build -L --keep-going --max-jobs 3 \
Expand All @@ -122,4 +134,5 @@ jobs:
.#checks.x86_64-linux.download-files \
.#checks.x86_64-linux.settings-page \
.#checks.x86_64-linux.memory-protection \
.#checks.x86_64-linux.sessions
.#checks.x86_64-linux.sessions \
.#checks.x86_64-linux.webhook
74 changes: 64 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,47 @@ tells the agent about this route, so "send me that file" just works. For
unauthenticated sharing, an agent can instead run its own web service and
expose it via `~/sites` (see the seeded `AGENTS.md`).

## Webhooks: the agent gets told, instead of polling (web setups)

An agent waiting on CI, a review, or a teammate's push otherwise loops on
`gh pr checks` and `sleep` — slow, and it burns context re-reading state it
already had. Each web user instead gets a webhook endpoint whose deliveries
land **in the session** as messages:

```bash
agent-box-webhook setup # mint an HMAC secret, print the URL
# register that URL + secret in the repo (Settings -> Webhooks -> Add webhook)
agent-box-webhook subscribe OWNER/REPO --note "PR 42: waiting on CI"
agent-box-webhook ls # what this session listens to
```

On by default (`webhook.enable`), needs `web.enable`. How it fits together:

- A **receiver-only daemon** per web user (`agent-box-webhook-<user>`) owns a
socket-activated UNIX ingress at `/run/agent-box-webhook/<user>.sock`, bound
`0660 <user>:caddy` by systemd — the same isolation model as the settings
socket. It keeps the box's one endpoint up regardless of which sessions exist.
- Caddy reverse-proxies `https://<domain>/<user>/webhook` there **without basic
auth**: GitHub can't authenticate, so the per-source **HMAC-SHA256** check is
the trust boundary. Nothing is reachable until a user runs
`agent-box-webhook setup` — with no source configured every POST is answered
`404`, and a configured source rejects anything unsigned (`401`).
- The daemon verifies once, then fans each event out over IPC to that user's
sessions. Subscriptions are **per session** and expire (1h by default), so a
straggler can't wake a session whose context is long gone. Payload text is
truncated and marked `⟪UNTRUSTED⟫` — the agent is told to read it as data.
- Claude Code sessions additionally get `webhook_subscribe` /
`webhook_unsubscribe` / `webhook_subscriptions` as MCP tools, from the
[local-channels](https://github.com/defangdevs/local-channels) `local-webhook`
plugin (enabled non-interactively via seeded `~/.claude/settings.json`). The
CLI and the tools share one subscription list, so Codex and shell sessions are
not second-class.

Any sender that HMAC-signs its raw body works, not just GitHub —
`agent-box-webhook setup stripe` adds a second source at
`/<user>/webhook/stripe`. Set `services.agent-box.webhook.enable = false` to
omit the daemon, socket, and public path entirely.

## VM image

Build from the same config:
Expand Down Expand Up @@ -329,6 +370,8 @@ All under `services.agent-box`:
| `sudoAllowlist` | `[]` | Passwordless sudo commands granted to every agent. |
| `extraPackages` | `[]` | Packages placed on each agent's PATH. |
| `environmentFiles` | `[]` | Extra `EnvironmentFile` paths applied to every agent. |
| `webhook.enable` | `true` | Per-user webhook receiver (see above): an unauthenticated `/<user>/webhook` ingress whose HMAC-verified deliveries reach that user's agent sessions, plus the `agent-box-webhook` CLI. No-op without `web.enable`; inert until a user runs `agent-box-webhook setup`. |
| `webhook.repo` / `.rev` / `.sha256` | pinned `defangdevs/local-channels` | Source and pin of the `local-webhook` script the daemon and CLI run. |
| `protectMemory` | `true` | zram swap (zstd, sized to RAM), earlyoom, and `OOMScoreAdjust=500` on agent units, so runaway agent memory gets its process killed (and auto-restarted) instead of livelocking the whole box. All knobs are `mkDefault` - tune or disable pieces from the host config. |

## Security model
Expand All @@ -355,16 +398,27 @@ arbitrary command execution as the agent user.
containment for scoped elevation.
- **Tight sudo:** whatever's in `sudoAllowlist` is the entire root-capable
surface. `NOPASSWD` only - no `SETENV`, no blanket sudo, no ALL.
- **Nothing anonymous, brute-force damping (web deployments):** every path
on the vhost — terminal workspace, per-session terminals, settings, and the
`/<user>/downloads/` file drop — sits behind the
login (the CI tests assert the 401s), new password hashes use Caddy's
recommended Argon2id algorithm (legacy bcrypt hashes remain accepted until
the password changes), and a fail2ban jail bans IPs that repeatedly fail it
(default on, `web.fail2ban`; it only counts requests that actually carried
credentials). Discovery isn't assumed to be hard — the ACME cert for
`<ip>.sslip.io` lands in public CT logs minutes after launch — auth is
simply required everywhere.
- **Login on everything a human reaches, brute-force damping (web
deployments):** the terminal workspace, per-session terminals, settings, and
the `/<user>/downloads/` file drop all sit behind the login (the CI tests
assert the 401s), new password hashes use Caddy's recommended Argon2id
algorithm (legacy bcrypt hashes remain accepted until the password changes),
and a fail2ban jail bans IPs that repeatedly fail it (default on,
`web.fail2ban`; it only counts requests that actually carried credentials).
Discovery isn't assumed to be hard — the ACME cert for `<ip>.sslip.io` lands
in public CT logs minutes after launch — so auth is required, not relied on
being unguessable.
- **One deliberate exception: `/<user>/webhook`.** Webhook senders can't do
basic auth, so that path is served without it and a per-source
**HMAC-SHA256** signature over the raw body is the trust boundary instead
(see [Webhooks](#webhooks-the-agent-gets-told-instead-of-polling-web-setups)).
It fails closed in both directions: with no source configured every POST gets
`404`, and a configured source rejects anything whose signature doesn't
verify with `401` — so a box where nobody ran `agent-box-webhook setup`
accepts nothing at all. A bad-signature `401` also can't feed the fail2ban
jail into banning a real sender: the jail's regex requires an `Authorization`
header on the request, and webhook senders don't send one. Set
`webhook.enable = false` to remove the path.
- **User-scoped secrets file:** each user's tokens live in their own
`~/.config/agent-box/env` (0600, inside the 0700 `~/.config/agent-box`),
written only by that user's settings daemon / `agent-box-session env` —
Expand Down
60 changes: 60 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,66 @@
printf 'downloads route present in generated Caddyfile\n' > "$out"
'';

# Interactive VM test (issue #101): the per-user webhook receiver, ON
# BY DEFAULT. Socket-activated 0660 <user>:caddy ingress, the
# unauthenticated public path next to a still-401ing vhost, HMAC
# accept/reject (and 404 before any secret exists — why default-on is
# safe), IPC fan-out into a stand-in session peer applying its own
# filter, and the discovery surface (CLI on PATH,
# AGENT_BOX_WEBHOOK_URL, per-session LOCAL_WEBHOOK_SESSION, seeded
# claude plugin settings).
webhook = pkgs.testers.runNixOSTest
(import ./tests/webhook.nix { agent-box = self.nixosModules.agent-box; });

# Guard (issue #101): the module's REAL generated Caddyfile (the VM
# test above swaps in a `tls internal` stand-in) must route the
# webhook path to the user's ingress socket, BEFORE the /<user>/*
# catch-all and with no basic_auth in that handle — GitHub cannot
# authenticate, so an auth gate here means silently dropped
# deliveries. Cheap: realises only the tiny rendered config.
webhook-route =
let
sys = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
self.nixosModules.agent-box
({ modulesPath, ... }: { imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ]; })
{
services.agent-box = {
enable = true;
agent = "claude";
users.agent.web.passwordHashFile = "/var/lib/agent-box-web/password-hash";
web = {
enable = true;
domain = "webhook.test";
user = "agent";
};
};
system.stateVersion = "25.05";
}
];
};
in
pkgs.runCommand "agent-box-webhook-route-ok"
{ caddyfile = sys.config.services.caddy.configFile; } ''
# The handle and its ENTIRE body — three lines, then the closing
# brace at the handle's own indentation. Asserting the whole body
# is what makes "no basic_auth in here" meaningful: grepping the
# file at large would hit the terminal/settings blocks, which are
# supposed to have one.
grep -A3 -F 'handle /agent/webhook* {' "$caddyfile" > block
grep -qF 'uri strip_prefix /agent/webhook' block
grep -qF 'reverse_proxy unix//run/agent-box-webhook/agent.sock' block
grep -qxF ' }' block
! grep -q basic_auth block
# Ordering: Caddy prefers the more specific matcher, but keep the
# emitted order honest too — the webhook handle must come first.
wh=$(grep -n 'handle /agent/webhook\*' "$caddyfile" | cut -d: -f1)
catchall=$(grep -n 'handle /agent/\* {' "$caddyfile" | cut -d: -f1)
[ "$wh" -lt "$catchall" ]
printf 'unauthenticated webhook route present in generated Caddyfile\n' > "$out"
'';

# Regression guard (issue #51): deployed boxes fetch
# modules/agent-box.nix as a SINGLE file — the CFN user-data and
# agent-box-update.service both fetchurl just that path — so the
Expand Down
Loading