mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* fix(auth): honor permissions.takes_holders for legacy bearer tokens over serve --http (#2529) The OAuth provider's legacy access_tokens branch parsed permissions.source_id but never read permissions.takes_holders, so the /mcp dispatch site's fail-closed default pinned every remote caller to world-only takes visibility — set-takes-holders was a silent no-op over serve --http, in both directions (grants above world never applied; restrictions below world didn't either). - src/core/legacy-token-scope.ts: new parseTakesHoldersAllowList shared by BOTH transports (the drift between the legacy HTTP transport's correct inline parse and the OAuth provider is how this bug shipped). [] preserved as explicit deny-all; non-array → undefined → consumer defaults ['world']. - src/core/operations.ts: AuthInfo.takesHoldersAllowList typed field (same ride-along as sourceId/allowedSources). - src/core/oauth-provider.ts: legacy branch threads the stored grant. OAuth-client tokens unchanged (no per-client storage — TODO filed). - src/mcp/http-transport.ts: converged on the shared helper (behavior no-op). - src/commands/serve-http.ts: sidecar cast replaced by the typed field. - src/core/facts/meta-hook.ts: hashAllowList gives [] its own cache key (cache identity only — payload filtering stays visibility-based). Tests: 7 verifyAccessToken cases (grant/absent/garbage/deny-all/mixed/ oauth-client/column-default), pure-helper describe, meta-hook cache-key pin, and a Postgres e2e (test/e2e/serve-http-takes-holders.test.ts) pinning the issue repro end-to-end over POST /mcp — the seam that had no coverage. Reported by @Derek95king. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent-voice): default-deny CORS + origin gate + loopback bind in reference server (#2477) The recipe reference server reflected any Origin into Access-Control-Allow-Origin on every route, including the unauthenticated side-effectful POSTs. Recipe is install_kind: copy-into-host-repo, so the reference is the source of truth for every future install. - Default-deny CORS: headers emitted only for exact matches against the new AGENT_VOICE_CORS_ORIGIN allowlist (comma-separated), with Vary: Origin; Allow-Credentials never set. Same-origin /call needs no configuration. - Origin gate on /session and /tool: CORS headers gate response reads, not request sends — a no-preflight "simple" cross-origin POST still executes. Disallowed Origins now 403 before any body read / upstream fetch / tool dispatch. No-Origin callers (curl, Twilio, native) and same-origin pages (Origin host == Host, tunnels included) pass. DNS rebinding stays a documented production-checklist item (TODO filed). - Loopback-default bind: HOST env, default 127.0.0.1 (mirrors gbrain serve --http --bind default); HOST=0.0.0.0 for containers/LAN. - Startup log prints the bind + CORS posture; recipe md + install manifest bumped to 0.1.1 with the production checklist rewritten to match. Tests: test/agent-voice-cors.serial.test.ts spawns the real server twice and pins default-deny, allowlist echo + trimming, preflight behavior, and the gate's ordering (evil-origin 403 vs no-origin reaching the handler). Reported by @sebastiondev. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: agent-voice origin gate fails closed on malformed Origin (#2477) Coverage-audit follow-up: a cross-origin POST with an unparseable Origin header must 403 (new URL() throws → originAllowed returns false), never fall through to the handler. A bypass here would defeat the gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin loopback-bind default + wire-level fail-closed takes default (#2529 #2477) Pre-landing review (testing specialist) flagged two revert-catching gaps: - #2477 HOST default (127.0.0.1) had no assertion — a regression to all-interfaces would pass every test. Capture the server's startup log and assert the loopback bind; add a HOST=0.0.0.0 override case. - #2529 serve-http `?? ['world']` default branch was only unit-covered. Add a 4th e2e case: a legacy token with no takes_holders key sees world-held takes but NOT brain-held ones over POST /mcp, pinning the fail-closed default end-to-end. Also assert the deny-all case returns a successful (non-error) tool result so the negatives can't pass vacuously. Verified: agent-voice 4/4, serve-http-takes-holders e2e 4/4 (real Postgres). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): shared permissions decode + collision-free hot-memory cache key (#2529) Adversarial-review hardening on the #2529 fix: - The two transports shared parseTakesHoldersAllowList but still decoded the permissions column differently: the OAuth provider JSON.parse'd a string-typed value, the legacy HTTP transport didn't. On a double-encoded jsonb string scalar (#2339 class) a deny-all token would fail open to ['world'] on the HTTP transport while the provider honored it. Extract coerceLegacyPermissions into the shared module and route both through it, so "the two transports cannot drift" is literally true (shared decode + shared parse). Arrays/scalars/malformed strings → undefined (no grant). - hashAllowList used bare sentinels ('_' for undefined, '(empty)' for []), which collided with real holder values ['_'] and ['(empty)']. Encode collision-free (undefined → 'none', else JSON.stringify(sorted)) so the []-vs-undefined cache separation the #2529 change relies on holds for every holder value. Tests: coerceLegacyPermissions unit cases (object/JSON-string/malformed/ array/scalar), all existing takes-holders + meta-hook + e2e suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v0.42.74.0 fix(security): honor takes_holders over serve --http + agent-voice default-deny CORS (#2529 #2477) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document agent-voice HOST + AGENT_VOICE_CORS_ORIGIN env vars in install hint (v0.42.74.0) The post-install hint's env-var quick-start predated the #2477 hardening and listed neither the loopback-default HOST bind nor the default-deny AGENT_VOICE_CORS_ORIGIN allowlist. Add both as optional entries (safe by default) and refresh the stale startup-log line to match the server's actual loopback-bind output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: correct CHANGELOG command guidance + TODOS filing version (#2529 #2477) Ship-stage document-release caught two wrong CLI invocations in the v0.42.74.0 CHANGELOG "To take advantage" block: `gbrain auth permissions <token>` has no read-only view form (that shape errors + exits 1 — set the scope directly with `set-takes-holders <values>`), and `integrations install agent-voice --refresh` requires `--target <host-repo>`. Also correct the follow-ups TODO header from the plan's stale v0.42.56.0 guess to the actual ship version v0.42.74.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deps): bump js-yaml to 3.15.1 — osv GHSA-5p4m-2wfm-xmqj (#2529 #2477) osv-scan flagged js-yaml@3.15.0 (High, CVSS 7.5), fixed in 3.15.1. The transitive copy (gray-matter → js-yaml) was pinned to ^3.15.0 by the package.json `overrides` block; bump both the direct dependency and the override to ^3.15.1 so the vulnerable version is gone from bun.lock entirely (gray-matter/js-yaml now resolves to 3.15.1). Patch bump, in-range, frontmatter parsing verified (markdown + frontmatter + import + oauth suites green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deps): bump admin nanoid to 3.3.18 — osv GHSA-2v37-7h3g-55p8 (#2529 #2477) osv-scan flagged nanoid@3.3.16 (High, CVSS 8.2) in admin/bun.lock, fixed in 3.3.17. nanoid is transitive (postcss → nanoid), so pin it in the admin overrides block; refresh resolves to 3.3.18 (latest patched 3.x). Admin SPA build verified green; both root and admin lockfiles now scan clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
344 lines
14 KiB
JavaScript
344 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* server.mjs — agent-voice reference server.
|
|
*
|
|
* WebRTC-first: the primary surface is browser-side via /call.
|
|
* GET / → redirect to /call
|
|
* GET /call → serves public/call.html (browser client)
|
|
* POST /session → SDP exchange with OpenAI Realtime; returns SDP answer
|
|
* POST /tool → tool-call dispatch from WebRTC data channel
|
|
* GET /health → {ok:true} liveness
|
|
*
|
|
* Twilio inbound (optional adapter):
|
|
* POST /voice → returns TwiML to open a Media Stream
|
|
* WSS /ws → Twilio↔OpenAI Realtime audio bridge
|
|
* POST /fallback → fallback TwiML (forward to operator's cell)
|
|
*
|
|
* The Twilio path is OPTIONAL; recipe Option A (WebRTC-only) doesn't need
|
|
* it. Operators wiring Twilio inbound implement the bridge themselves
|
|
* against `lib/twilio-bridge.mjs` (port-ready stubs included).
|
|
*
|
|
* Configuration via env:
|
|
* PORT default 8765
|
|
* HOST default '127.0.0.1' (loopback-only; set 0.0.0.0
|
|
* for containers / direct LAN exposure)
|
|
* OPENAI_API_KEY required for /session
|
|
* OPENAI_REALTIME_MODEL default 'gpt-4o-realtime-preview'
|
|
* DEFAULT_PERSONA default 'venus' (one of 'mars' | 'venus')
|
|
* AGENT_VOICE_CORS_ORIGIN comma-separated exact origins allowed via CORS
|
|
* (default unset = default-deny; the served /call
|
|
* page is same-origin and needs nothing)
|
|
* BRAIN_ROOT passed through to context-builder
|
|
* TIMEZONE passed through to context-builder
|
|
*
|
|
* Security posture: CORS is default-deny (exact-origin allowlist via
|
|
* AGENT_VOICE_CORS_ORIGIN), the side-effectful POSTs (/session, /tool) are
|
|
* gated on the Origin header (CORS headers gate response READS, not request
|
|
* SENDS — a cross-origin "simple" POST skips preflight, so without the gate
|
|
* an attacker page could blind-fire /session and burn the OpenAI key), and
|
|
* the listener binds loopback by default. Still reference code: rate
|
|
* limiting, Twilio signature validation, and a Host-header allowlist
|
|
* (DNS-rebinding hardening) are operator-added per the recipe's
|
|
* "production checklist."
|
|
*/
|
|
|
|
import { createServer } from 'node:http';
|
|
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join, extname } from 'node:path';
|
|
import { buildSystemPrompt } from './prompt.mjs';
|
|
import { dispatchTool, getEffectiveAllowlist } from './tools.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const PUBLIC_DIR = join(__dirname, 'public');
|
|
|
|
const PORT = parseInt(process.env.PORT || '8765', 10);
|
|
// Loopback by default (mirrors `gbrain serve --http --bind 127.0.0.1`).
|
|
// Tunnels (ngrok / Caddy / Cloudflare) target localhost, so the documented
|
|
// flows keep working; container/LAN deployments set HOST=0.0.0.0.
|
|
const HOST = process.env.HOST || '127.0.0.1';
|
|
const DEFAULT_PERSONA = (process.env.DEFAULT_PERSONA || 'venus').toLowerCase();
|
|
const OPENAI_REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || 'gpt-4o-realtime-preview';
|
|
const OPENAI_REALTIME_URL = 'https://api.openai.com/v1/realtime/calls';
|
|
|
|
// ── CORS + origin gate (default-deny) ─────────────────────────────────
|
|
// Mirrors the GBRAIN_HTTP_CORS_ORIGIN pattern in gbrain's own HTTP
|
|
// transport: exact-origin allowlist, no header emitted otherwise, and
|
|
// Access-Control-Allow-Credentials is never set.
|
|
function parseCorsAllowlist() {
|
|
const v = process.env.AGENT_VOICE_CORS_ORIGIN;
|
|
if (!v) return null;
|
|
const entries = v.split(',').map((s) => s.trim()).filter(Boolean);
|
|
return entries.length > 0 ? new Set(entries) : null;
|
|
}
|
|
const CORS_ALLOWLIST = parseCorsAllowlist();
|
|
|
|
function applyCors(req, res) {
|
|
const origin = req.headers.origin;
|
|
if (!(CORS_ALLOWLIST && origin && CORS_ALLOWLIST.has(origin))) return;
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Vary', 'Origin');
|
|
if (req.method === 'OPTIONS') {
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
}
|
|
}
|
|
|
|
// CORS headers gate response READS, not request SENDS: a cross-origin
|
|
// "simple" POST (e.g. text/plain) skips preflight entirely, executes
|
|
// server-side, and only the response is withheld from the attacker's JS.
|
|
// For endpoints with side effects (/session spends OPENAI_API_KEY, /tool
|
|
// dispatches brain reads) that isn't enough — reject disallowed Origins
|
|
// BEFORE doing any work. Requests without an Origin header (curl, Twilio
|
|
// webhooks, native apps) pass; browser requests pass only when same-origin
|
|
// (Origin host matches the Host header — covers the served /call page,
|
|
// including through a tunnel) or explicitly allowlisted. Known limit: a
|
|
// DNS-rebound page's Origin host matches the rebound Host header, so this
|
|
// does not defend against DNS rebinding (Host-header allowlist is the
|
|
// production-checklist follow-up).
|
|
function originAllowed(req) {
|
|
const origin = req.headers.origin;
|
|
if (!origin) return true;
|
|
if (CORS_ALLOWLIST && CORS_ALLOWLIST.has(origin)) return true;
|
|
try {
|
|
return new URL(origin).host === req.headers.host;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'application/javascript; charset=utf-8',
|
|
'.mjs': 'application/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.wasm': 'application/wasm',
|
|
'.png': 'image/png',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
};
|
|
|
|
function send(res, status, body, headers = {}) {
|
|
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', ...headers });
|
|
res.end(body);
|
|
}
|
|
|
|
function sendJson(res, status, obj) {
|
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
res.end(JSON.stringify(obj));
|
|
}
|
|
|
|
async function readBody(req, max = 1 << 20) {
|
|
const chunks = [];
|
|
let n = 0;
|
|
for await (const chunk of req) {
|
|
n += chunk.length;
|
|
if (n > max) {
|
|
const err = new Error('payload too large');
|
|
err.status = 413;
|
|
throw err;
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
function serveStatic(res, relPath) {
|
|
const full = join(PUBLIC_DIR, relPath);
|
|
if (!full.startsWith(PUBLIC_DIR)) return send(res, 403, 'forbidden');
|
|
if (!existsSync(full)) return send(res, 404, 'not found');
|
|
try {
|
|
const stat = statSync(full);
|
|
if (!stat.isFile()) return send(res, 404, 'not found');
|
|
const body = readFileSync(full);
|
|
const mime = MIME[extname(full)] || 'application/octet-stream';
|
|
res.writeHead(200, {
|
|
'content-type': mime,
|
|
'content-length': stat.size,
|
|
'cache-control': 'no-cache',
|
|
});
|
|
res.end(body);
|
|
} catch (err) {
|
|
send(res, 500, `read error: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// ── /session: WebRTC SDP exchange with OpenAI Realtime ────────────────
|
|
async function handleSession(req, res) {
|
|
if (req.method !== 'POST') return send(res, 405, 'method not allowed');
|
|
if (!process.env.OPENAI_API_KEY) {
|
|
return sendJson(res, 500, { error: 'OPENAI_API_KEY not set' });
|
|
}
|
|
|
|
let sdpOffer;
|
|
try {
|
|
sdpOffer = (await readBody(req)).toString('utf8');
|
|
} catch (err) {
|
|
return send(res, err.status || 400, err.message);
|
|
}
|
|
|
|
if (!sdpOffer || !sdpOffer.startsWith('v=')) {
|
|
return sendJson(res, 400, { error: 'missing or malformed SDP offer' });
|
|
}
|
|
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const persona = (url.searchParams.get('persona') || DEFAULT_PERSONA).toLowerCase();
|
|
// #1851: a call link minted from a Telegram topic carries topicId (+ an
|
|
// optional display topicName). The id is the ONLY topic data we accept over
|
|
// the wire — buildSystemPrompt resolves the recent-conversation context from
|
|
// the brain server-side. We never accept topic CONTENT as a param (that would
|
|
// be prompt injection + a leak into URLs/referrers/access logs).
|
|
const topicId = url.searchParams.get('topicId') || undefined;
|
|
const topicName = url.searchParams.get('topicName') || undefined;
|
|
|
|
// Build the persona-aware system prompt at session start.
|
|
const systemPrompt = await buildSystemPrompt({
|
|
persona,
|
|
brainRoot: process.env.BRAIN_ROOT,
|
|
timezone: process.env.TIMEZONE,
|
|
topicId,
|
|
topicName,
|
|
});
|
|
|
|
// Session config for OpenAI Realtime /v1/realtime/calls.
|
|
// Important gotchas (from production):
|
|
// - `voice` goes under `audio.output.voice`, NOT top-level
|
|
// - Do NOT send `turn_detection` (rejected by /v1/realtime/calls)
|
|
// - All `session.update` calls must include `type: 'realtime'`
|
|
const personaVoice = persona === 'mars' ? 'Orus' : 'Aoede';
|
|
const sessionConfig = {
|
|
type: 'realtime',
|
|
model: OPENAI_REALTIME_MODEL,
|
|
audio: { output: { voice: personaVoice } },
|
|
instructions: systemPrompt,
|
|
// Tools advertised to the model; the actual dispatch happens via /tool.
|
|
tools: getEffectiveAllowlist().map((name) => ({
|
|
type: 'function',
|
|
name,
|
|
description: `gbrain operation: ${name}`,
|
|
parameters: { type: 'object', properties: {}, additionalProperties: true },
|
|
})),
|
|
};
|
|
|
|
// OpenAI Realtime expects multipart/form-data with two parts:
|
|
// sdp: the WebRTC SDP offer
|
|
// session: JSON.stringify(sessionConfig)
|
|
const form = new FormData();
|
|
form.set('sdp', sdpOffer);
|
|
form.set('session', JSON.stringify(sessionConfig));
|
|
|
|
try {
|
|
const upstream = await fetch(OPENAI_REALTIME_URL, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
|
|
body: form,
|
|
});
|
|
if (!upstream.ok) {
|
|
const text = await upstream.text();
|
|
console.error(`[session] OpenAI Realtime returned ${upstream.status}: ${text.slice(0, 400)}`);
|
|
return send(res, upstream.status, text);
|
|
}
|
|
const sdpAnswer = await upstream.text();
|
|
res.writeHead(200, { 'content-type': 'application/sdp; charset=utf-8' });
|
|
res.end(sdpAnswer);
|
|
} catch (err) {
|
|
console.error(`[session] upstream error: ${err.message}`);
|
|
sendJson(res, 502, { error: 'upstream_unreachable', detail: err.message });
|
|
}
|
|
}
|
|
|
|
// ── /tool: tool-call dispatch from the WebRTC data channel ────────────
|
|
async function handleTool(req, res) {
|
|
if (req.method !== 'POST') return send(res, 405, 'method not allowed');
|
|
let body;
|
|
try {
|
|
body = JSON.parse((await readBody(req)).toString('utf8'));
|
|
} catch (err) {
|
|
return sendJson(res, 400, { error: 'invalid_json', detail: err.message });
|
|
}
|
|
const { name, arguments: params } = body || {};
|
|
if (typeof name !== 'string') {
|
|
return sendJson(res, 400, { error: 'missing tool name' });
|
|
}
|
|
const result = await dispatchTool(name, params || {});
|
|
// dispatchTool always returns either {data} or {error}; never throws.
|
|
sendJson(res, 200, result);
|
|
}
|
|
|
|
// ── /voice: Twilio TwiML stub (optional Twilio inbound) ───────────────
|
|
function handleVoiceTwiml(req, res) {
|
|
const host = req.headers.host;
|
|
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response>
|
|
<Connect>
|
|
<Stream url="wss://${host}/ws" />
|
|
</Connect>
|
|
</Response>`;
|
|
res.writeHead(200, { 'content-type': 'text/xml; charset=utf-8' });
|
|
res.end(twiml);
|
|
}
|
|
|
|
// ── HTTP router ──────────────────────────────────────────────────────
|
|
const server = createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
|
|
// CORS: default-deny. Headers are emitted only for allowlisted origins
|
|
// (AGENT_VOICE_CORS_ORIGIN); a 204 without CORS headers is browser-blocked.
|
|
applyCors(req, res);
|
|
if (req.method === 'OPTIONS') return send(res, 204, '');
|
|
|
|
try {
|
|
if (url.pathname === '/health') {
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
if (url.pathname === '/' || url.pathname === '/call') {
|
|
return serveStatic(res, 'call.html');
|
|
}
|
|
if (url.pathname === '/directory') {
|
|
return serveStatic(res, 'directory.html');
|
|
}
|
|
if (url.pathname === '/session' || url.pathname === '/tool') {
|
|
// Origin gate BEFORE any body read / upstream fetch / tool dispatch —
|
|
// blocks blind cross-origin "simple" POSTs that CORS headers can't.
|
|
if (!originAllowed(req)) {
|
|
return sendJson(res, 403, { error: 'origin not allowed' });
|
|
}
|
|
return url.pathname === '/session' ? handleSession(req, res) : handleTool(req, res);
|
|
}
|
|
if (url.pathname === '/voice') {
|
|
return handleVoiceTwiml(req, res);
|
|
}
|
|
if (url.pathname.startsWith('/public/')) {
|
|
return serveStatic(res, url.pathname.slice('/public/'.length));
|
|
}
|
|
// Static fallback for files in public/ at root path (e.g., /rnnoise-processor.js).
|
|
const candidate = url.pathname.slice(1);
|
|
if (candidate && !candidate.includes('..')) {
|
|
const candPath = join(PUBLIC_DIR, candidate);
|
|
if (existsSync(candPath) && statSync(candPath).isFile()) {
|
|
return serveStatic(res, candidate);
|
|
}
|
|
}
|
|
send(res, 404, 'not found');
|
|
} catch (err) {
|
|
console.error(`[server] ${err.message}\n${err.stack}`);
|
|
if (!res.headersSent) sendJson(res, 500, { error: err.message });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[agent-voice] listening on http://${HOST}:${PORT} (bind: ${HOST}${HOST === '127.0.0.1' ? ' — set HOST=0.0.0.0 to expose beyond loopback' : ''})`);
|
|
console.log(`[agent-voice] default persona: ${DEFAULT_PERSONA}`);
|
|
console.log(`[agent-voice] read-only tools: ${getEffectiveAllowlist().join(', ')}`);
|
|
if (!CORS_ALLOWLIST) {
|
|
console.log('[agent-voice] CORS: default-deny. Set AGENT_VOICE_CORS_ORIGIN=https://your.app to allow cross-origin browser clients.');
|
|
} else {
|
|
console.log(`[agent-voice] CORS allowlist: ${[...CORS_ALLOWLIST].join(', ')}`);
|
|
}
|
|
});
|
|
|
|
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
|
process.on('SIGINT', () => server.close(() => process.exit(0)));
|