Files
gbrain/recipes/agent-voice/code/server.mjs
T
Garry TanandClaude Fable 5 a948dfd6e2 v0.42.74.0 fix(security): honor takes_holders over serve --http + agent-voice default-deny CORS (#2529 #2477) (#3868)
* 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>
2026-08-07 15:31:53 -07:00

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)));