companion-inference-listener.mjs
153 lines 4.8 KB
Raw
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