@ouro.bot/cli 0.1.0-alpha.880 → 0.1.0-alpha.881
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.
- package/changelog.json +6 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/senses/voice/call-auth.js +248 -0
- package/dist/senses/voice/process-guards.js +51 -0
- package/dist/senses/voice/twilio-phone-runtime.js +13 -0
- package/dist/senses/voice/twilio-phone.js +596 -303
- package/dist/senses/voice-entry.js +2 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.881",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Voice calls are authenticated end to end: the signed Twilio webhook records who is calling and hands the Media Stream (or the OpenAI SIP leg) a short-lived single-use token, and a stream or SIP call without a valid token is refused, so the caller identity, friend, direction and greeting come from server state and never from stream parameters. defaultFriendId no longer decides who an inbound caller is on any transport, friend ids are validated before they touch the friend store, the voice transport stays off without voice.twilioAuthToken, a call that cannot connect now hears a short failure line instead of dead air, and a failed transcript save or stray rejection no longer takes the voice process down. A malformed frame from an unauthenticated client no longer crashes the voice process, a second stream for the same call is refused even while the first is still starting, and a burst of more than five uncaught exceptions in a minute makes the voice process exit so its supervisor restarts it."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
4
10
|
{
|
|
5
11
|
"version": "0.1.0-alpha.880",
|
|
6
12
|
"changes": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.881</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.RecentIds = exports.PendingVoiceCalls = exports.DEFAULT_PENDING_VOICE_CALL_MAX_ENTRIES = exports.DEFAULT_PENDING_VOICE_CALL_TTL_MS = exports.DEFAULT_VOICE_CALL_TOKEN_TTL_MS = void 0;
|
|
37
|
+
exports.newVoiceCallNonce = newVoiceCallNonce;
|
|
38
|
+
exports.mintVoiceCallToken = mintVoiceCallToken;
|
|
39
|
+
exports.verifyVoiceCallToken = verifyVoiceCallToken;
|
|
40
|
+
const crypto = __importStar(require("node:crypto"));
|
|
41
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
42
|
+
exports.DEFAULT_VOICE_CALL_TOKEN_TTL_MS = 60_000;
|
|
43
|
+
exports.DEFAULT_PENDING_VOICE_CALL_TTL_MS = 120_000;
|
|
44
|
+
exports.DEFAULT_PENDING_VOICE_CALL_MAX_ENTRIES = 256;
|
|
45
|
+
const TOKEN_KEY_DOMAIN = "ouro-voice-stream-v1";
|
|
46
|
+
function newVoiceCallNonce() {
|
|
47
|
+
return crypto.randomBytes(16).toString("hex");
|
|
48
|
+
}
|
|
49
|
+
function signingKey(secret) {
|
|
50
|
+
return crypto.createHmac("sha256", secret).update(TOKEN_KEY_DOMAIN).digest();
|
|
51
|
+
}
|
|
52
|
+
function sign(secret, payload) {
|
|
53
|
+
return crypto.createHmac("sha256", signingKey(secret)).update(payload).digest("base64url");
|
|
54
|
+
}
|
|
55
|
+
function mintVoiceCallToken(input) {
|
|
56
|
+
const claims = {
|
|
57
|
+
v: 1,
|
|
58
|
+
p: input.purpose,
|
|
59
|
+
a: input.agentName,
|
|
60
|
+
c: input.callSid,
|
|
61
|
+
d: input.direction,
|
|
62
|
+
e: input.nowMs + (input.ttlMs ?? exports.DEFAULT_VOICE_CALL_TOKEN_TTL_MS),
|
|
63
|
+
n: input.nonce ?? newVoiceCallNonce(),
|
|
64
|
+
...(input.outboundId ? { o: input.outboundId } : {}),
|
|
65
|
+
...(input.from ? { f: input.from } : {}),
|
|
66
|
+
...(input.to ? { t: input.to } : {}),
|
|
67
|
+
};
|
|
68
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
|
|
69
|
+
return `${payload}.${sign(input.secret, payload)}`;
|
|
70
|
+
}
|
|
71
|
+
function parseClaims(payload) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
80
|
+
return null;
|
|
81
|
+
const claims = parsed;
|
|
82
|
+
if (claims.v !== 1
|
|
83
|
+
|| (claims.p !== "stream" && claims.p !== "sip")
|
|
84
|
+
|| typeof claims.a !== "string"
|
|
85
|
+
|| typeof claims.c !== "string"
|
|
86
|
+
|| typeof claims.d !== "string"
|
|
87
|
+
|| typeof claims.e !== "number"
|
|
88
|
+
|| typeof claims.n !== "string")
|
|
89
|
+
return null;
|
|
90
|
+
return claims;
|
|
91
|
+
}
|
|
92
|
+
function verifyVoiceCallToken(input) {
|
|
93
|
+
const token = input.token.trim();
|
|
94
|
+
if (!token)
|
|
95
|
+
return { ok: false, reason: "missing" };
|
|
96
|
+
const parts = token.split(".");
|
|
97
|
+
if (parts.length !== 2 || !parts[0] || !parts[1])
|
|
98
|
+
return { ok: false, reason: "malformed" };
|
|
99
|
+
const [payload, signature] = parts;
|
|
100
|
+
const expected = Buffer.from(sign(input.secret, payload));
|
|
101
|
+
const actual = Buffer.from(signature);
|
|
102
|
+
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
|
|
103
|
+
return { ok: false, reason: "bad_signature" };
|
|
104
|
+
}
|
|
105
|
+
const claims = parseClaims(payload);
|
|
106
|
+
if (!claims)
|
|
107
|
+
return { ok: false, reason: "malformed" };
|
|
108
|
+
if (input.nowMs > claims.e)
|
|
109
|
+
return { ok: false, reason: "expired" };
|
|
110
|
+
if (claims.p !== input.purpose)
|
|
111
|
+
return { ok: false, reason: "wrong_purpose" };
|
|
112
|
+
if (claims.a !== input.agentName)
|
|
113
|
+
return { ok: false, reason: "wrong_agent" };
|
|
114
|
+
if (claims.c !== input.callSid)
|
|
115
|
+
return { ok: false, reason: "wrong_call" };
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
nonce: claims.n,
|
|
119
|
+
direction: claims.d,
|
|
120
|
+
...(claims.o ? { outboundId: claims.o } : {}),
|
|
121
|
+
...(claims.f ? { from: claims.f } : {}),
|
|
122
|
+
...(claims.t ? { to: claims.t } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Bounded, TTL'd, in-memory record of calls the signed webhooks have admitted, keyed by CallSid.
|
|
127
|
+
* A call can hold several nonces at once (Twilio may retry the webhook while a greeting is being
|
|
128
|
+
* prepared); the first successful consume removes the whole entry, so the call connects once.
|
|
129
|
+
*/
|
|
130
|
+
class PendingVoiceCalls {
|
|
131
|
+
entries = new Map();
|
|
132
|
+
connected = new Map();
|
|
133
|
+
rejected = new Map();
|
|
134
|
+
ttlMs;
|
|
135
|
+
maxEntries;
|
|
136
|
+
now;
|
|
137
|
+
constructor(options = {}) {
|
|
138
|
+
this.ttlMs = options.ttlMs ?? exports.DEFAULT_PENDING_VOICE_CALL_TTL_MS;
|
|
139
|
+
this.maxEntries = options.maxEntries ?? exports.DEFAULT_PENDING_VOICE_CALL_MAX_ENTRIES;
|
|
140
|
+
this.now = options.now ?? Date.now;
|
|
141
|
+
}
|
|
142
|
+
record(identity, nonce) {
|
|
143
|
+
const existing = this.live(identity.callSid);
|
|
144
|
+
const nonces = existing?.nonces ?? new Set();
|
|
145
|
+
nonces.add(nonce);
|
|
146
|
+
// Re-insert so the Map's insertion order is least-recently-recorded first.
|
|
147
|
+
this.entries.delete(identity.callSid);
|
|
148
|
+
this.entries.set(identity.callSid, { identity, nonces, expiresAtMs: this.now() + this.ttlMs });
|
|
149
|
+
while (this.entries.size > this.maxEntries) {
|
|
150
|
+
const evicted = this.entries.keys().next().value;
|
|
151
|
+
this.entries.delete(evicted);
|
|
152
|
+
// An evicted call's stream will be refused, so make the pressure visible.
|
|
153
|
+
(0, runtime_1.emitNervesEvent)({
|
|
154
|
+
level: "warn",
|
|
155
|
+
component: "senses",
|
|
156
|
+
event: "senses.voice_pending_call_evicted",
|
|
157
|
+
message: "evicted a pending voice call before its stream started",
|
|
158
|
+
meta: { callSid: evicted, maxEntries: this.maxEntries },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
consume(callSid, nonce) {
|
|
163
|
+
const entry = this.live(callSid);
|
|
164
|
+
if (!entry || !entry.nonces.has(nonce))
|
|
165
|
+
return null;
|
|
166
|
+
this.entries.delete(callSid);
|
|
167
|
+
this.remember(this.connected, callSid);
|
|
168
|
+
return entry.identity;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Remember that a stream for this call was refused, so the call's end can say so. Ignored for a
|
|
172
|
+
* call that already connected: a replayed token must not turn a good call into a failure.
|
|
173
|
+
*/
|
|
174
|
+
markRejected(callSid) {
|
|
175
|
+
if (this.recalls(this.connected, callSid))
|
|
176
|
+
return;
|
|
177
|
+
this.remember(this.rejected, callSid);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Called when the stream's `<Connect>` ends. Returns true when the call never connected to a
|
|
181
|
+
* session (still waiting, or its stream was refused); forgets everything known about the call.
|
|
182
|
+
*/
|
|
183
|
+
settle(callSid) {
|
|
184
|
+
const failed = this.live(callSid) !== undefined || this.recalls(this.rejected, callSid);
|
|
185
|
+
this.entries.delete(callSid);
|
|
186
|
+
this.connected.delete(callSid);
|
|
187
|
+
this.rejected.delete(callSid);
|
|
188
|
+
return failed;
|
|
189
|
+
}
|
|
190
|
+
has(callSid) {
|
|
191
|
+
return this.live(callSid) !== undefined;
|
|
192
|
+
}
|
|
193
|
+
discard(callSid) {
|
|
194
|
+
return this.entries.delete(callSid);
|
|
195
|
+
}
|
|
196
|
+
size() {
|
|
197
|
+
return this.entries.size;
|
|
198
|
+
}
|
|
199
|
+
remember(marks, callSid) {
|
|
200
|
+
marks.delete(callSid);
|
|
201
|
+
marks.set(callSid, this.now() + this.ttlMs);
|
|
202
|
+
while (marks.size > this.maxEntries)
|
|
203
|
+
marks.delete(marks.keys().next().value);
|
|
204
|
+
}
|
|
205
|
+
recalls(marks, callSid) {
|
|
206
|
+
const expiresAtMs = marks.get(callSid);
|
|
207
|
+
if (expiresAtMs === undefined)
|
|
208
|
+
return false;
|
|
209
|
+
if (this.now() > expiresAtMs) {
|
|
210
|
+
marks.delete(callSid);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
live(callSid) {
|
|
216
|
+
const entry = this.entries.get(callSid);
|
|
217
|
+
if (!entry)
|
|
218
|
+
return undefined;
|
|
219
|
+
if (this.now() > entry.expiresAtMs) {
|
|
220
|
+
this.entries.delete(callSid);
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return entry;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
exports.PendingVoiceCalls = PendingVoiceCalls;
|
|
227
|
+
/** Bounded, TTL'd "have I seen this id" set, used to ignore replayed or duplicate webhooks. */
|
|
228
|
+
class RecentIds {
|
|
229
|
+
options;
|
|
230
|
+
seen = new Map();
|
|
231
|
+
constructor(options = {}) {
|
|
232
|
+
this.options = options;
|
|
233
|
+
}
|
|
234
|
+
/** Returns true when the id is new (and remembers it), false when it was already seen. */
|
|
235
|
+
add(id) {
|
|
236
|
+
const now = (this.options.now ?? Date.now)();
|
|
237
|
+
const expiresAtMs = this.seen.get(id);
|
|
238
|
+
if (expiresAtMs !== undefined && now <= expiresAtMs)
|
|
239
|
+
return false;
|
|
240
|
+
this.seen.delete(id);
|
|
241
|
+
this.seen.set(id, now + (this.options.ttlMs ?? 600_000));
|
|
242
|
+
while (this.seen.size > (this.options.maxEntries ?? 1_024)) {
|
|
243
|
+
this.seen.delete(this.seen.keys().next().value);
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
exports.RecentIds = RecentIds;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.installVoiceProcessGuards = installVoiceProcessGuards;
|
|
4
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
5
|
+
function describeError(error) {
|
|
6
|
+
return error instanceof Error ? error.stack ?? error.message : String(error);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Keep one bad call from taking the voice process down for every other caller. A failure that
|
|
10
|
+
* escapes a call's own handlers is logged and the process keeps serving. A burst of uncaught
|
|
11
|
+
* exceptions means the process is no longer healthy, so it exits and lets the supervisor restart it.
|
|
12
|
+
*/
|
|
13
|
+
function installVoiceProcessGuards(proc, options = {}) {
|
|
14
|
+
const limit = options.limit ?? 5;
|
|
15
|
+
const windowMs = options.windowMs ?? 60_000;
|
|
16
|
+
const exit = options.exit ?? ((code) => proc.exit(code));
|
|
17
|
+
const now = options.now ?? Date.now;
|
|
18
|
+
const recent = [];
|
|
19
|
+
proc.on("unhandledRejection", (reason) => {
|
|
20
|
+
(0, runtime_1.emitNervesEvent)({
|
|
21
|
+
level: "error",
|
|
22
|
+
component: "senses",
|
|
23
|
+
event: "senses.voice_process_error",
|
|
24
|
+
message: "Voice process caught an unhandled promise rejection and kept running",
|
|
25
|
+
meta: { kind: "unhandledRejection", error: describeError(reason) },
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
proc.on("uncaughtException", (error) => {
|
|
29
|
+
(0, runtime_1.emitNervesEvent)({
|
|
30
|
+
level: "error",
|
|
31
|
+
component: "senses",
|
|
32
|
+
event: "senses.voice_process_error",
|
|
33
|
+
message: "Voice process caught an uncaught exception and kept running",
|
|
34
|
+
meta: { kind: "uncaughtException", error: describeError(error) },
|
|
35
|
+
});
|
|
36
|
+
const at = now();
|
|
37
|
+
recent.push(at);
|
|
38
|
+
while (recent.length > 0 && at - recent[0] > windowMs)
|
|
39
|
+
recent.shift();
|
|
40
|
+
if (recent.length > limit) {
|
|
41
|
+
(0, runtime_1.emitNervesEvent)({
|
|
42
|
+
level: "error",
|
|
43
|
+
component: "senses",
|
|
44
|
+
event: "senses.voice_process_crash_budget_exhausted",
|
|
45
|
+
message: "Voice process exceeded its uncaught exception budget and is exiting for a supervisor restart",
|
|
46
|
+
meta: { limit, windowMs, count: recent.length },
|
|
47
|
+
});
|
|
48
|
+
exit(1);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -516,6 +516,19 @@ async function startConfiguredTwilioPhoneTransport(options, deps = defaultTwilio
|
|
|
516
516
|
});
|
|
517
517
|
return { status: "disabled", reason };
|
|
518
518
|
}
|
|
519
|
+
if (!settings.twilioAuthToken?.trim()) {
|
|
520
|
+
// Call tokens and webhook signatures are keyed by the Twilio auth token; without it the bridge
|
|
521
|
+
// could not tell a real Twilio call from a forged one, so it does not listen at all.
|
|
522
|
+
const reason = "voice.twilioAuthToken is required for inbound calls";
|
|
523
|
+
(0, runtime_1.emitNervesEvent)({
|
|
524
|
+
level: "warn",
|
|
525
|
+
component: "senses",
|
|
526
|
+
event: "senses.voice_twilio_transport_disabled",
|
|
527
|
+
message: "Twilio phone voice transport is not attached because the Twilio auth token is missing",
|
|
528
|
+
meta: { agentName: options.agentName, reason },
|
|
529
|
+
});
|
|
530
|
+
return { status: "disabled", reason };
|
|
531
|
+
}
|
|
519
532
|
await deps.cacheSelectedProviderCredentials(options.agentName);
|
|
520
533
|
if (settings.openaiRealtime?.apiKeySource === "integrations.openaiEmbeddingsApiKey") {
|
|
521
534
|
(0, runtime_1.emitNervesEvent)({
|