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(authenticatepure auth,handleIngressRequesttransport gating,HttpIOsingle-shotChannelIO,readBodysize cap,createIngressHandlernode:http wrapper,parseIngressTokensenv→config);src/index.ts(wiresPOST /ingressinto 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
- Pure transport, same core.
POST /ingresswith a JSON body{ text, channel?, thread?, history? }becomes anIncomingMessageand is handed to the unchangeddispatch(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 messagereceivedAt, handing both todispatch(deps, msg, io, { trace })— theDispatchFnseam's optional fourth argument (tracing.md item 18); the async path's root ends when the background dispatch does. - 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 optionalhistoryarray from the body, else[]. Run receipt: when the core created a run for the request — an agent run, or an inline command run such asfriction propose— the core callsChannelIO.runFinished({ id, status })once the run is finished in the registry, and the response carries it asrun: { id, status }(status∈completed | 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 norunfield. This is how the Worker shim learns which run a scheduled firing became. - Bearer auth, fail-closed.
authenticate(headers, config)requiresAuthorization: Bearer <token>; a missing, malformed, or unknown token yieldsnull→401. If no tokens are configured the endpoint is disabled →503 {error:"disabled"}, never open. The map istoken -> { subject, channel?, scopes? }; the mapped subject becomes theuserId, 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 thehttp:<subject>(andmcp:<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 malformedscopesskips the entry rather than widening it. Starting a run requires thedispatchscope: a token whosescopesomit it (a registry-only credential such as["runs:read"]) is refused at this endpoint with403 {error:"forbidden", code:"unauthorized"}— decided from the headers (requireDispatchScope, before the body is read) anddispatch()is never called. A token may pin achannelthat overrides the body's channel (locks the config scope). - Constant-time compares, no token logging. Tokens are compared with
crypto.timingSafeEqualover 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. - Input hardening. The body is size-capped at read time (~1 MB,
413before it is fully buffered); invalid JSON, a non-object body, a missing/blanktext, or a malformedhistoryentry return400; a non-POST method returns405. Malformed env token config is treated as "no tokens" (disabled), never as open. - 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. - Async mode (
"async": true). A machine caller (e.g. an automation dispatching a coding brief) that only needs the acknowledgement can add"async": trueto the body: validation and authorization are exactly the sync path's (the async branch is only reached afterauthorizeRequest,requireDispatchScope, andparseBody; a non-booleanasyncis a400, and the token's scopes and channel pin apply unchanged), thendispatch()is started, not awaited, and the response is202 Acceptedwith{ runId, runUrl, threadKey }the moment the core has created the run in the registry (the new optionalChannelIO.runStarted({ id })hook, fired at everyregistry.createsite).runUrlis<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 becausedispatch()increments the in-flight counter on its first line, before the202is written. An async request the core answers without creating a run (a config reply) falls back to the sync200 { reply }shape rather than hanging. Withoutasync(or with"async": false) the behavior is byte-identical to before. - Wiring keeps health working. The
PORTserver routes/ingressto the ingress handler and every other path to the existingokhealth probe. With no tokens configured the startup log says ingress is DISABLED.
Validation criteria
| Criterion | Evidence |
|---|---|
| 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": true → 202 { 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 |