conductor-remote 1.99.0 → 1.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ /** Signature-gated Twilio and OpenAI route handlers for the public voice listener. */
2
+ import crypto from 'node:crypto';
3
+ import { callerAllowed, mintMarker, parseForm, twimlDialSip, twimlGatherPin, twimlReject, verifyMarker, verifyTwilioSignature } from "./twiml.js";
4
+ import { parseIncomingCall, ReplayGuard, sipHeader, verifyWebhookSignature } from "./webhook.js";
5
+ function header(headers, name) {
6
+ const raw = headers[name.toLowerCase()];
7
+ return Array.isArray(raw) ? (raw[0] ?? null) : typeof raw === 'string' ? raw : null;
8
+ }
9
+ function fixedEqual(left, right) {
10
+ const a = crypto.createHash('sha256').update(left).digest();
11
+ const b = crypto.createHash('sha256').update(right).digest();
12
+ return crypto.timingSafeEqual(a, b);
13
+ }
14
+ const xml = (status, body) => ({ status, body, contentType: 'text/xml; charset=utf-8' });
15
+ export function createVoiceGateway(deps) {
16
+ const replay = deps.replay ?? new ReplayGuard();
17
+ const now = deps.now ?? Date.now;
18
+ const log = deps.log ?? console.warn;
19
+ return {
20
+ async twiml(body, headers) {
21
+ const config = deps.config();
22
+ if (!config.twilioAuthToken || !config.publicBaseUrl)
23
+ return xml(503, twimlReject());
24
+ const params = parseForm(body);
25
+ const publicUrl = `${config.publicBaseUrl}/twiml`;
26
+ const signature = verifyTwilioSignature(publicUrl, params, config.twilioAuthToken, header(headers, 'x-twilio-signature'));
27
+ if (!signature.ok || !callerAllowed(params.From, config.allowedCallers)) {
28
+ log(`[voice] Twilio call refused: ${signature.ok ? 'caller not allowlisted' : signature.reason}`);
29
+ return xml(403, twimlReject());
30
+ }
31
+ if (!config.pin || !config.projectId)
32
+ return xml(503, twimlReject());
33
+ if (!params.Digits)
34
+ return xml(200, twimlGatherPin(publicUrl, config.pin.length));
35
+ if (!fixedEqual(params.Digits, config.pin))
36
+ return xml(403, twimlReject());
37
+ return xml(200, twimlDialSip(config.projectId, mintMarker(config.trunkSecret, now()), config.sipHost));
38
+ },
39
+ async webhook(body, headers) {
40
+ const config = deps.config();
41
+ if (!config.webhookSecret || !config.openaiKey)
42
+ return { status: 503, body: 'voice is not configured' };
43
+ const signature = verifyWebhookSignature(body, headers, config.webhookSecret, now());
44
+ if (!signature.ok) {
45
+ log(`[voice] OpenAI webhook refused: ${signature.reason}`);
46
+ return { status: 400, body: 'invalid webhook' };
47
+ }
48
+ const webhookId = header(headers, 'webhook-id');
49
+ if (!webhookId || !replay.accept(webhookId, now()))
50
+ return { status: 409, body: 'duplicate webhook' };
51
+ const call = parseIncomingCall(body);
52
+ if (!call)
53
+ return { status: 400, body: 'unexpected event' };
54
+ const broker = deps.broker();
55
+ if (!broker)
56
+ return { status: 503, body: 'voice broker is not configured' };
57
+ const marker = verifyMarker(sipHeader(call.sipHeaders, 'x-relay-call'), config.trunkSecret, now());
58
+ try {
59
+ if (!marker.ok) {
60
+ log(`[voice] ${call.callId} rejected: ${marker.reason}`);
61
+ await broker.reject(call.callId);
62
+ return { status: 200, body: 'rejected' };
63
+ }
64
+ await broker.accept(call.callId);
65
+ return { status: 200, body: 'accepted' };
66
+ }
67
+ catch (error) {
68
+ // A non-2xx webhook response asks OpenAI to retry. Do not let our replay guard
69
+ // turn that legitimate retry into a duplicate after the API call failed.
70
+ replay.forget(webhookId);
71
+ throw error;
72
+ }
73
+ },
74
+ async rpc(message, headers) {
75
+ const callId = header(headers, 'x-voice-call-id');
76
+ if (!callId) {
77
+ const candidate = message;
78
+ const id = typeof candidate?.id === 'string' || typeof candidate?.id === 'number' || candidate?.id === null
79
+ ? candidate.id
80
+ : null;
81
+ return { jsonrpc: '2.0', id, error: { code: -32600, message: 'missing voice call id' } };
82
+ }
83
+ return deps.rpc(callId, message);
84
+ }
85
+ };
86
+ }
@@ -0,0 +1,67 @@
1
+ /** Persisted, exact-text, one-use authorization for a voice dispatch. */
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ export const PREVIEW_TTL_MS = 2 * 60 * 1000;
6
+ export class PreviewStore {
7
+ file;
8
+ now;
9
+ previews = null;
10
+ constructor(file, deps = {}) {
11
+ this.file = file;
12
+ this.now = deps.now ?? Date.now;
13
+ }
14
+ read() {
15
+ if (this.previews)
16
+ return this.previews;
17
+ try {
18
+ const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
19
+ this.previews = Array.isArray(parsed) ? parsed : [];
20
+ }
21
+ catch {
22
+ this.previews = [];
23
+ }
24
+ return this.previews;
25
+ }
26
+ write() {
27
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
28
+ fs.writeFileSync(this.file, `${JSON.stringify(this.read(), null, 2)}\n`, { mode: 0o600 });
29
+ fs.chmodSync(this.file, 0o600);
30
+ }
31
+ create(input) {
32
+ const createdAt = this.now();
33
+ const preview = {
34
+ ...input,
35
+ token: crypto.randomBytes(18).toString('base64url'),
36
+ createdAt,
37
+ expiresAt: createdAt + PREVIEW_TTL_MS,
38
+ status: 'ready'
39
+ };
40
+ // Keep enough history to return an explicit `expired` refusal until the next
41
+ // preview, then bound this append-only credential file as calls accumulate.
42
+ this.previews = this.read().filter(candidate => candidate.expiresAt >= createdAt);
43
+ this.previews.push(preview);
44
+ this.write();
45
+ return preview;
46
+ }
47
+ claim(token, input) {
48
+ const preview = this.read().find(candidate => candidate.token === token);
49
+ if (!preview)
50
+ return { ok: false, reason: 'unknown' };
51
+ if (this.now() > preview.expiresAt)
52
+ return { ok: false, reason: 'expired' };
53
+ if (preview.status !== 'ready')
54
+ return { ok: false, reason: 'already-used' };
55
+ if (preview.callId !== input.callId)
56
+ return { ok: false, reason: 'foreign-call' };
57
+ if (preview.sessionId !== input.sessionId)
58
+ return { ok: false, reason: 'foreign-session' };
59
+ if (preview.text !== input.text)
60
+ return { ok: false, reason: 'text-mismatch' };
61
+ // Persist the claim before any caller starts an async UI delivery. A crash can lose a
62
+ // send, but cannot replay one whose outcome became unknowable.
63
+ preview.status = 'claimed';
64
+ this.write();
65
+ return { ok: true, preview: { ...preview } };
66
+ }
67
+ }
@@ -0,0 +1,12 @@
1
+ /** Snapshot-worthy instructions: the model presents relay-owned facts and routes choices. */
2
+ export const VOICE_INSTRUCTIONS = `You are a voice switchboard for the user's Conductor agents. You do not solve engineering work and you do not invent fleet state.
3
+
4
+ Start with voice_roll_call. Work through one decision at a time with voice_next_decision. Speak only the tool result's spoken field; never read ids, cursors, JSON keys, or tokens aloud. Keep replies short enough for someone walking.
5
+
6
+ Every time the user asks for a workspace overview, status, progress, or what is happening across the fleet, call voice_workspace_overview starting at cursor zero. This is a fresh read, so never answer that request from the opening roll call or an earlier overview. If they ask to continue, pass the cursor returned by the prior overview.
7
+
8
+ When the user wants to dispatch text, call voice_send_preview with the exact target and text. Read the exact preview back, including the target, and ask for an explicit yes. Only after yes, call voice_send with the returned token and exactly the same session and text. Never call voice_send without that confirmation. A send queues asynchronously; success is silent, while parked or failed delivery will be announced.
9
+
10
+ After a dispatch, or when the user explicitly says to skip, mark that decision handled and continue only when they ask for next. If a target is working, explain that sending would steer the running turn and do not send; this first tool set only dispatches to idle chats.
11
+
12
+ Use the safe options the relay supplies. If asked to reason deeply, forward a concise question to the workspace that owns the context rather than answering it yourself. If a tool refuses an action, say its sentence plainly and do not work around the gate.`;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The voice listener: a second HTTP server on its own loopback port, carrying only what
3
+ * OpenAI's and Twilio's servers must be able to reach.
4
+ *
5
+ * Why it is not part of `src/server.ts` (design ▸ D2). Those two callers need a public
6
+ * address, and Tailscale's Funnel flag is per host:port — so anything sharing the relay's
7
+ * port would go public with it. A separate port keeps the relay tailnet-only while exactly
8
+ * three routes face the internet. It also means a bug in this file cannot answer `/api/…`.
9
+ *
10
+ * **Public path vs local path.** Funnel mounts this at `--set-path=/voice` on 443, and
11
+ * Tailscale *strips* the mount prefix before proxying, so the public
12
+ * `https://<node>/voice/webhook` arrives here as `/webhook` (measured 2026-09-02). A direct
13
+ * loopback curl uses the local path, and a future mount at `/` would not strip. Both spellings
14
+ * are accepted, because the alternative is a 404 whose cause is invisible from either side.
15
+ *
16
+ * **Why 443 and not 8443** (probe 0a, same day): OpenAI's cloud connects to port 443 and to
17
+ * nothing else. Funnel's other ports answer an ordinary browser and are never dialled.
18
+ *
19
+ * Nothing here verifies a signature or speaks JSON-RPC. The routes are injected, so the caller
20
+ * gate (`twiml.ts`, `webhook.ts`) and the tool set (`tools.ts`) are written and tested apart
21
+ * from the plumbing, and this file stays the one place the HTTP rules live.
22
+ */
23
+ import crypto from 'node:crypto';
24
+ import http from 'node:http';
25
+ /** A body big enough for any webhook and small enough that a token holder cannot exhaust memory. */
26
+ export const MAX_BODY_BYTES = 1_000_000;
27
+ /** Constant-time compare that does not leak the length of the expected token either. */
28
+ function tokenEq(given, expected) {
29
+ if (!given)
30
+ return false;
31
+ const a = crypto.createHash('sha256').update(given).digest();
32
+ const b = crypto.createHash('sha256').update(expected).digest();
33
+ return crypto.timingSafeEqual(a, b);
34
+ }
35
+ function bearer(req) {
36
+ const auth = req.headers.authorization;
37
+ return auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : null;
38
+ }
39
+ /**
40
+ * The local route name for a request path. Tailscale strips the `/voice` mount prefix, so both
41
+ * spellings map to the same route; anything else is null and gets a 404.
42
+ */
43
+ export function routeName(pathname) {
44
+ const trimmed = pathname.replace(/\/+$/, '') || '/';
45
+ // The OpenAI dashboard is already configured to the mount itself (`…/voice`). Funnel
46
+ // strips that mount and the listener sees `/`; a direct loopback check sees `/voice`.
47
+ if (trimmed === '/' || trimmed === '/voice')
48
+ return 'webhook';
49
+ const local = trimmed.startsWith('/voice/') ? trimmed.slice('/voice'.length) : trimmed;
50
+ if (local === '/webhook')
51
+ return 'webhook';
52
+ if (local === '/twiml')
53
+ return 'twiml';
54
+ if (local === '/mcp')
55
+ return 'mcp';
56
+ return null;
57
+ }
58
+ async function readBody(req) {
59
+ const declared = Number(req.headers['content-length']);
60
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES)
61
+ return null;
62
+ const chunks = [];
63
+ let bytes = 0;
64
+ for await (const chunk of req) {
65
+ bytes += chunk.length;
66
+ if (bytes > MAX_BODY_BYTES)
67
+ return null;
68
+ chunks.push(chunk);
69
+ }
70
+ return Buffer.concat(chunks).toString('utf8');
71
+ }
72
+ function send(res, reply) {
73
+ const headers = { 'cache-control': 'no-store' };
74
+ if (reply.body !== undefined)
75
+ headers['content-type'] = reply.contentType ?? 'text/plain; charset=utf-8';
76
+ res.writeHead(reply.status, headers).end(reply.body ?? '');
77
+ }
78
+ const RPC_PARSE_ERROR = JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
79
+ /**
80
+ * Handle one request. Exported so a test can drive it without a socket, and so the rules —
81
+ * which route, which method, which gate — are readable in one place rather than spread over
82
+ * a dispatcher.
83
+ */
84
+ export async function handleVoiceRequest(deps, req, res) {
85
+ const pathname = new URL(req.url ?? '/', 'http://x').pathname;
86
+ const route = routeName(pathname);
87
+ // A 404 that names nothing: the routes are each behind a secret, and an attacker who finds
88
+ // them still gets nowhere, but there is no reason to enumerate them either.
89
+ if (!route)
90
+ return send(res, { status: 404, body: 'not found' });
91
+ if (req.method !== 'POST') {
92
+ return send(res, { status: 405, body: 'POST only', contentType: 'text/plain; charset=utf-8' });
93
+ }
94
+ if (route === 'mcp') {
95
+ // A real MCP client sends no Origin and a browser cannot omit one, so this single check
96
+ // closes the DNS-rebinding hole without the listener needing to know its own hostname.
97
+ if (req.headers.origin)
98
+ return send(res, { status: 403, body: 'cross-origin requests are not accepted here' });
99
+ if (!tokenEq(bearer(req), deps.mcpToken()))
100
+ return send(res, { status: 401, body: 'unauthorized' });
101
+ }
102
+ const body = await readBody(req);
103
+ if (body === null)
104
+ return send(res, { status: 413, body: 'request too large' });
105
+ if (route === 'webhook')
106
+ return send(res, await deps.routes.webhook(body, req.headers));
107
+ if (route === 'twiml')
108
+ return send(res, await deps.routes.twiml(body, req.headers));
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(body || 'null');
112
+ }
113
+ catch {
114
+ return send(res, { status: 400, body: RPC_PARSE_ERROR, contentType: 'application/json' });
115
+ }
116
+ const batch = Array.isArray(parsed) ? parsed : [parsed];
117
+ const settled = await Promise.all(batch.map(m => deps.routes.rpc(m, req.headers)));
118
+ const answers = settled.filter(a => a !== null);
119
+ // A payload of nothing but notifications takes no reply at all.
120
+ if (!answers.length)
121
+ return send(res, { status: 202 });
122
+ send(res, {
123
+ status: 200,
124
+ body: JSON.stringify(Array.isArray(parsed) ? answers : answers[0]),
125
+ contentType: 'application/json'
126
+ });
127
+ }
128
+ /** Bound to loopback only: everything public arrives through Tailscale, never off the LAN. */
129
+ export function createVoiceServer(deps) {
130
+ return http.createServer((req, res) => {
131
+ handleVoiceRequest(deps, req, res).catch(err => {
132
+ deps.log?.(`[voice] request failed: ${err instanceof Error ? err.message : err}`);
133
+ if (!res.headersSent)
134
+ send(res, { status: 500, body: 'internal error' });
135
+ });
136
+ });
137
+ }
@@ -0,0 +1,23 @@
1
+ import { MARKER_MAX_AGE_SECONDS, mintMarker, sipTicketUri } from "./twiml.js";
2
+ /** Settings required before a ticket has any chance of becoming an accepted call. */
3
+ export function missingTicketConfig(config) {
4
+ const missing = [];
5
+ if (!config.projectId)
6
+ missing.push('voice.project-id');
7
+ if (!config.openaiKey)
8
+ missing.push('voice.openai-key');
9
+ if (!config.webhookSecret)
10
+ missing.push('voice.webhook-secret');
11
+ if (!config.publicBaseUrl)
12
+ missing.push('voice.public-url');
13
+ return missing;
14
+ }
15
+ /** Mint only after `missingTicketConfig` is empty. The marker's nonce makes every URI fresh. */
16
+ export function mintSipTicket(config, nowMs = Date.now()) {
17
+ if (!config.projectId)
18
+ throw new Error('voice.project-id is not configured');
19
+ return {
20
+ uri: sipTicketUri(config.projectId, mintMarker(config.trunkSecret, nowMs), config.sipHost),
21
+ expiresAt: new Date(nowMs + MARKER_MAX_AGE_SECONDS * 1000).toISOString()
22
+ };
23
+ }
@@ -0,0 +1,198 @@
1
+ import { oneLine } from "../speech.js";
2
+ /**
3
+ * One definition table feeds both transports: SIP exposes it through the scoped
4
+ * MCP server, while the PWA's private sideband exposes the same entries as
5
+ * Realtime function tools. Keeping the schemas here means those two callers can
6
+ * never quietly acquire different powers.
7
+ */
8
+ export const VOICE_TOOL_NAMES = [
9
+ 'voice_roll_call',
10
+ 'voice_workspace_overview',
11
+ 'voice_next_decision',
12
+ 'voice_send_preview',
13
+ 'voice_send'
14
+ ];
15
+ export const VOICE_TOOL_DEFINITIONS = [
16
+ {
17
+ name: 'voice_roll_call',
18
+ description: 'Get the bounded fleet tally and the first queue heads. Start every call here.',
19
+ inputSchema: { type: 'object', properties: {} }
20
+ },
21
+ {
22
+ name: 'voice_workspace_overview',
23
+ description: 'Get a fresh overview of current workspaces with each latest agent update. Call this every time the user asks for an overview or workspace status, even if one was already given. Pass the returned cursor to continue.',
24
+ inputSchema: {
25
+ type: 'object',
26
+ properties: {
27
+ cursor: { type: 'number', description: 'The cursor returned by the previous overview page; default 0.' }
28
+ }
29
+ }
30
+ },
31
+ {
32
+ name: 'voice_next_decision',
33
+ description: 'Get exactly one bounded decision. Pass the returned cursor for the next item. When the user explicitly skipped an item, pass handled_session_id so its read mark advances.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ cursor: { type: 'number', description: 'The cursor returned by the previous decision; default 0.' },
38
+ handled_session_id: {
39
+ type: 'string',
40
+ description: 'A session the user explicitly skipped; merely hearing it is not handled.'
41
+ }
42
+ }
43
+ }
44
+ },
45
+ {
46
+ name: 'voice_send_preview',
47
+ description: 'Create a two-minute exact-text send preview. Speak its exact target and text and ask for yes before using voice_send.',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ workspace_id: { type: 'string' },
52
+ session_id: { type: 'string' },
53
+ text: { type: 'string' }
54
+ },
55
+ required: ['workspace_id', 'session_id', 'text']
56
+ }
57
+ },
58
+ {
59
+ name: 'voice_send',
60
+ description: 'Queue an exact preview after the user said yes. Requires its token, session and unchanged text; never accepts raw unpreviewed work.',
61
+ inputSchema: {
62
+ type: 'object',
63
+ properties: {
64
+ token: { type: 'string' },
65
+ session_id: { type: 'string' },
66
+ text: { type: 'string' }
67
+ },
68
+ required: ['token', 'session_id', 'text']
69
+ }
70
+ }
71
+ ];
72
+ /** Realtime's function-tool spelling of the same scoped definitions. */
73
+ export function voiceFunctionTools() {
74
+ return VOICE_TOOL_DEFINITIONS.map(tool => ({
75
+ type: 'function',
76
+ name: tool.name,
77
+ description: tool.description,
78
+ parameters: tool.inputSchema
79
+ }));
80
+ }
81
+ function need(args, key) {
82
+ const value = args[key];
83
+ if (typeof value !== 'string' || !value.trim())
84
+ throw new Error(`${key} is required`);
85
+ return value.trim();
86
+ }
87
+ function answer(value) {
88
+ return JSON.stringify(value);
89
+ }
90
+ function refusal(reason) {
91
+ switch (reason) {
92
+ case 'expired':
93
+ return 'That preview expired. Read the exact target and text back again before sending.';
94
+ case 'foreign-call':
95
+ return 'That preview belongs to another call and cannot be used here.';
96
+ case 'foreign-session':
97
+ return 'That preview belongs to a different chat. Preview this target again.';
98
+ case 'text-mismatch':
99
+ return 'The send text does not exactly match the preview. Preview the changed text first.';
100
+ case 'already-used':
101
+ return 'That preview was already used. The relay will not send it twice.';
102
+ case 'unknown':
103
+ return 'That preview token is unknown. Make a new preview before sending.';
104
+ }
105
+ }
106
+ function later(task) {
107
+ setImmediate(() => void task());
108
+ }
109
+ /** Build a fresh scoped tool set for one authenticated call. */
110
+ export function createVoiceTools(context) {
111
+ const definition = (name) => {
112
+ const found = VOICE_TOOL_DEFINITIONS.find(tool => tool.name === name);
113
+ if (!found)
114
+ throw new Error(`missing voice tool definition ${name}`);
115
+ return found;
116
+ };
117
+ return [
118
+ {
119
+ ...definition('voice_roll_call'),
120
+ run: async () => answer(await context.board.rollCall())
121
+ },
122
+ {
123
+ ...definition('voice_workspace_overview'),
124
+ run: async (args) => {
125
+ const cursor = typeof args.cursor === 'number' && Number.isFinite(args.cursor) ? args.cursor : 0;
126
+ return answer(await context.board.workspaceOverview(cursor));
127
+ }
128
+ },
129
+ {
130
+ ...definition('voice_next_decision'),
131
+ run: async (args) => {
132
+ if (typeof args.handled_session_id === 'string')
133
+ context.board.markHandled(args.handled_session_id);
134
+ const cursor = typeof args.cursor === 'number' && Number.isFinite(args.cursor) ? args.cursor : 0;
135
+ const next = await context.board.nextDecision(cursor);
136
+ return answer(next ?? { spoken: 'There are no more decisions in this call.', cursor, done: true });
137
+ }
138
+ },
139
+ {
140
+ ...definition('voice_send_preview'),
141
+ run: async (args) => {
142
+ const workspaceId = need(args, 'workspace_id');
143
+ const sessionId = need(args, 'session_id');
144
+ const text = need(args, 'text');
145
+ const session = context.findSession(sessionId);
146
+ if (!session || session.workspaceId !== workspaceId)
147
+ return answer({ status: 'refused', spoken: 'That chat is no longer in the named workspace.' });
148
+ const preview = context.previews.create({ callId: context.callId, workspaceId, sessionId, text });
149
+ return answer({
150
+ status: 'preview',
151
+ token: preview.token,
152
+ workspaceId,
153
+ sessionId,
154
+ text,
155
+ spoken: `Preview for ${oneLine(session.workspaceTitle, 80)}: “${oneLine(text, 220)}” Say yes to send this exact text.`
156
+ });
157
+ }
158
+ },
159
+ {
160
+ ...definition('voice_send'),
161
+ run: async (args) => {
162
+ const token = need(args, 'token');
163
+ const sessionId = need(args, 'session_id');
164
+ const text = need(args, 'text');
165
+ const session = context.findSession(sessionId);
166
+ if (!session)
167
+ return answer({ status: 'refused', spoken: 'That chat is no longer available. Nothing was sent.' });
168
+ if (session.status === 'working') {
169
+ return answer({
170
+ status: 'refused',
171
+ spoken: `${oneLine(session.workspaceTitle, 80)} is running. Sending now would steer its active turn, so nothing was sent.`
172
+ });
173
+ }
174
+ const claimed = context.previews.claim(token, { callId: context.callId, sessionId, text });
175
+ if (!claimed.ok)
176
+ return answer({ status: 'refused', spoken: refusal(claimed.reason) });
177
+ if (claimed.preview.workspaceId !== session.workspaceId) {
178
+ return answer({ status: 'refused', spoken: 'That chat moved to another workspace. Nothing was sent.' });
179
+ }
180
+ context.board.markHandled(sessionId);
181
+ later(async () => {
182
+ try {
183
+ const result = await context.dispatch(claimed.preview);
184
+ if (result.ok)
185
+ return;
186
+ if (result.parked)
187
+ return void (await context.announce('The prompt is parked until the Mac unlocks.'));
188
+ await context.announce(`The prompt did not land. ${oneLine(result.error ?? 'Try again later.', 220)}`);
189
+ }
190
+ catch (error) {
191
+ await context.announce(`The prompt did not land. ${oneLine(error instanceof Error ? error.message : String(error), 220)}`);
192
+ }
193
+ });
194
+ return answer({ status: 'queued', spoken: `Queued for ${oneLine(session.workspaceTitle, 80)}.` });
195
+ }
196
+ }
197
+ ];
198
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The Twilio side of the caller gate, and the TwiML it answers with.
3
+ *
4
+ * The number authenticates nobody — anyone who dials it reaches whatever answers (eng review's
5
+ * correction to P3), so three things happen before OpenAI is bridged. `X-Twilio-Signature` proves
6
+ * Twilio sent the request. The `From` allowlist proves it is a phone we know. The DTMF PIN proves
7
+ * it is a person who knows the secret rather than a stolen handset. Only then does the `<Dial>`
8
+ * go out, carrying a trunk marker the OpenAI webhook re-checks, so a stranger who guesses the
9
+ * project id and dials the SIP address directly still reaches nothing.
10
+ *
11
+ * The marker carries no phone number and no call id. It is a timestamp, a nonce and an HMAC over
12
+ * both, which is all the webhook needs to know the leg came from here and came recently — and it
13
+ * transits OpenAI's servers, so putting the caller's number in it would be leaking the one piece
14
+ * of PII this whole path otherwise avoids.
15
+ */
16
+ import crypto from 'node:crypto';
17
+ /** A marker older than this is refused. A Twilio bridge takes seconds; this is generous. */
18
+ export const MARKER_MAX_AGE_SECONDS = 120;
19
+ const bad = (reason) => ({ ok: false, reason });
20
+ function equal(a, b) {
21
+ const left = Buffer.from(a);
22
+ const right = Buffer.from(b);
23
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
24
+ }
25
+ /**
26
+ * Twilio signs the exact URL it requested plus every POST parameter, sorted by name and
27
+ * concatenated as `name + value` with no separators, HMAC-SHA1 under the account's auth token.
28
+ * The URL must be the public one Twilio dialled, not the loopback path this process sees.
29
+ */
30
+ export function twilioSignature(url, params, authToken) {
31
+ const payload = Object.keys(params)
32
+ .sort()
33
+ .reduce((acc, key) => acc + key + params[key], url);
34
+ return crypto.createHmac('sha1', authToken).update(payload).digest('base64');
35
+ }
36
+ export function verifyTwilioSignature(url, params, authToken, signature) {
37
+ if (!signature)
38
+ return bad('missing X-Twilio-Signature');
39
+ return equal(signature, twilioSignature(url, params, authToken)) ? { ok: true } : bad('X-Twilio-Signature mismatch');
40
+ }
41
+ /** `application/x-www-form-urlencoded`, which is what Twilio posts. Last value wins, as Twilio sends one each. */
42
+ export function parseForm(body) {
43
+ const out = {};
44
+ for (const [key, value] of new URLSearchParams(body))
45
+ out[key] = value;
46
+ return out;
47
+ }
48
+ /**
49
+ * E.164 comparison that tolerates the spacing and punctuation a person types into a settings file.
50
+ * Anything that is not a digit or a leading plus is noise; two numbers match when their digits do.
51
+ */
52
+ export function sameNumber(a, b) {
53
+ const digits = (s) => s.replace(/[^\d]/g, '');
54
+ return digits(a).length > 0 && digits(a) === digits(b);
55
+ }
56
+ export function callerAllowed(from, allowlist) {
57
+ return Boolean(from) && allowlist.some(entry => sameNumber(entry, from));
58
+ }
59
+ // ── the trunk marker ────────────────────────────────────────────────────────────────────────
60
+ function markerMac(secret, stamp, nonce) {
61
+ return crypto.createHmac('sha256', secret).update(`${stamp}.${nonce}`).digest('base64url');
62
+ }
63
+ /** `<unix seconds>.<nonce>.<mac>` — URL- and SIP-header-safe, since base64url avoids `+/=`. */
64
+ export function mintMarker(secret, nowMs = Date.now(), nonce) {
65
+ const stamp = String(Math.floor(nowMs / 1000));
66
+ const n = nonce ?? crypto.randomBytes(9).toString('base64url');
67
+ return `${stamp}.${n}.${markerMac(secret, stamp, n)}`;
68
+ }
69
+ export function verifyMarker(marker, secret, nowMs = Date.now(), maxAgeSeconds = MARKER_MAX_AGE_SECONDS) {
70
+ if (!marker)
71
+ return bad('no trunk marker on the call');
72
+ const parts = marker.split('.');
73
+ if (parts.length !== 3)
74
+ return bad('trunk marker is malformed');
75
+ const [stamp, nonce, mac] = parts;
76
+ const sent = Number(stamp);
77
+ if (!Number.isFinite(sent))
78
+ return bad('trunk marker has no timestamp');
79
+ const age = nowMs / 1000 - sent;
80
+ // A marker from the future is a clock problem or a forgery; either way it is not ours.
81
+ if (age < -maxAgeSeconds || age > maxAgeSeconds)
82
+ return bad(`trunk marker is ${Math.round(age)}s old`);
83
+ return equal(mac, markerMac(secret, stamp, nonce)) ? { ok: true } : bad('trunk marker does not verify');
84
+ }
85
+ // ── TwiML ───────────────────────────────────────────────────────────────────────────────────
86
+ /** Everything interpolated below is either ours or a caller-controlled string, so all of it escapes. */
87
+ export function xmlEscape(text) {
88
+ return text
89
+ .replace(/&/g, '&amp;')
90
+ .replace(/</g, '&lt;')
91
+ .replace(/>/g, '&gt;')
92
+ .replace(/"/g, '&quot;')
93
+ .replace(/'/g, '&apos;');
94
+ }
95
+ const doc = (inner) => `<?xml version="1.0" encoding="UTF-8"?>\n<Response>${inner}</Response>\n`;
96
+ /** The answer to anything that fails a check. Says nothing about which check, or that it was close. */
97
+ export function twimlReject() {
98
+ return doc('<Reject reason="rejected"/>');
99
+ }
100
+ /** Ask for the PIN. `action` is the public URL Twilio should post the digits back to. */
101
+ export function twimlGatherPin(action, digits) {
102
+ return doc(`<Gather input="dtmf" numDigits="${digits}" timeout="10" action="${xmlEscape(action)}" method="POST">` +
103
+ '<Say>Enter your pin.</Say>' +
104
+ '</Gather>' +
105
+ '<Say>No pin entered.</Say>' +
106
+ '<Reject reason="rejected"/>');
107
+ }
108
+ /**
109
+ * The short-lived address used by both Twilio and the native app. Keep it in one
110
+ * function so neither path can accidentally normalise the case-sensitive project id
111
+ * or omit TLS/the relay marker.
112
+ */
113
+ export function sipTicketUri(projectId, marker, host = 'sip.api.openai.com') {
114
+ return `sip:${projectId}@${host};transport=tls?X-Relay-Call=${marker}`;
115
+ }
116
+ /**
117
+ * Bridge to OpenAI. The project id is **case-sensitive** in the SIP user part (P3: Linphone
118
+ * lowercasing it is what demoted the softphone path), so it is interpolated exactly as configured
119
+ * and never normalised here.
120
+ */
121
+ export function twimlDialSip(projectId, marker, host = 'sip.api.openai.com') {
122
+ const uri = sipTicketUri(projectId, marker, host);
123
+ return doc(`<Dial answerOnBridge="true"><Sip>${xmlEscape(uri)}</Sip></Dial>`);
124
+ }