Skip to content

HTTP ingress: authenticated channel adapter #3

The core is channel-agnostic: a channel is only a transport that turns an inbound request into an IncomingMessage, calls the core dispatch(), and provides a ChannelIO to reply through. Slack (adapter #1) and the CLI (adapter #2) already prove this; HTTP is adapter #3 — the same request from an HTTP client reaches the same agents under the same permissions, satisfying the AGENTS.md ≥2-implementations invariant for the channel boundary. MCP ingress is a separate follow-up.

HTTP is single-shot request/response, unlike Slack's long-lived threads. The endpoint is our own service (not behind a platform's auth), so it owns its security in-band: bearer-token auth, fail-closed, constant-time compares, identity mapped from the token into the namespaced IncomingMessage so the existing canRunAgent/canUseRepo gates apply unchanged.

  • Code: src/channels/http.ts (authenticate pure auth, handleIngressRequest transport gating, HttpIO single-shot ChannelIO, readBody size cap, createIngressHandler node:http wrapper, parseIngressTokens env→config); src/index.ts (wires POST /ingress into the existing http server alongside the health probe).
  • Tests: src/channels/http.test.ts.
  • Docs: AGENTS.md invariants 1, 2, 3, 4, How a request flows, Worker topology.

Behavior

  1. Pure transport, same core. POST /ingress with a JSON body { text, channel?, thread?, history? } becomes an IncomingMessage and is handed to the unchanged dispatch(deps, msg, io) — no dispatcher, runner, or permission change. Identities are platform-namespaced (invariant 4), mirroring Slack: userId = http:<subject>, channelId = http:<channel|default>, threadKey = http:<channel>:<thread|default>. Once the caller is identified and the body parsed, the adapter starts the request's root and stamps the message receivedAt, handing both to dispatch(deps, msg, io, { trace }) — the DispatchFn seam's optional fourth argument (tracing.md item 18); the async path's root ends when the background dispatch does.
  2. Single-shot ChannelIO. reply() collects text and the collected text is the HTTP response body ({ reply }); status() is an honest no-op handle (no live surface to edit in one shot); history() replays the optional history array from the body, else []. Run receipt: when the core created a run for the request — an agent run, or an inline command run such as friction propose — the core calls ChannelIO.runFinished({ id, status }) once the run is finished in the registry, and the response carries it as run: { id, status } (statuscompleted | failed | stopped_soft | stopped_hard). The receipt names the run (/runs/<id>), it never carries the view token. A request that produced no run (a config reply) has no run field. This is how the Worker shim learns which run a scheduled firing became.
  3. Bearer auth, fail-closed. authenticate(headers, config) requires Authorization: Bearer <token>; a missing, malformed, or unknown token yields null401. If no tokens are configured the endpoint is disabled503 {error:"disabled"}, never open. The map is token -> { subject, channel?, scopes? }; the mapped subject becomes the userId, so a token can only ever act as its assigned identity and existing gates apply unchanged. scopes (default ["dispatch"] = this endpoint only) additionally names the command-registry actions the token holds — they are the grants of the http:<subject> (and mcp:<subject>) actor, e.g. ["dispatch","runs:read"], and the policy table decides every registry command from them on every surface, a text command sent through this endpoint included (authorization.md item 9, command-registry.md item 6); a malformed scopes skips the entry rather than widening it. Starting a run requires the dispatch scope: a token whose scopes omit it (a registry-only credential such as ["runs:read"]) is refused at this endpoint with 403 {error:"forbidden", code:"unauthorized"} — decided from the headers (requireDispatchScope, before the body is read) and dispatch() is never called. A token may pin a channel that overrides the body's channel (locks the config scope).
  4. Constant-time compares, no token logging. Tokens are compared with crypto.timingSafeEqual over equal-length buffers (length guarded first); the loop checks every configured token without short-circuiting, so neither the presence nor the position of a match is a timing oracle. Token material is never logged.
  5. Input hardening. The body is size-capped at read time (~1 MB, 413 before it is fully buffered); invalid JSON, a non-object body, a missing/blank text, or a malformed history entry return 400; a non-POST method returns 405. Malformed env token config is treated as "no tokens" (disabled), never as open.
  6. Authorization precedes body-buffering. Method (405), the disabled check (503), and bearer auth (401) are decided from headers alone (authorizeRequest) before the body is read, so an unauthorized/wrong-method/disabled caller never buffers a body — shrinking the pre-auth request surface.
  7. Async mode ("async": true). A machine caller (e.g. an automation dispatching a coding brief) that only needs the acknowledgement can add "async": true to the body: validation and authorization are exactly the sync path's (the async branch is only reached after authorizeRequest, requireDispatchScope, and parseBody; a non-boolean async is a 400, and the token's scopes and channel pin apply unchanged), then dispatch() is started, not awaited, and the response is 202 Accepted with { runId, runUrl, threadKey } the moment the core has created the run in the registry (the new optional ChannelIO.runStarted({ id }) hook, fired at every registry.create site). runUrl is <PUBLIC_BASE_URL>/runs/<id> (path-only when unset). The run continues to completion in the background: its reply text goes to the run record — never to any HTTP response — and its history record lands as usual, including for failed and hard-stopped runs (the dispatcher's own finally owns the record). The shutdown drain awaits async runs exactly like synchronous ones because dispatch() increments the in-flight counter on its first line, before the 202 is written. An async request the core answers without creating a run (a config reply) falls back to the sync 200 { reply } shape rather than hanging. Without async (or with "async": false) the behavior is byte-identical to before.
  8. Wiring keeps health working. The PORT server routes /ingress to the ingress handler and every other path to the existing ok health probe. With no tokens configured the startup log says ingress is DISABLED.

Validation criteria

CriterionEvidence
Valid token maps to its identity; wrong/unknown/missing/malformed token → null; all tokens checked (position-independent); array header handled[unit] src/channels/http.test.ts::authenticate (bearer auth, constant-time)::*
Valid request → dispatch() called with the correctly namespaced IncomingMessage; collected reply returned as { reply } (200)[unit] ::handleIngressRequest (transport gating + dispatch)::valid token → dispatch called with the namespaced IncomingMessage; reply returned
Namespacing: channel/thread defaults; token-pinned channel overrides body[unit] ::defaults channel/thread when the body omits them, ::a token-pinned channel overrides the body's channel
Fail-closed: no tokens configured → 503 disabled, dispatch never called[unit] ::no tokens configured → 503 disabled, dispatch never called (fail-closed)
Missing/invalid token → 401, dispatch never called[unit] ::missing/invalid token → 401, dispatch never called
A token without the dispatch scope → 403 {error:"forbidden", code:"unauthorized"}, dispatch never called, decided before the body is read; a default-scoped token still dispatches[unit] ::handleIngressRequest — the dispatch scope (fail-closed)::*
Invalid JSON / missing-blank text / bad history → 400; non-POST → 405[unit] ::invalid JSON → 400, dispatch never called, ::missing/blank text → 400, ::malformed history → 400; well-formed history reaches io.history(), ::non-POST → 405
HttpIO: reply collection, no-op status, history replay/default[unit] ::HttpIO (single-shot ChannelIO)::*
Run receipt: response carries run: {id, status} when the core finished a run (truthful failed/stopped statuses), never a token; absent when no run was created; HttpIO.run() undefined until runFinished[unit] ::run receipt in the response …::*
The token map has ONE parser shared with the Worker shim: parseIngressTokens delegates to src/core/ingressTokens.ts (parseIngressTokenMap, tokenForSubject — ambiguous subject → undefined)[unit] src/core/ingressTokens.test.ts::*, src/channels/http.test.ts::parseIngressTokens (env → config, fail-closed)::*
Body size cap: reads under cap, rejects over cap; wrapper answers 413 and destroys the request[unit] ::readBody (size cap)::*, ::createIngressHandler (node:http wrapper)::answers 413 and destroys the request when the body exceeds the cap
node:http wrapper reads body, dispatches, writes 200 JSON[unit] ::createIngressHandler (node:http wrapper)::reads the body, dispatches, and writes a 200 JSON reply
Pre-auth: an unauthorized request is rejected from headers without reading the body[unit] ::createIngressHandler (node:http wrapper)::rejects an unauthorized request without reading the body (pre-auth); ::authorizeRequest (header-only gate)::*
Env→config parsing is fail-closed: valid map parsed; unset/blank/malformed → disabled; malformed entries skipped without opening[unit] src/channels/http.test.ts::parseIngressTokens (env → config, fail-closed)::*
Async mode: "async": true202 { runId, runUrl, threadKey } before the run finishes; runUrl honors PUBLIC_BASE_URL (path-only fallback); channel pin reflected in threadKey; no-run request falls back to sync 200[unit] ::async mode ("async": true → 202 Accepted, run continues in background)::answers 202 with runId, runUrl (PUBLIC_BASE_URL) and threadKey before the run finishes, ::async mode ("async": true → 202 Accepted, run continues in background)::runUrl degrades to a path when no publicBaseUrl is configured, ::async mode ("async": true → 202 Accepted, run continues in background)::the token's channel pin applies on the async path (threadKey reflects the pinned channel), ::async mode ("async": true → 202 Accepted, run continues in background)::an async request the core answers WITHOUT a run (no runStarted) falls back to the sync 200 shape
Async path authorization unchanged: unknown token → 401, dispatch-less token → 403, non-boolean async → 400 — dispatch never called[unit] ::async mode ("async": true → 202 Accepted, run continues in background)::authorization applies unchanged on the async path: unknown token → 401, dispatch never called, ::async mode ("async": true → 202 Accepted, run continues in background)::a dispatch-less token is refused on the async path too (403, fail-closed), ::async mode ("async": true→ 202 Accepted, run continues in background)::a non-booleanasync → 400, dispatch never called
Sync path byte-identical without async (and with "async": false)[unit] ::async mode ("async": true→ 202 Accepted, run continues in background)::the sync path is unchanged: same body withoutasync → 200 with the reply, ::async mode ("async": true → 202 Accepted, run continues in background)::async: false behaves exactly like omitting it
Drain: the async run counts in flight after the 202 and completes in the background with its runFinished receipt[unit] ::async mode ("async": true → 202 Accepted, run continues in background)::drain semantics: the run counts in flight after the 202 and completes in the background with its receipt
Live: an authed POST /ingress reaches an agent end-to-end; unauthenticated is refused[agent] Requires the bot deployed with SWITCHBOARD_INGRESS_TOKENS set and PORT exposed: POST /ingress with a configured bearer and {"text":"…"} → 200 { reply }; the same request without the header → 401.
Item 1: the dispatched message carries receivedAt[unit] ::handleIngressRequest (transport gating + dispatch)::valid token → dispatch called with the namespaced IncomingMessage; reply returned