companion-inference-listener.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Companion App — Phase 5 guarded inference listener. |
| 3 | * |
| 4 | * Binds an OS-assigned loopback port and applies `verifyLoopbackRequest` before any |
| 5 | * runtime work is invoked. The listener never emits permissive CORS headers. |
| 6 | */ |
| 7 | |
| 8 | import http from 'node:http'; |
| 9 | |
| 10 | import { |
| 11 | createLoopbackRateState, |
| 12 | recordLoopbackRequest, |
| 13 | shouldCountTowardRateLimit, |
| 14 | verifyLoopbackRequest, |
| 15 | } from './companion-loopback-guard.mjs'; |
| 16 | |
| 17 | export const INFERENCE_LISTENER_REASONS = Object.freeze({ |
| 18 | BIND_NOT_LOOPBACK: 'bind_not_loopback', |
| 19 | NOT_LISTENING: 'not_listening', |
| 20 | RUNTIME_UNAVAILABLE: 'runtime_unavailable', |
| 21 | }); |
| 22 | |
| 23 | const LOOPBACK_BINDS = new Set(['127.0.0.1', '::1']); |
| 24 | |
| 25 | /** |
| 26 | * Build the exact Host allowlist for a bound loopback port. |
| 27 | * @param {{ host: string, port: number }} params |
| 28 | * @returns {string[]} |
| 29 | */ |
| 30 | export function buildAllowedHosts({ host, port }) { |
| 31 | if (!Number.isInteger(port) || port < 1 || port > 65535) { |
| 32 | throw new TypeError(INFERENCE_LISTENER_REASONS.NOT_LISTENING); |
| 33 | } |
| 34 | if (host === '::1') return [`[::1]:${port}`]; |
| 35 | if (host === '127.0.0.1') return [`127.0.0.1:${port}`, `localhost:${port}`]; |
| 36 | throw new TypeError(INFERENCE_LISTENER_REASONS.BIND_NOT_LOOPBACK); |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * Extract the bearer token from a request without inspecting the body. |
| 41 | * @param {import('node:http').IncomingMessage} req |
| 42 | * @returns {string} |
| 43 | */ |
| 44 | export function extractLoopbackBearerToken(req) { |
| 45 | const header = req.headers.authorization; |
| 46 | if (typeof header === 'string' && header.toLowerCase().startsWith('bearer ')) { |
| 47 | return header.slice(7).trim(); |
| 48 | } |
| 49 | const companionHeader = req.headers['x-knowtation-companion-token']; |
| 50 | return typeof companionHeader === 'string' ? companionHeader.trim() : ''; |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Create a loopback-only HTTP listener with the Phase 2 guard as the front door. |
| 55 | * @param {{ |
| 56 | * host?: '127.0.0.1'|'::1', |
| 57 | * expectedToken: string, |
| 58 | * runtimeRequest: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>|void, |
| 59 | * now?: () => number, |
| 60 | * rateState?: ReturnType<typeof createLoopbackRateState>, |
| 61 | * verify?: typeof verifyLoopbackRequest, |
| 62 | * createServer?: typeof http.createServer, |
| 63 | * }} params |
| 64 | */ |
| 65 | export function createCompanionInferenceListener(params) { |
| 66 | const { |
| 67 | host = '127.0.0.1', |
| 68 | expectedToken, |
| 69 | runtimeRequest, |
| 70 | now = () => Date.now(), |
| 71 | verify = verifyLoopbackRequest, |
| 72 | createServer = http.createServer, |
| 73 | } = params ?? {}; |
| 74 | |
| 75 | if (!LOOPBACK_BINDS.has(host)) { |
| 76 | throw new TypeError(INFERENCE_LISTENER_REASONS.BIND_NOT_LOOPBACK); |
| 77 | } |
| 78 | if (typeof expectedToken !== 'string' || expectedToken.length === 0) { |
| 79 | throw new TypeError('expected_token_required'); |
| 80 | } |
| 81 | if (typeof runtimeRequest !== 'function') { |
| 82 | throw new TypeError(INFERENCE_LISTENER_REASONS.RUNTIME_UNAVAILABLE); |
| 83 | } |
| 84 | |
| 85 | let rateState = params?.rateState ?? createLoopbackRateState(); |
| 86 | let allowedHosts = []; |
| 87 | |
| 88 | const server = createServer(async (req, res) => { |
| 89 | const verdict = verify({ |
| 90 | method: req.method, |
| 91 | headers: req.headers, |
| 92 | token: extractLoopbackBearerToken(req), |
| 93 | expectedToken, |
| 94 | allowedHosts, |
| 95 | now: now(), |
| 96 | rateState, |
| 97 | }); |
| 98 | |
| 99 | if (shouldCountTowardRateLimit(verdict)) { |
| 100 | rateState = recordLoopbackRequest(rateState, now()); |
| 101 | } |
| 102 | |
| 103 | if (!verdict.allow) { |
| 104 | res.statusCode = verdict.status; |
| 105 | res.setHeader('Content-Type', 'application/json'); |
| 106 | res.setHeader('Cache-Control', 'no-store'); |
| 107 | res.end(JSON.stringify({ ok: false, reason: verdict.reason })); |
| 108 | return; |
| 109 | } |
| 110 | |
| 111 | try { |
| 112 | await runtimeRequest(req, res); |
| 113 | } catch { |
| 114 | res.statusCode = 503; |
| 115 | res.setHeader('Content-Type', 'application/json'); |
| 116 | res.setHeader('Cache-Control', 'no-store'); |
| 117 | res.end(JSON.stringify({ ok: false, reason: INFERENCE_LISTENER_REASONS.RUNTIME_UNAVAILABLE })); |
| 118 | } |
| 119 | }); |
| 120 | |
| 121 | return { |
| 122 | server, |
| 123 | get allowedHosts() { |
| 124 | return [...allowedHosts]; |
| 125 | }, |
| 126 | get port() { |
| 127 | const address = server.address(); |
| 128 | return address && typeof address === 'object' ? address.port : null; |
| 129 | }, |
| 130 | async start() { |
| 131 | await new Promise((resolve, reject) => { |
| 132 | server.once('error', reject); |
| 133 | server.listen(0, host, () => { |
| 134 | server.off('error', reject); |
| 135 | resolve(); |
| 136 | }); |
| 137 | }); |
| 138 | const address = server.address(); |
| 139 | if (!address || typeof address !== 'object' || !LOOPBACK_BINDS.has(address.address)) { |
| 140 | await this.close(); |
| 141 | throw new Error(INFERENCE_LISTENER_REASONS.BIND_NOT_LOOPBACK); |
| 142 | } |
| 143 | allowedHosts = buildAllowedHosts({ host: address.address, port: address.port }); |
| 144 | return { host: address.address, port: address.port, allowedHosts: [...allowedHosts] }; |
| 145 | }, |
| 146 | async close() { |
| 147 | if (!server.listening) return; |
| 148 | await new Promise((resolve, reject) => { |
| 149 | server.close((err) => (err ? reject(err) : resolve())); |
| 150 | }); |
| 151 | }, |
| 152 | }; |
| 153 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago