@ouro.bot/cli 0.1.0-alpha.888 → 0.1.0-alpha.889

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,344 @@
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.LocalAudioControlServer = void 0;
37
+ exports.localAudioPaths = localAudioPaths;
38
+ exports.sendLocalAudioControl = sendLocalAudioControl;
39
+ exports.inspectActiveJoin = inspectActiveJoin;
40
+ exports.lockOwnerAlive = lockOwnerAlive;
41
+ exports.stopLockHolder = stopLockHolder;
42
+ exports.acquireJoinLock = acquireJoinLock;
43
+ exports.writeJoinStatus = writeJoinStatus;
44
+ exports.readJoinStatus = readJoinStatus;
45
+ exports.parseNotifySession = parseNotifySession;
46
+ const crypto = __importStar(require("node:crypto"));
47
+ const fs = __importStar(require("fs"));
48
+ const net = __importStar(require("net"));
49
+ const os = __importStar(require("os"));
50
+ const path = __importStar(require("path"));
51
+ const runtime_1 = require("../../nerves/runtime");
52
+ const local_audio_devices_1 = require("./local-audio-devices");
53
+ /** macOS unix socket paths are limited to about 104 bytes. */
54
+ const MAX_SOCKET_PATH = 100;
55
+ const MAX_REQUEST_BYTES = 1024;
56
+ const DEFAULT_CLIENT_TIMEOUT_MS = 10_000;
57
+ function localAudioPaths(agentRoot) {
58
+ const dir = path.join(agentRoot, "state", "voice", "local-audio");
59
+ let socketPath = path.join(dir, "control.sock");
60
+ if (socketPath.length > MAX_SOCKET_PATH) {
61
+ const digest = crypto.createHash("sha256").update(dir).digest("hex").slice(0, 12);
62
+ socketPath = path.join(os.tmpdir(), `ouro-la-${digest}.sock`);
63
+ }
64
+ return {
65
+ dir,
66
+ socketPath,
67
+ pidFile: path.join(dir, "join.pid"),
68
+ statusFile: path.join(dir, "status.json"),
69
+ soxPidFile: path.join(dir, "sox.pids"),
70
+ callsDir: path.join(dir, "calls"),
71
+ logFile: path.join(dir, "join.log"),
72
+ lockFile: path.join(dir, "join.lock"),
73
+ };
74
+ }
75
+ class LocalAudioControlServer {
76
+ paths;
77
+ handler;
78
+ server;
79
+ constructor(paths, handler) {
80
+ this.paths = paths;
81
+ this.handler = handler;
82
+ }
83
+ async start() {
84
+ fs.mkdirSync(this.paths.dir, { recursive: true, mode: 0o700 });
85
+ fs.chmodSync(this.paths.dir, 0o700);
86
+ fs.rmSync(this.paths.socketPath, { force: true });
87
+ this.server = net.createServer((socket) => this.serve(socket));
88
+ await new Promise((resolve, reject) => {
89
+ this.server.once("error", reject);
90
+ this.server.listen(this.paths.socketPath, resolve);
91
+ });
92
+ fs.chmodSync(this.paths.socketPath, 0o600);
93
+ fs.writeFileSync(this.paths.pidFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { mode: 0o600 });
94
+ (0, runtime_1.emitNervesEvent)({
95
+ component: "senses",
96
+ event: "senses.voice_local_control_started",
97
+ message: "local audio control socket listening",
98
+ meta: { socketPath: this.paths.socketPath },
99
+ });
100
+ }
101
+ async close() {
102
+ const server = this.server;
103
+ this.server = undefined;
104
+ if (server)
105
+ await new Promise((resolve) => server.close(() => resolve()));
106
+ fs.rmSync(this.paths.socketPath, { force: true });
107
+ fs.rmSync(this.paths.pidFile, { force: true });
108
+ }
109
+ serve(socket) {
110
+ let buffer = "";
111
+ let answered = false;
112
+ const reply = (body) => {
113
+ socket.end(`${JSON.stringify(body)}\n`);
114
+ };
115
+ socket.on("error", () => undefined);
116
+ socket.on("data", (chunk) => {
117
+ // One request per connection: later bytes can never trigger a second leave or reply.
118
+ if (answered)
119
+ return;
120
+ buffer += chunk.toString();
121
+ if (buffer.length > MAX_REQUEST_BYTES) {
122
+ answered = true;
123
+ reply({ ok: false, error: "request too large" });
124
+ return;
125
+ }
126
+ const newline = buffer.indexOf("\n");
127
+ if (newline === -1)
128
+ return;
129
+ answered = true;
130
+ void this.handle(buffer.slice(0, newline), reply);
131
+ });
132
+ }
133
+ async handle(line, reply) {
134
+ let command;
135
+ try {
136
+ command = JSON.parse(line).cmd;
137
+ }
138
+ catch {
139
+ reply({ ok: false, error: "invalid request" });
140
+ return;
141
+ }
142
+ try {
143
+ if (command === "status") {
144
+ reply({ ok: true, status: this.handler.status() });
145
+ }
146
+ else if (command === "leave") {
147
+ await this.handler.leave();
148
+ reply({ ok: true, status: this.handler.status() });
149
+ }
150
+ else {
151
+ reply({ ok: false, error: "unknown command" });
152
+ }
153
+ }
154
+ catch (error) {
155
+ reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
156
+ }
157
+ }
158
+ }
159
+ exports.LocalAudioControlServer = LocalAudioControlServer;
160
+ function sendLocalAudioControl(paths, cmd, timeoutMs = DEFAULT_CLIENT_TIMEOUT_MS) {
161
+ return new Promise((resolve) => {
162
+ let buffer = "";
163
+ // A promise settles once, so a late second `done` (end after error) only repeats harmless cleanup.
164
+ const done = (reply) => {
165
+ clearTimeout(timer);
166
+ socket.destroy();
167
+ resolve(reply);
168
+ };
169
+ const socket = net.createConnection(paths.socketPath);
170
+ const timer = setTimeout(() => done({ ok: false, code: "timeout", error: "local audio session did not answer in time" }), timeoutMs);
171
+ socket.on("connect", () => socket.write(`${JSON.stringify({ cmd })}\n`));
172
+ socket.on("data", (chunk) => { buffer += chunk.toString(); });
173
+ socket.on("end", () => {
174
+ try {
175
+ done(JSON.parse(buffer.trim()));
176
+ }
177
+ catch {
178
+ done({ ok: false, code: "protocol", error: "local audio session sent an unreadable reply" });
179
+ }
180
+ });
181
+ socket.on("error", (error) => {
182
+ // Only a refused or missing socket proves nobody is listening; anything else may be a live, busy session.
183
+ const unreachable = error.code === "ECONNREFUSED" || error.code === "ENOENT" || error.code === "ENOTSOCK";
184
+ done(unreachable
185
+ ? { ok: false, code: "unreachable", error: "no local audio session is running" }
186
+ : { ok: false, code: "protocol", error: `could not reach the local audio session (${error.code})` });
187
+ });
188
+ });
189
+ }
190
+ /**
191
+ * The live status when a join is running; otherwise null. A join that holds the lock but has not
192
+ * opened its socket yet reports "starting". Leftover socket and pid files are deleted only when the
193
+ * socket is provably dead (connection refused, missing, or not a socket) and no live process holds the lock; a
194
+ * timeout never deletes anything, because a slow session is still a session.
195
+ */
196
+ async function inspectActiveJoin(paths, timeoutMs = 2_000) {
197
+ const reply = await sendLocalAudioControl(paths, "status", timeoutMs);
198
+ if (reply.ok)
199
+ return reply.status;
200
+ const holder = readJoinLock(paths);
201
+ if (holder.live)
202
+ return { state: "starting", callSid: "starting" };
203
+ if (reply.code === "unreachable") {
204
+ fs.rmSync(paths.socketPath, { force: true });
205
+ fs.rmSync(paths.pidFile, { force: true });
206
+ }
207
+ return null;
208
+ }
209
+ /**
210
+ * True when `pid` is a live process of ours that is still the one that wrote the lock. A process we
211
+ * may not signal (EPERM) belongs to someone else, so it is never ours; a recorded start time must
212
+ * match, so a pid reused after a crash or reboot does not count.
213
+ */
214
+ function lockOwnerAlive(pid, startTime, inspect = local_audio_devices_1.defaultInspectProcess) {
215
+ try {
216
+ process.kill(pid, 0);
217
+ }
218
+ catch {
219
+ return false;
220
+ }
221
+ return startTime === undefined || inspect(pid)?.startTime === startTime;
222
+ }
223
+ /** A lock file with no readable owner is treated as held for this long (its owner may be mid-write). */
224
+ const UNREADABLE_LOCK_GRACE_MS = 5_000;
225
+ /** A missing or unreadable lock reads as an old, ownerless record: stale, never held. */
226
+ function readLockFile(file) {
227
+ let text = "";
228
+ let mtimeMs = 0;
229
+ try {
230
+ text = fs.readFileSync(file, "utf8");
231
+ mtimeMs = fs.statSync(file).mtimeMs;
232
+ }
233
+ catch { /* leave the record empty and old */ }
234
+ try {
235
+ const { pid, startTime } = JSON.parse(text);
236
+ return { text, pid: typeof pid === "number" ? pid : undefined, ...(typeof startTime === "string" ? { startTime } : {}), mtimeMs };
237
+ }
238
+ catch {
239
+ return { text, pid: undefined, mtimeMs };
240
+ }
241
+ }
242
+ /** Held means a live owner, or an ownerless file young enough that its owner may be mid-write. */
243
+ function lockIsHeld(lock, isAlive) {
244
+ return lock.pid === undefined ? Date.now() - lock.mtimeMs < UNREADABLE_LOCK_GRACE_MS : isAlive(lock.pid, lock.startTime);
245
+ }
246
+ function readJoinLock(paths, isAlive = lockOwnerAlive) {
247
+ const lock = readLockFile(paths.lockFile);
248
+ return { live: lockIsHeld(lock, isAlive), ...(lock.pid !== undefined ? { pid: lock.pid } : {}) };
249
+ }
250
+ /** Tells the process holding the join lock to stop (SIGTERM). Returns its pid, or null when nobody live holds it. */
251
+ function stopLockHolder(paths, kill = process.kill.bind(process), isAlive = lockOwnerAlive) {
252
+ const holder = readJoinLock(paths, isAlive);
253
+ if (!holder.live || holder.pid === undefined || holder.pid === process.pid)
254
+ return null;
255
+ try {
256
+ kill(holder.pid, "SIGTERM");
257
+ }
258
+ catch {
259
+ return null;
260
+ }
261
+ return holder.pid;
262
+ }
263
+ /**
264
+ * The single-join lock: an exclusive (O_EXCL) file holding the owner's pid. Another live process
265
+ * holding it means a join is running or starting, so the caller must not touch its sox, socket or
266
+ * files. A lock whose owner is dead is reclaimed; the reclaim renames the file away first and puts
267
+ * it back if it turns out to belong to someone else, so two reclaimers cannot both win.
268
+ */
269
+ function acquireJoinLock(paths, pid = process.pid, isAlive = lockOwnerAlive, startTimeOf = (owner) => (0, local_audio_devices_1.defaultInspectProcess)(owner)?.startTime) {
270
+ fs.mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
271
+ fs.chmodSync(paths.dir, 0o700);
272
+ for (;;) {
273
+ try {
274
+ const fd = fs.openSync(paths.lockFile, "wx", 0o600);
275
+ try {
276
+ const startTime = startTimeOf(pid);
277
+ fs.writeSync(fd, JSON.stringify({ pid, ...(startTime ? { startTime } : {}), startedAt: new Date().toISOString() }));
278
+ }
279
+ finally {
280
+ fs.closeSync(fd);
281
+ }
282
+ return {
283
+ ok: true,
284
+ release: () => {
285
+ if (readLockFile(paths.lockFile)?.pid === pid)
286
+ fs.rmSync(paths.lockFile, { force: true });
287
+ },
288
+ };
289
+ }
290
+ catch (error) {
291
+ if (error.code !== "EEXIST")
292
+ throw error;
293
+ }
294
+ const seen = readLockFile(paths.lockFile);
295
+ if (lockIsHeld(seen, isAlive))
296
+ return { ok: false, holderPid: seen.pid };
297
+ const claimed = `${paths.lockFile}.claim.${pid}.${crypto.randomBytes(4).toString("hex")}`;
298
+ try {
299
+ fs.renameSync(paths.lockFile, claimed);
300
+ }
301
+ catch {
302
+ continue;
303
+ }
304
+ const taken = readLockFile(claimed);
305
+ fs.rmSync(claimed, { force: true });
306
+ if (taken.text !== seen.text) {
307
+ // We moved aside a lock another process had just created: give it back and report it held.
308
+ try {
309
+ fs.writeFileSync(paths.lockFile, taken.text, { flag: "wx", mode: 0o600 });
310
+ }
311
+ catch { /* its owner already wrote a new one */ }
312
+ return { ok: false, holderPid: taken.pid };
313
+ }
314
+ }
315
+ }
316
+ function writeJoinStatus(paths, status) {
317
+ fs.mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
318
+ fs.writeFileSync(paths.statusFile, JSON.stringify({ ...status, updatedAt: new Date().toISOString() }), { mode: 0o600 });
319
+ }
320
+ function readJoinStatus(paths) {
321
+ try {
322
+ return JSON.parse(fs.readFileSync(paths.statusFile, "utf8"));
323
+ }
324
+ catch {
325
+ return null;
326
+ }
327
+ }
328
+ const UNSAFE_SEGMENT = /[/\\\0]|^\.{1,2}$/;
329
+ /** `friend:channel:key` (the key may contain colons). Refuses anything that could leave the pending directory. */
330
+ function parseNotifySession(value) {
331
+ const first = value.indexOf(":");
332
+ const second = value.indexOf(":", first + 1);
333
+ if (first <= 0 || second <= first + 1)
334
+ return null;
335
+ const friendId = value.slice(0, first);
336
+ const channel = value.slice(first + 1, second);
337
+ const key = value.slice(second + 1);
338
+ if (!key)
339
+ return null;
340
+ for (const segment of [friendId, channel, key])
341
+ if (UNSAFE_SEGMENT.test(segment))
342
+ return null;
343
+ return { friendId, channel, key };
344
+ }