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,447 @@
1
+ /** OpenAI SIP/WebRTC setup plus the authenticated sideband controller for one live call. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { VOICE_INSTRUCTIONS } from "./prompt.js";
5
+ import { VOICE_TOOL_NAMES } from "./tools.js";
6
+ /** Current Realtime call-accept shape, kept pure so upstream API drift has a snapshot-sized test. */
7
+ export function buildAcceptBody(input) {
8
+ return {
9
+ type: 'realtime',
10
+ model: input.model,
11
+ instructions: input.instructions,
12
+ max_output_tokens: 800,
13
+ audio: { output: { voice: input.voice } },
14
+ tools: [
15
+ {
16
+ type: 'mcp',
17
+ server_label: 'conductor_voice',
18
+ server_url: input.mcpUrl,
19
+ headers: {
20
+ authorization: `Bearer ${input.mcpToken}`,
21
+ 'x-voice-call-id': input.callId
22
+ },
23
+ allowed_tools: [...VOICE_TOOL_NAMES],
24
+ require_approval: 'never'
25
+ }
26
+ ]
27
+ };
28
+ }
29
+ /** USD estimate for the model whose rates are pinned in the design and documentation. */
30
+ export function estimateRealtimeCost(model, usage) {
31
+ if (model !== 'gpt-realtime-2.1-mini')
32
+ return null;
33
+ const input = usage.input_token_details ?? {};
34
+ const output = usage.output_token_details ?? {};
35
+ const cachedText = input.cached_tokens_details?.text_tokens ?? 0;
36
+ const cachedAudio = input.cached_tokens_details?.audio_tokens ?? 0;
37
+ const uncachedText = Math.max(0, (input.text_tokens ?? 0) - cachedText);
38
+ const uncachedAudio = Math.max(0, (input.audio_tokens ?? 0) - cachedAudio);
39
+ return ((uncachedText * 0.6 +
40
+ cachedText * 0.06 +
41
+ uncachedAudio * 10 +
42
+ cachedAudio * 0.3 +
43
+ (output.text_tokens ?? 0) * 2.4 +
44
+ (output.audio_tokens ?? 0) * 20) /
45
+ 1_000_000);
46
+ }
47
+ function defaultSocket(url, headers) {
48
+ // Node 24's built-in client accepts an undici options object here. DOM's constructor
49
+ // type still describes the browser's `protocols` argument, hence the narrow cast.
50
+ const Client = WebSocket;
51
+ return new Client(url, { headers });
52
+ }
53
+ function messageText(raw) {
54
+ if (typeof raw === 'string')
55
+ return raw;
56
+ if (raw instanceof ArrayBuffer)
57
+ return Buffer.from(raw).toString('utf8');
58
+ if (ArrayBuffer.isView(raw))
59
+ return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
60
+ return String(raw ?? '');
61
+ }
62
+ function safeNumber(value) {
63
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
64
+ }
65
+ export class VoiceBroker {
66
+ stateFile;
67
+ deps;
68
+ fetcher;
69
+ sockets;
70
+ log;
71
+ now;
72
+ apiOrigin;
73
+ websocketOrigin;
74
+ calls = new Map();
75
+ runtimes = new Map();
76
+ constructor(deps) {
77
+ this.deps = deps;
78
+ this.stateFile = deps.stateFile;
79
+ this.fetcher = deps.fetch ?? fetch;
80
+ this.sockets = deps.socket ?? defaultSocket;
81
+ this.log = deps.log ?? console.info;
82
+ this.now = deps.now ?? Date.now;
83
+ this.apiOrigin = deps.apiOrigin.replace(/\/+$/, '');
84
+ this.websocketOrigin = this.apiOrigin.replace(/^https:/, 'wss:');
85
+ this.readCalls();
86
+ }
87
+ readCalls() {
88
+ try {
89
+ const parsed = JSON.parse(fs.readFileSync(this.stateFile, 'utf8'));
90
+ if (!Array.isArray(parsed))
91
+ return;
92
+ for (const raw of parsed) {
93
+ const call = raw;
94
+ if (typeof call.callId === 'string' && typeof call.acceptedAt === 'number') {
95
+ this.calls.set(call.callId, {
96
+ callId: call.callId,
97
+ acceptedAt: call.acceptedAt,
98
+ // Records written before WebRTC support were all SIP/MCP calls.
99
+ mode: call.mode === 'function' ? 'function' : 'mcp',
100
+ ready: call.ready ?? true,
101
+ greeted: call.greeted ?? false
102
+ });
103
+ }
104
+ }
105
+ }
106
+ catch {
107
+ // First run or a recoverable partial file: the webhook can repopulate it.
108
+ }
109
+ }
110
+ persist() {
111
+ fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
112
+ fs.writeFileSync(this.stateFile, `${JSON.stringify([...this.calls.values()], null, 2)}\n`, { mode: 0o600 });
113
+ fs.chmodSync(this.stateFile, 0o600);
114
+ }
115
+ async accept(callId, options = {}) {
116
+ if (!this.deps.mcpUrl || !this.deps.mcpToken)
117
+ throw new Error('SIP voice MCP is not configured');
118
+ const response = await this.fetcher(`${this.apiOrigin}/v1/realtime/calls/${encodeURIComponent(callId)}/accept`, {
119
+ method: 'POST',
120
+ headers: { authorization: `Bearer ${this.deps.apiKey}`, 'content-type': 'application/json' },
121
+ body: JSON.stringify(buildAcceptBody({
122
+ callId,
123
+ model: this.deps.model,
124
+ voice: options.voice ?? this.deps.voice,
125
+ mcpUrl: this.deps.mcpUrl,
126
+ mcpToken: this.deps.mcpToken,
127
+ instructions: options.instructions ?? this.deps.instructions ?? VOICE_INSTRUCTIONS
128
+ }))
129
+ });
130
+ if (!response.ok)
131
+ throw new Error(`OpenAI call accept returned ${response.status}: ${await response.text()}`);
132
+ this.calls.set(callId, {
133
+ callId,
134
+ acceptedAt: this.now(),
135
+ mode: 'mcp',
136
+ ready: true,
137
+ greeted: false
138
+ });
139
+ this.persist();
140
+ this.attach(callId);
141
+ }
142
+ async reject(callId, statusCode = 603) {
143
+ const response = await this.fetcher(`${this.apiOrigin}/v1/realtime/calls/${encodeURIComponent(callId)}/reject`, {
144
+ method: 'POST',
145
+ headers: { authorization: `Bearer ${this.deps.apiKey}`, 'content-type': 'application/json' },
146
+ body: JSON.stringify({ status_code: statusCode })
147
+ });
148
+ if (!response.ok)
149
+ throw new Error(`OpenAI call reject returned ${response.status}: ${await response.text()}`);
150
+ }
151
+ /** Reattach every call whose socket was alive when a self-update stopped this process. */
152
+ async restore() {
153
+ for (const callId of this.calls.keys())
154
+ this.attach(callId);
155
+ }
156
+ attach(callId) {
157
+ if (this.runtimes.has(callId))
158
+ return;
159
+ const call = this.calls.get(callId);
160
+ if (!call)
161
+ return;
162
+ const socket = this.sockets(`${this.websocketOrigin}/v1/realtime?call_id=${encodeURIComponent(callId)}`, {
163
+ authorization: `Bearer ${this.deps.apiKey}`
164
+ });
165
+ const runtime = {
166
+ socket,
167
+ mode: call.mode,
168
+ ready: call.ready,
169
+ open: socket.readyState === 1,
170
+ toolsReady: call.mode === 'function',
171
+ greeted: call.greeted,
172
+ started: call.greeted,
173
+ responseActive: false,
174
+ responseDone: false,
175
+ responseHadTool: false,
176
+ pending: [],
177
+ pendingTools: new Set(),
178
+ handledTools: new Set(),
179
+ toolStarted: new Map()
180
+ };
181
+ this.runtimes.set(callId, runtime);
182
+ socket.addEventListener('open', () => {
183
+ runtime.open = true;
184
+ this.maybeStart(runtime);
185
+ this.flush(runtime);
186
+ });
187
+ socket.addEventListener('message', event => this.onEvent(callId, runtime, messageText(event.data)));
188
+ socket.addEventListener('error', () => this.log(`[voice] ${callId} observer socket error`));
189
+ socket.addEventListener('close', () => {
190
+ if (this.runtimes.get(callId) !== runtime)
191
+ return;
192
+ this.forget(callId, runtime);
193
+ this.log(`[voice] ${callId} observer closed`);
194
+ });
195
+ this.maybeStart(runtime);
196
+ }
197
+ send(runtime, value) {
198
+ if (runtime.socket.readyState !== 1)
199
+ return;
200
+ runtime.socket.send(JSON.stringify(value));
201
+ }
202
+ forget(callId, runtime) {
203
+ if (runtime && this.runtimes.get(callId) !== runtime)
204
+ return;
205
+ const existed = this.calls.delete(callId);
206
+ this.runtimes.delete(callId);
207
+ if (!existed)
208
+ return;
209
+ this.persist();
210
+ this.deps.onClose?.(callId);
211
+ }
212
+ createResponse(runtime) {
213
+ if (runtime.responseActive || runtime.socket.readyState !== 1)
214
+ return false;
215
+ runtime.responseActive = true;
216
+ runtime.responseDone = false;
217
+ runtime.responseHadTool = false;
218
+ this.send(runtime, { type: 'response.create' });
219
+ return true;
220
+ }
221
+ maybeStart(runtime) {
222
+ if (!runtime.open || !runtime.ready || !runtime.toolsReady || runtime.started)
223
+ return;
224
+ runtime.started = this.createResponse(runtime);
225
+ }
226
+ flush(runtime) {
227
+ if (!runtime.toolsReady || !runtime.greeted || runtime.responseActive || runtime.socket.readyState !== 1)
228
+ return;
229
+ const text = runtime.pending.shift();
230
+ if (!text)
231
+ return;
232
+ this.send(runtime, {
233
+ type: 'conversation.item.create',
234
+ item: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] }
235
+ });
236
+ this.createResponse(runtime);
237
+ }
238
+ /** OpenAI may finish the response before or after its tool calls. Both are a barrier. */
239
+ continueAfterTools(runtime) {
240
+ if (!runtime.responseDone || !runtime.responseHadTool || runtime.pendingTools.size)
241
+ return;
242
+ runtime.responseDone = false;
243
+ runtime.responseHadTool = false;
244
+ this.createResponse(runtime);
245
+ }
246
+ finishTool(callId, runtime, event) {
247
+ const item = (event.item ?? {});
248
+ const id = typeof event.item_id === 'string' ? event.item_id : typeof item.id === 'string' ? item.id : null;
249
+ const started = id ? runtime.toolStarted.get(id) : undefined;
250
+ if (id) {
251
+ runtime.toolStarted.delete(id);
252
+ runtime.pendingTools.delete(id);
253
+ }
254
+ const name = typeof item.name === 'string' ? item.name : (id ?? 'unknown tool');
255
+ const latency = started === undefined ? 'unknown latency' : `${this.now() - started}ms`;
256
+ this.log(`[voice] ${callId} tool ${name}: ${latency}`);
257
+ runtime.responseHadTool = true;
258
+ // Remote MCP results do not automatically continue, but OpenAI requires the
259
+ // original response *and every MCP call in it* to finish before the next one.
260
+ this.continueAfterTools(runtime);
261
+ }
262
+ startFunction(callId, runtime, event) {
263
+ if (runtime.mode !== 'function')
264
+ return;
265
+ const item = (event.item ?? {});
266
+ const invocationId = typeof event.call_id === 'string' ? event.call_id : typeof item.call_id === 'string' ? item.call_id : null;
267
+ const name = typeof event.name === 'string' ? event.name : typeof item.name === 'string' ? item.name : null;
268
+ const encodedArgs = typeof event.arguments === 'string' ? event.arguments : typeof item.arguments === 'string' ? item.arguments : '{}';
269
+ if (!invocationId || !name || runtime.handledTools.has(invocationId))
270
+ return;
271
+ runtime.handledTools.add(invocationId);
272
+ runtime.pendingTools.add(invocationId);
273
+ runtime.toolStarted.set(invocationId, this.now());
274
+ runtime.responseHadTool = true;
275
+ void (async () => {
276
+ let output;
277
+ try {
278
+ const parsed = JSON.parse(encodedArgs);
279
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
280
+ throw new Error('tool arguments must be an object');
281
+ const tool = this.deps.tools?.(callId).find(candidate => candidate.name === name);
282
+ if (!tool)
283
+ throw new Error(`tool ${name} is not available in this call`);
284
+ output = await tool.run(parsed);
285
+ }
286
+ catch (error) {
287
+ output = JSON.stringify({
288
+ status: 'error',
289
+ spoken: `The relay could not run that action: ${error instanceof Error ? error.message : String(error)}`
290
+ });
291
+ }
292
+ this.send(runtime, {
293
+ type: 'conversation.item.create',
294
+ item: { type: 'function_call_output', call_id: invocationId, output }
295
+ });
296
+ const started = runtime.toolStarted.get(invocationId);
297
+ runtime.toolStarted.delete(invocationId);
298
+ runtime.pendingTools.delete(invocationId);
299
+ const latency = started === undefined ? 'unknown latency' : `${this.now() - started}ms`;
300
+ this.log(`[voice] ${callId} tool ${name}: ${latency}`);
301
+ this.continueAfterTools(runtime);
302
+ })();
303
+ }
304
+ logUsage(callId, usage) {
305
+ const input = safeNumber(usage.input_tokens);
306
+ const output = safeNumber(usage.output_tokens);
307
+ const detailedInput = safeNumber(usage.input_token_details?.text_tokens) + safeNumber(usage.input_token_details?.audio_tokens);
308
+ const detailedOutput = safeNumber(usage.output_token_details?.text_tokens) + safeNumber(usage.output_token_details?.audio_tokens);
309
+ const tokens = safeNumber(usage.total_tokens) || input + output || detailedInput + detailedOutput;
310
+ const cost = estimateRealtimeCost(this.deps.model, usage);
311
+ this.log(`[voice] ${callId} ${tokens} tokens${cost === null ? '' : `, estimated $${cost.toFixed(4)}`}`);
312
+ }
313
+ onEvent(callId, runtime, raw) {
314
+ let event;
315
+ try {
316
+ event = JSON.parse(raw);
317
+ }
318
+ catch {
319
+ this.log(`[voice] ${callId} sent an unreadable observer event`);
320
+ return;
321
+ }
322
+ const type = event.type;
323
+ if (type === 'mcp_list_tools.completed') {
324
+ if (runtime.mode !== 'mcp')
325
+ return;
326
+ runtime.toolsReady = true;
327
+ this.maybeStart(runtime);
328
+ this.flush(runtime);
329
+ return;
330
+ }
331
+ if (type === 'mcp_list_tools.failed') {
332
+ this.log(`[voice] ${callId} could not import the scoped voice tools`);
333
+ return;
334
+ }
335
+ if (type === 'response.created') {
336
+ // Server VAD can start a response without this sideband having sent
337
+ // `response.create`; broker nudges must not collide with it.
338
+ runtime.responseActive = true;
339
+ return;
340
+ }
341
+ if (type === 'response.mcp_call.in_progress' && typeof event.item_id === 'string') {
342
+ runtime.responseHadTool = true;
343
+ runtime.pendingTools.add(event.item_id);
344
+ runtime.toolStarted.set(event.item_id, this.now());
345
+ return;
346
+ }
347
+ if (type === 'response.function_call_arguments.done') {
348
+ this.startFunction(callId, runtime, event);
349
+ return;
350
+ }
351
+ if (type === 'response.output_item.done' && event.item?.type === 'mcp_call') {
352
+ this.finishTool(callId, runtime, event);
353
+ return;
354
+ }
355
+ if (type === 'response.output_item.done' &&
356
+ event.item?.type === 'function_call') {
357
+ this.startFunction(callId, runtime, event);
358
+ return;
359
+ }
360
+ if (type === 'response.mcp_call.failed') {
361
+ this.finishTool(callId, runtime, event);
362
+ return;
363
+ }
364
+ if (type === 'response.done') {
365
+ runtime.responseActive = false;
366
+ runtime.responseDone = true;
367
+ const response = (event.response ?? {});
368
+ if (response.usage)
369
+ this.logUsage(callId, response.usage);
370
+ for (const item of response.output ?? []) {
371
+ if (item.type === 'mcp_call')
372
+ runtime.responseHadTool = true;
373
+ if (item.type === 'function_call')
374
+ this.startFunction(callId, runtime, { item });
375
+ }
376
+ if (runtime.responseHadTool) {
377
+ this.continueAfterTools(runtime);
378
+ return;
379
+ }
380
+ runtime.responseDone = false;
381
+ if (!runtime.greeted) {
382
+ runtime.greeted = true;
383
+ const call = this.calls.get(callId);
384
+ if (call) {
385
+ call.greeted = true;
386
+ this.persist();
387
+ }
388
+ }
389
+ this.flush(runtime);
390
+ }
391
+ }
392
+ /** Attach the relay sideband before the browser receives its SDP answer. */
393
+ registerWebRtc(callId) {
394
+ this.calls.set(callId, {
395
+ callId,
396
+ acceptedAt: this.now(),
397
+ mode: 'function',
398
+ ready: false,
399
+ greeted: false
400
+ });
401
+ this.persist();
402
+ this.attach(callId);
403
+ }
404
+ /** The browser calls this only after it has installed the remote SDP answer. */
405
+ beginWebRtc(callId) {
406
+ const call = this.calls.get(callId);
407
+ if (call?.mode !== 'function')
408
+ return false;
409
+ call.ready = true;
410
+ this.persist();
411
+ const runtime = this.runtimes.get(callId);
412
+ if (runtime) {
413
+ runtime.ready = true;
414
+ this.maybeStart(runtime);
415
+ }
416
+ return true;
417
+ }
418
+ /** End only a relay-created browser call; SIP calls remain owned by their phone leg. */
419
+ async hangupWebRtc(callId) {
420
+ const call = this.calls.get(callId);
421
+ if (call?.mode !== 'function')
422
+ return false;
423
+ const response = await this.fetcher(`${this.apiOrigin}/v1/realtime/calls/${encodeURIComponent(callId)}/hangup`, {
424
+ method: 'POST',
425
+ headers: { authorization: `Bearer ${this.deps.apiKey}` }
426
+ });
427
+ const runtime = this.runtimes.get(callId);
428
+ try {
429
+ runtime?.socket.close();
430
+ }
431
+ finally {
432
+ this.forget(callId, runtime);
433
+ }
434
+ if (!response.ok)
435
+ throw new Error(`OpenAI call hangup returned ${response.status}: ${await response.text()}`);
436
+ return true;
437
+ }
438
+ /** Queue a broker-authored line; it cannot overtake tool import or the call's greeting. */
439
+ inject(callId, text) {
440
+ const runtime = this.runtimes.get(callId);
441
+ if (!runtime)
442
+ return false;
443
+ runtime.pending.push(text);
444
+ this.flush(runtime);
445
+ return true;
446
+ }
447
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The voice layer's own secrets and knobs, in `stateDir()/voice.json` at 0600.
3
+ *
4
+ * Separate from the relay's token file for one reason (design ▸ D4): the token that reaches
5
+ * OpenAI's session store must not be the one that drives this Mac. The relay token is full
6
+ * remote control; this one reaches `createVoiceTools` and nothing else. They are different
7
+ * secrets in different files so that no future refactor can quietly make them the same.
8
+ *
9
+ * Not `settings.json`, which `/api/settings` serves to the phone. Not the LaunchAgent plist,
10
+ * which is user-readable and which `scripts/service.ts` already keeps the relay token out of.
11
+ */
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { stateDir } from "../config.js";
16
+ /** The listener's loopback port. Non-secret, so it may ride the plist like its siblings. */
17
+ export function voicePort() {
18
+ const raw = Number(process.env.VOICE_PORT);
19
+ return Number.isInteger(raw) && raw > 0 && raw <= 65535 ? raw : 8788;
20
+ }
21
+ export function voiceConfigPath() {
22
+ return path.join(stateDir(), 'voice.json');
23
+ }
24
+ const EMPTY = {
25
+ webhookSecret: null,
26
+ openaiKey: null,
27
+ twilioAuthToken: null,
28
+ allowedCallers: [],
29
+ pin: null,
30
+ projectId: null,
31
+ publicBaseUrl: null,
32
+ model: 'gpt-realtime-2.1-mini',
33
+ voice: 'marin',
34
+ sipHost: 'sip.api.openai.com'
35
+ };
36
+ export function openAIOriginForSipHost(sipHost) {
37
+ switch (sipHost) {
38
+ case 'sip.api.openai.com':
39
+ return 'https://api.openai.com';
40
+ case 'sip-eu.api.openai.com':
41
+ return 'https://eu.api.openai.com';
42
+ default:
43
+ throw new Error('voice.sip-host must be sip.api.openai.com or sip-eu.api.openai.com');
44
+ }
45
+ }
46
+ function asStringOrNull(value) {
47
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
48
+ }
49
+ function normalizedPublicUrl(value) {
50
+ if (!value)
51
+ return null;
52
+ const trimmed = value.replace(/\/+$/, '');
53
+ return trimmed.endsWith('/voice') ? trimmed : `${trimmed}/voice`;
54
+ }
55
+ /**
56
+ * Read the file, minting the scoped token on first use. A malformed file is treated as an empty
57
+ * one rather than thrown: the listener refusing to start is a worse failure than a re-mint, and
58
+ * every secret in here is re-enterable while a lost token only costs one dashboard edit.
59
+ */
60
+ export function readVoiceConfig(file = voiceConfigPath()) {
61
+ let raw = {};
62
+ try {
63
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
64
+ if (parsed && typeof parsed === 'object')
65
+ raw = parsed;
66
+ }
67
+ catch {
68
+ // absent or unreadable — fall through to defaults and mint below
69
+ }
70
+ const config = {
71
+ ...EMPTY,
72
+ mcpToken: asStringOrNull(raw.mcpToken) ?? crypto.randomBytes(16).toString('hex'),
73
+ trunkSecret: asStringOrNull(raw.trunkSecret) ?? crypto.randomBytes(32).toString('hex'),
74
+ webhookSecret: asStringOrNull(raw.webhookSecret),
75
+ openaiKey: asStringOrNull(raw.openaiKey),
76
+ twilioAuthToken: asStringOrNull(raw.twilioAuthToken),
77
+ allowedCallers: Array.isArray(raw.allowedCallers)
78
+ ? raw.allowedCallers.filter((c) => typeof c === 'string' && Boolean(c.trim())).map(c => c.trim())
79
+ : [],
80
+ pin: asStringOrNull(raw.pin),
81
+ projectId: asStringOrNull(raw.projectId),
82
+ publicBaseUrl: normalizedPublicUrl(asStringOrNull(raw.publicBaseUrl)),
83
+ model: asStringOrNull(raw.model) ?? EMPTY.model,
84
+ voice: asStringOrNull(raw.voice) ?? EMPTY.voice,
85
+ sipHost: asStringOrNull(raw.sipHost) ?? EMPTY.sipHost
86
+ };
87
+ if (asStringOrNull(raw.mcpToken) !== config.mcpToken || asStringOrNull(raw.trunkSecret) !== config.trunkSecret)
88
+ writeVoiceConfig(config, file);
89
+ return config;
90
+ }
91
+ /** Write 0600, creating the directory. The mode is set explicitly on an existing file too. */
92
+ export function writeVoiceConfig(config, file = voiceConfigPath()) {
93
+ fs.mkdirSync(path.dirname(file), { recursive: true });
94
+ fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
95
+ try {
96
+ fs.chmodSync(file, 0o600);
97
+ }
98
+ catch {
99
+ // a file we just wrote; if the mode cannot be set the write above already failed loudly
100
+ }
101
+ }
102
+ export const VOICE_SETTING_NAMES = [
103
+ 'voice.openai-key',
104
+ 'voice.webhook-secret',
105
+ 'voice.twilio-auth-token',
106
+ 'voice.allowed-callers',
107
+ 'voice.pin',
108
+ 'voice.project-id',
109
+ 'voice.public-url',
110
+ 'voice.model',
111
+ 'voice.voice',
112
+ 'voice.sip-host'
113
+ ];
114
+ /** Apply one CLI setting without ever returning or printing the secret value. `unset` clears nullable fields. */
115
+ export function setVoiceSetting(name, value, file = voiceConfigPath()) {
116
+ if (!VOICE_SETTING_NAMES.includes(name))
117
+ throw new Error(`unknown voice setting "${name}"`);
118
+ const config = readVoiceConfig(file);
119
+ const nullable = value === 'unset' ? null : value.trim() || null;
120
+ switch (name) {
121
+ case 'voice.openai-key':
122
+ config.openaiKey = nullable;
123
+ break;
124
+ case 'voice.webhook-secret':
125
+ config.webhookSecret = nullable;
126
+ break;
127
+ case 'voice.twilio-auth-token':
128
+ config.twilioAuthToken = nullable;
129
+ break;
130
+ case 'voice.allowed-callers':
131
+ config.allowedCallers =
132
+ value === 'unset'
133
+ ? []
134
+ : value
135
+ .split(',')
136
+ .map(item => item.trim())
137
+ .filter(Boolean);
138
+ break;
139
+ case 'voice.pin':
140
+ if (nullable && !/^\d{4,12}$/.test(nullable))
141
+ throw new Error('voice.pin must be 4–12 digits or unset');
142
+ config.pin = nullable;
143
+ break;
144
+ case 'voice.project-id':
145
+ config.projectId = nullable;
146
+ break;
147
+ case 'voice.public-url':
148
+ if (nullable && !/^https:\/\/[^/]+(?:\/voice)?\/?$/.test(nullable))
149
+ throw new Error('voice.public-url must be an HTTPS origin optionally ending in /voice, or unset');
150
+ config.publicBaseUrl = normalizedPublicUrl(nullable);
151
+ break;
152
+ case 'voice.model':
153
+ if (!nullable)
154
+ throw new Error('voice.model cannot be unset');
155
+ config.model = nullable;
156
+ break;
157
+ case 'voice.voice':
158
+ if (!nullable)
159
+ throw new Error('voice.voice cannot be unset');
160
+ config.voice = nullable;
161
+ break;
162
+ case 'voice.sip-host':
163
+ if (!nullable)
164
+ throw new Error('voice.sip-host cannot be unset');
165
+ openAIOriginForSipHost(nullable);
166
+ config.sipHost = nullable;
167
+ break;
168
+ }
169
+ writeVoiceConfig(config, file);
170
+ return config;
171
+ }
@@ -0,0 +1,75 @@
1
+ /** Ownership receipt and pure inspection for the one public `/voice` Funnel mount. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { stateDir } from "../config.js";
5
+ export const VOICE_FUNNEL_PORT = 443;
6
+ export const VOICE_FUNNEL_PATH = '/voice';
7
+ export function voiceFunnelReceiptPath() {
8
+ return path.join(stateDir(), 'voice-funnel.json');
9
+ }
10
+ export function readVoiceFunnelReceipt(file = voiceFunnelReceiptPath()) {
11
+ try {
12
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
13
+ return raw.version === 1 && typeof raw.host === 'string' && raw.path === '/voice' && typeof raw.target === 'string'
14
+ ? raw
15
+ : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export function writeVoiceFunnelReceipt(receipt, file = voiceFunnelReceiptPath()) {
22
+ fs.mkdirSync(path.dirname(file), { recursive: true });
23
+ fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
24
+ fs.chmodSync(file, 0o600);
25
+ }
26
+ function portOf(hostPort) {
27
+ const port = Number(hostPort.slice(hostPort.lastIndexOf(':') + 1));
28
+ return Number.isInteger(port) ? port : null;
29
+ }
30
+ function hostOf(hostPort) {
31
+ return hostPort.slice(0, hostPort.lastIndexOf(':'));
32
+ }
33
+ function proxyMatches(proxy, port) {
34
+ return proxy === `http://127.0.0.1:${port}` || proxy === `http://localhost:${port}`;
35
+ }
36
+ /**
37
+ * Decide whether a live path is ours before the service script mutates anything. A matching
38
+ * target without a receipt is deliberately *not* adopted: it may be the user's manual mount.
39
+ */
40
+ export function inspectVoiceFunnel(status, voicePort, relayPort, receipt) {
41
+ let present = false;
42
+ let targetMatches = false;
43
+ let owned = false;
44
+ let funnelOn = false;
45
+ let relayAtRoot = false;
46
+ const conflicts = [];
47
+ for (const [key, web] of Object.entries(status.Web ?? {})) {
48
+ if (portOf(key) !== VOICE_FUNNEL_PORT)
49
+ continue;
50
+ for (const [mount, handler] of Object.entries(web.Handlers ?? {})) {
51
+ if (mount === '/' && proxyMatches(handler.Proxy, relayPort)) {
52
+ relayAtRoot = true;
53
+ continue;
54
+ }
55
+ if (mount === VOICE_FUNNEL_PATH && handler.Proxy) {
56
+ present = true;
57
+ targetMatches ||= proxyMatches(handler.Proxy, voicePort);
58
+ const target = handler.Proxy;
59
+ owned = Boolean(receipt && receipt.host === hostOf(key) && receipt.path === VOICE_FUNNEL_PATH && receipt.target === target);
60
+ funnelOn ||= status.AllowFunnel?.[key] === true;
61
+ if (!proxyMatches(handler.Proxy, voicePort) && !owned)
62
+ conflicts.push(`${mount} → ${target}`);
63
+ continue;
64
+ }
65
+ conflicts.push(`${mount} → ${handler.Proxy ?? handler.Path ?? (handler.Text !== undefined ? 'text' : 'unknown')}`);
66
+ }
67
+ }
68
+ for (const [key, tcp] of Object.entries(status.TCP ?? {})) {
69
+ if (portOf(key) === VOICE_FUNNEL_PORT && tcp.TCPForward)
70
+ conflicts.push(`tcp → ${tcp.TCPForward}`);
71
+ }
72
+ if (present && !owned && !conflicts.some(value => value.startsWith(`${VOICE_FUNNEL_PATH} →`)))
73
+ conflicts.push(`${VOICE_FUNNEL_PATH} → an unowned matching target`);
74
+ return { present, targetMatches, owned, funnelOn, relayAtRoot, conflicts };
75
+ }