Storyboard webhook receiver cannot serve a containerised run: loopback-only bind, and no TLS despite accepting https public URLs
Package: @adcp/sdk@9.3.0 (adcp storyboard run)
Summary
Two gaps in the ephemeral webhook receiver make the expect_webhook* bundles
ungradable for a containerised run, which is the shape any CI that runs the agent
under test in Docker will have:
- Bind address.
createWebhookReceiver() accepts a host option and the runner
forwards it, but the CLI never populates it — so the receiver always binds
127.0.0.1 and cannot receive a delivery from another container.
- TLS.
validateProxyUrl accepts an https: public URL, but the receiver is
built on node:http with no TLS path. An operator can advertise https:// and
the receiver will never be able to serve it.
Neither surfaces as an error. The affected steps come back as
requirement_unmet: webhook_receiver: storyboard requires a runtime that is not available on this run, which reads as "the operator chose not to host a receiver"
rather than "the receiver cannot be reached in this topology".
The topology this makes impossible
A docker compose stack where the agent under test and the conformance runner are
separate services on a shared network:
adcp-server — the agent under test; emits the webhooks being graded
tests — the container running adcp storyboard run, joined to the same
network with a stable DNS alias (docker compose run --use-aliases)
- no published host ports on any service; everything is addressed by service name
The agent is configured to call the runner back by that alias rather than by
localhost — deliberately, so the address is not rewritten to
host.docker.internal — and this reverse-addressing half already works for our own
webhook tests. The delivery leaves adcp-server, resolves tests to that
container's address, and arrives on its eth0. Nothing is bound there, because the
receiver is on 127.0.0.1 inside the same container.
A host-side run — runner on the CI machine, stack publishing ports to 127.0.0.1 —
works today only because the runner shares a network namespace with those published
ports. We are moving away from that shape deliberately: published ports are a global
resource on the runner, so concurrent stacks contend for them, and a host-side runner
has no stable name that a container can dial back. Neither problem exists in-network,
which is why the receiver's bind address is the last thing standing in the way.
TLS matters for the same topology: our e2e stack is moving to TLS shortly, at which
point the agent will be delivering to https:// endpoints and a plaintext-only
receiver cannot terminate them.
Where it happens
// dist/lib/testing/storyboard/webhook-receiver.js:89
const host = options.host ?? '127.0.0.1';
// :103 — http only; there is no https/TLS branch anywhere in this module
const server = (0, node_http_1.createServer)((req, res) => handleRequest(...));
// :114
server.listen(port, host, () => { ... })
The runner passes host through when present:
// dist/lib/testing/storyboard/runner.js:1950 and :3184
...(options.webhook_receiver.host !== undefined && { host: options.webhook_receiver.host }),
Both CLI construction sites omit it:
// bin/adcp.js:2809
webhook_receiver: {
mode,
...(port !== undefined && { port }),
...(publicUrl !== undefined && { public_url: publicUrl }),
}
// bin/adcp.js:3191
webhook_receiver: { mode: 'proxy_url', port, public_url: publicUrl },
--help lists --webhook-receiver, --webhook-receiver-port,
--webhook-receiver-public-url, --webhook-receiver-auto-tunnel — no host, no TLS.
webhook-receiver.js reads no process.env, so there is no environment escape hatch.
Why --webhook-receiver proxy does not already solve it
proxy_url is the documented answer for "publicly reachable", and its validation
accepts plain http: (validateProxyUrl, :73), so an in-network URL is a legal
value. But public_url only changes the address the runner advertises; the socket
still binds 127.0.0.1, because the CLI cannot pass host.
So --webhook-receiver proxy --webhook-receiver-public-url http://tests:9999/
advertises a reachable name pointing at a socket that accepts nothing off-host. The
delivery is refused, and the failure is attributed to the agent rather than to the
harness — strictly worse than the current skip.
Confirmed against 9.3.0 that the library layer is already correct. Calling
createWebhookReceiver({ mode: 'proxy_url', host: '0.0.0.0', port: 19998, public_url: 'http://tests:19998/' }) directly binds all interfaces and advertises
the supplied URL:
advertised base_url = http://tests:19998 | mode = proxy_url
LISTEN: node ... TCP *:19998 (LISTEN)
Only the CLI plumbing is missing.
To be explicit, since it is easy to misread as the fix: the loopback_mock guard at
:93-96 that rejects 0.0.0.0 is correct and should stay. An unauthenticated open
POST endpoint on a CI runner is a real hazard. This request is only about
proxy_url, where the operator has already supplied an explicit public URL and
thereby accepted that exposure.
Requested change
--webhook-receiver-host HOST Bind address for the ephemeral receiver
(default 127.0.0.1). Only valid with
--webhook-receiver proxy, which already requires
an explicit --webhook-receiver-public-url.
--webhook-receiver-tls-cert FILE Serve the receiver over TLS. Required when
--webhook-receiver-tls-key FILE --webhook-receiver-public-url is https://.
The host half is a two-line change at the two webhook_receiver: {...} sites —
runner.js and createWebhookReceiver() already accept and honour host, and the
existing loopback_mock guard keeps working unchanged.
The TLS half needs a real branch in createWebhookReceiver (node:https.createServer
when cert/key are supplied). Worth pairing with a validation fix either way: today
public_url may be https: while the receiver can only ever speak http:, so that
combination should either be served or rejected rather than silently advertised.
Env-var equivalents (ADCP_WEBHOOK_RECEIVER_HOST and friends) would also work and
are easier for images that wrap the CLI, but the flags are the clearer contract.
Storyboard webhook receiver cannot serve a containerised run: loopback-only bind, and no TLS despite accepting https public URLs
Package:
@adcp/sdk@9.3.0(adcp storyboard run)Summary
Two gaps in the ephemeral webhook receiver make the
expect_webhook*bundlesungradable for a containerised run, which is the shape any CI that runs the agent
under test in Docker will have:
createWebhookReceiver()accepts ahostoption and the runnerforwards it, but the CLI never populates it — so the receiver always binds
127.0.0.1and cannot receive a delivery from another container.validateProxyUrlaccepts anhttps:public URL, but the receiver isbuilt on
node:httpwith no TLS path. An operator can advertisehttps://andthe receiver will never be able to serve it.
Neither surfaces as an error. The affected steps come back as
requirement_unmet: webhook_receiver: storyboard requires a runtime that is not available on this run, which reads as "the operator chose not to host a receiver"rather than "the receiver cannot be reached in this topology".
The topology this makes impossible
A
docker composestack where the agent under test and the conformance runner areseparate services on a shared network:
adcp-server— the agent under test; emits the webhooks being gradedtests— the container runningadcp storyboard run, joined to the samenetwork with a stable DNS alias (
docker compose run --use-aliases)The agent is configured to call the runner back by that alias rather than by
localhost— deliberately, so the address is not rewritten tohost.docker.internal— and this reverse-addressing half already works for our ownwebhook tests. The delivery leaves
adcp-server, resolvesteststo thatcontainer's address, and arrives on its
eth0. Nothing is bound there, because thereceiver is on
127.0.0.1inside the same container.A host-side run — runner on the CI machine, stack publishing ports to
127.0.0.1—works today only because the runner shares a network namespace with those published
ports. We are moving away from that shape deliberately: published ports are a global
resource on the runner, so concurrent stacks contend for them, and a host-side runner
has no stable name that a container can dial back. Neither problem exists in-network,
which is why the receiver's bind address is the last thing standing in the way.
TLS matters for the same topology: our e2e stack is moving to TLS shortly, at which
point the agent will be delivering to
https://endpoints and a plaintext-onlyreceiver cannot terminate them.
Where it happens
The runner passes
hostthrough when present:Both CLI construction sites omit it:
--helplists--webhook-receiver,--webhook-receiver-port,--webhook-receiver-public-url,--webhook-receiver-auto-tunnel— no host, no TLS.webhook-receiver.jsreads noprocess.env, so there is no environment escape hatch.Why
--webhook-receiver proxydoes not already solve itproxy_urlis the documented answer for "publicly reachable", and its validationaccepts plain
http:(validateProxyUrl, :73), so an in-network URL is a legalvalue. But
public_urlonly changes the address the runner advertises; the socketstill binds
127.0.0.1, because the CLI cannot passhost.So
--webhook-receiver proxy --webhook-receiver-public-url http://tests:9999/advertises a reachable name pointing at a socket that accepts nothing off-host. The
delivery is refused, and the failure is attributed to the agent rather than to the
harness — strictly worse than the current skip.
Confirmed against 9.3.0 that the library layer is already correct. Calling
createWebhookReceiver({ mode: 'proxy_url', host: '0.0.0.0', port: 19998, public_url: 'http://tests:19998/' })directly binds all interfaces and advertisesthe supplied URL:
Only the CLI plumbing is missing.
To be explicit, since it is easy to misread as the fix: the
loopback_mockguard at:93-96 that rejects
0.0.0.0is correct and should stay. An unauthenticated openPOST endpoint on a CI runner is a real hazard. This request is only about
proxy_url, where the operator has already supplied an explicit public URL andthereby accepted that exposure.
Requested change
The host half is a two-line change at the two
webhook_receiver: {...}sites —runner.jsandcreateWebhookReceiver()already accept and honourhost, and theexisting
loopback_mockguard keeps working unchanged.The TLS half needs a real branch in
createWebhookReceiver(node:https.createServerwhen cert/key are supplied). Worth pairing with a validation fix either way: today
public_urlmay behttps:while the receiver can only ever speakhttp:, so thatcombination should either be served or rejected rather than silently advertised.
Env-var equivalents (
ADCP_WEBHOOK_RECEIVER_HOSTand friends) would also work andare easier for images that wrap the CLI, but the flags are the clearer contract.