Skip to content

Storyboard webhook receiver cannot serve a containerised run: loopback-only bind, and no TLS despite accepting https public URLs #2448

Description

@KonstantinMirin

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions