@inetafrica/open-claudia 3.4.11 → 3.4.12

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.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.12
4
+
5
+ - Keep incoming calls on their per-attempt admission policy when the organization canary setting changes; managed access supplies the assigned gateway and TURN configuration. Owner-only audio calling remains enforced.
6
+ - Flatten ICE server URLs for Werift, whose installed parser silently omits TURN when given browser-style URL arrays. Verify actual peer-connection TURN configuration in the tests.
7
+ - Refresh Janus grants and relay credentials for future peer connections. Existing Werift TURN allocations keep their original credentials and expiry, so the bot closes before the earliest active allocation deadline. It does not hot-swap existing allocation authentication; the default one-hour TURN lifetime exceeds the bot's thirty-minute call limit.
8
+ - Exclude the bot's own publisher by its Janus-assigned feed ID, including when the gateway replaces display names with canonical email. Keep the legacy bot display-prefix exclusion.
9
+
3
10
  ## 3.4.11
4
11
 
5
12
  - Verify timed-out guard processes from their parent, including forced termination and released concurrency, without racing a child-written receipt.
@@ -1,12 +1,40 @@
1
1
  "use strict";
2
2
  const { randomUUID } = require("crypto");
3
3
  function validServer(value) {
4
- if (value === undefined) return true;
4
+ if (value === undefined) return false;
5
5
  if (typeof value !== 'string') return false;
6
- try { const url = new URL(value); return url.protocol === 'wss:' && !!url.hostname && !url.username && !url.password && !url.hash; }
6
+ try { const url = new URL(value); return url.protocol === 'wss:' && !!url.hostname && !url.username && !url.password && !url.hash && !url.search; }
7
7
  catch { return false; }
8
8
  }
9
9
 
10
+ function projectAssignedIce(reply, now = Date.now()) {
11
+ if (!Array.isArray(reply.iceServers) || !reply.iceServers.length || reply.iceServers.length > 12
12
+ || !Number.isFinite(reply.iceCredentialsExpiresAt) || reply.iceCredentialsExpiresAt <= now) throw new Error("Invalid assigned call relay");
13
+ const iceServers = reply.iceServers.map(server => {
14
+ if (!server || typeof server !== 'object') throw new Error("Invalid assigned call relay");
15
+ const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
16
+ if (!urls.length || urls.length > 12 || urls.some(url => typeof url !== 'string' || url.length > 1024
17
+ || !/^(stun|turn|turns):[^\s@?#]+(?:\?transport=(udp|tcp))?$/.test(url))) throw new Error("Invalid assigned call relay");
18
+ if (urls.some(url => /^turns?:/.test(url)) && (typeof server.username !== 'string' || !server.username || server.username.length > 2048
19
+ || typeof server.credential !== 'string' || !server.credential || server.credential.length > 2048)) throw new Error("Invalid assigned call relay");
20
+ return { urls: [...urls], ...(server.username === undefined ? {} : { username: server.username }), ...(server.credential === undefined ? {} : { credential: server.credential }) };
21
+ });
22
+ if (!iceServers.some(server => server.urls.some(url => /^turns?:/.test(url)))) throw new Error("Assigned call relay is unavailable");
23
+ return { iceServers, iceCredentialsExpiresAt: reply.iceCredentialsExpiresAt };
24
+ }
25
+
26
+ /** The bot's media adapter supports UDP TURN. TLS-only assignment fails closed. */
27
+ function supportedIceServers(iceServers) {
28
+ const usable = iceServers.flatMap(server => {
29
+ const urls = (Array.isArray(server.urls) ? server.urls : [server.urls]).filter(url => typeof url === 'string' && (url.startsWith('stun:') || (url.startsWith('turn:') && !url.includes('transport=tcp'))));
30
+ // Werift's parser accepts one string URL per entry; arrays silently lose
31
+ // their TURN address/username despite matching the browser ICE shape.
32
+ return urls.map(url => ({ ...server, urls: url }));
33
+ });
34
+ if (!usable.some(server => server.urls.startsWith('turn:'))) throw new Error('Call configuration has no supported TURN route');
35
+ return usable;
36
+ }
37
+
10
38
  function publicCallIdentity(payload = {}) {
11
39
  return {
12
40
  ...(payload.callAttemptId ? { callAttemptId: payload.callAttemptId } : {}),
@@ -28,6 +56,8 @@ function requestRoomAccess(socket, operation, input, { signal, timeoutMs = 10000
28
56
  if (reply.requestId !== requestId) return;
29
57
  if (!reply.ok) return fail("Call room access denied");
30
58
  if (reply.protocolVersion !== 3 || typeof reply.callAttemptId !== "string" || !/^[\w-]{16,128}$/.test(reply.callAttemptId)
59
+ || reply.roomAdmissionRequired !== true
60
+ || typeof reply.nodeId !== 'string' || !/^[\w-]{1,64}$/.test(reply.nodeId)
31
61
  || typeof reply.organizationId !== "string" || !reply.organizationId || reply.deviceId !== input.deviceId
32
62
  || !Number.isSafeInteger(reply.roomId) || reply.roomId <= 0 || !Number.isSafeInteger(reply.generation) || reply.generation < 0
33
63
  || (input.callAttemptId && reply.callAttemptId !== input.callAttemptId)
@@ -38,8 +68,11 @@ function requestRoomAccess(socket, operation, input, { signal, timeoutMs = 10000
38
68
  || typeof reply.janusToken !== "string" || !reply.janusToken || typeof reply.roomToken !== "string" || !reply.roomToken
39
69
  || (reply.leaseExpiresAt !== undefined && (!Number.isFinite(reply.leaseExpiresAt) || reply.leaseExpiresAt <= Date.now()))
40
70
  || !Number.isFinite(reply.roomTokenExpiresAt) || reply.roomTokenExpiresAt <= Date.now()) return fail("Invalid call room access response");
71
+ let assignedIce;
72
+ try { assignedIce = projectAssignedIce(reply); } catch (_) { return fail("Invalid call room access response"); }
41
73
  cleanup();
42
74
  resolve({ ...publicCallIdentity(reply), protocolVersion: 3, deviceId: reply.deviceId, generation: reply.generation,
75
+ roomAdmissionRequired: true, ...assignedIce,
43
76
  ...(reply.janusTokenExpiresAt !== undefined ? {janusTokenExpiresAt: reply.janusTokenExpiresAt} : {}),
44
77
  chatId: reply.chatId, nodeId: reply.nodeId, ...(reply.server !== undefined ? { server: reply.server } : {}), janusToken: reply.janusToken, roomToken: reply.roomToken, roomTokenExpiresAt: reply.roomTokenExpiresAt, ...(reply.leaseExpiresAt !== undefined ? { leaseExpiresAt: reply.leaseExpiresAt } : {}) });
45
78
  };
@@ -50,4 +83,4 @@ function requestRoomAccess(socket, operation, input, { signal, timeoutMs = 10000
50
83
  catch (_) { fail("Call socket unavailable"); }
51
84
  });
52
85
  }
53
- module.exports = { publicCallIdentity, requestRoomAccess };
86
+ module.exports = { publicCallIdentity, requestRoomAccess, projectAssignedIce, supportedIceServers };
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { randomUUID, randomInt } = require("crypto");
4
4
  const { CallLeaseHeartbeat } = require("./lease");
5
- const { publicCallIdentity, requestRoomAccess } = require("./access");
5
+ const { publicCallIdentity, requestRoomAccess, supportedIceServers } = require("./access");
6
6
  const { canonicalForChannel } = require("../../../core/identity");
7
7
  const { KazeeCallSession } = require("./session");
8
8
 
@@ -56,12 +56,9 @@ class KazeeCallManager {
56
56
  catch (_) { throw new Error("Call configuration unavailable"); }
57
57
  const config = res?.config;
58
58
  if (!config || !Array.isArray(config.iceServers)) throw new Error("Call configuration unavailable");
59
- const usable = config.iceServers.flatMap(server => {
60
- const urls = (Array.isArray(server.urls) ? server.urls : [server.urls]).filter(url => typeof url === "string" && (url.startsWith("stun:") || (url.startsWith("turn:") && !url.includes("transport=tcp"))));
61
- return urls.length ? [{ ...server, urls }] : [];
62
- });
63
- if (!usable.some(server => server.urls.some(url => url.startsWith("turn:")))) throw new Error("Call configuration has no supported TURN route");
59
+ const usable = supportedIceServers(config.iceServers);
64
60
  return { iceServers: usable, janusServer: config.janusServer || this.janusUrl, janusToken: config.janusToken,
61
+ iceCredentialsExpiresAt: config.iceCredentialsExpiresAt,
65
62
  roomAdmissionEnabled: config.roomAdmissionEnabled === true };
66
63
  }
67
64
 
@@ -78,12 +75,13 @@ class KazeeCallManager {
78
75
  }
79
76
 
80
77
  _sessionOptions(config, access, identity, signal) {
81
- return { janusUrl: access?.server || config.janusServer, iceServers: config.iceServers, janusToken: access?.janusToken || config.janusToken,
82
- janusTokenExpiresAt: access?.janusTokenExpiresAt, managedRoom: config.roomAdmissionEnabled, roomToken: access?.roomToken, roomTokenExpiresAt: access?.roomTokenExpiresAt,
83
- refreshRoomAccess: config.roomAdmissionEnabled ? async () => {
78
+ return { janusUrl: access?.server || config.janusServer, iceServers: access ? supportedIceServers(access.iceServers) : config.iceServers, janusToken: access?.janusToken || config.janusToken,
79
+ iceCredentialsExpiresAt: access?.iceCredentialsExpiresAt ?? config.iceCredentialsExpiresAt,
80
+ janusTokenExpiresAt: access?.janusTokenExpiresAt, managedRoom: !!access, roomToken: access?.roomToken, roomTokenExpiresAt: access?.roomTokenExpiresAt,
81
+ refreshRoomAccess: access ? async () => {
84
82
  const next = await requestRoomAccess(this.adapter._socket, "room-access", { ...identity, deviceId: this.deviceId }, { signal });
85
83
  if (next.server !== access?.server || next.nodeId !== access?.nodeId || next.generation !== access?.generation) throw new Error('Call room placement changed');
86
- return next;
84
+ return { ...next, iceServers: supportedIceServers(next.iceServers) };
87
85
  } : undefined };
88
86
  }
89
87
 
@@ -124,6 +122,7 @@ class KazeeCallManager {
124
122
  const identity = publicCallIdentity(payload);
125
123
  const reject = (reason) => this.adapter._socket?.emit("call:reject", { ...identity, ...(identity.callAttemptId ? { deviceId: this.deviceId, generation: Number.isSafeInteger(payload.generation) && payload.generation >= 0 ? payload.generation : 0 } : {}), toUserId: callerId, callType, chatId, roomId, reason });
126
124
  if (!callerId || !chatId || !Number.isSafeInteger(roomId) || roomId <= 0) return reject("invalid_call");
125
+ if (payload.roomAdmissionRequired !== undefined && typeof payload.roomAdmissionRequired !== 'boolean') return reject("invalid_call");
127
126
  if (callerId !== String(this.adapter.ownerUserId || "")) return reject("not_allowed");
128
127
  if (callType !== "audio") return reject("unsupported_media");
129
128
  if (this.session || this._pending) return reject("busy");
@@ -133,8 +132,11 @@ class KazeeCallManager {
133
132
  let session;
134
133
  try {
135
134
  const config = await this._fetchCallConfig();
136
- if (config.roomAdmissionEnabled && !identity.callAttemptId) throw new Error("Call identity required");
137
- const access = config.roomAdmissionEnabled ? await requestRoomAccess(this.adapter._socket, "room-access", { ...identity, chatId, deviceId: this.deviceId }, { signal: pending.controller.signal }) : null;
135
+ // Server event metadata belongs to this attempt. The latest org flag only
136
+ // controls new outgoing allocation, never an existing call's admission.
137
+ const managed = payload.roomAdmissionRequired === true;
138
+ if (managed && !identity.callAttemptId) throw new Error("Call identity required");
139
+ const access = managed ? await requestRoomAccess(this.adapter._socket, "room-access", { ...identity, chatId, deviceId: this.deviceId }, { signal: pending.controller.signal }) : null;
138
140
  if (pending.controller.signal.aborted) return;
139
141
  const boundIdentity = access ? { ...publicCallIdentity(access), deviceId: this.deviceId, generation: access.generation } : identity;
140
142
  session = new this.SessionImpl({ roomId, chatId, callerId, botUserId: this.adapter.botUserId,
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("werift");
4
4
  const { JanusClient } = require("./janus-client");
5
+ const { projectAssignedIce, supportedIceServers } = require("./access");
5
6
  const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48 } = require("./audio");
6
7
  const { SpeechStream, synthesizePcm, synthesizeStream: synthesizeStreamPcm } = require("./speech-client");
7
8
  const ttsVoice = require("../../../core/tts-voice");
@@ -73,7 +74,7 @@ const ACK_CHIME_PCM48 = tonePcm48([{ freq: 660, ms: 90 }, { freq: 990, ms: 110 }
73
74
  const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
74
75
 
75
76
  class KazeeCallSession {
76
- constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], onTranscript, onEnded, onMediaReady,
77
+ constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], iceCredentialsExpiresAt, onTranscript, onEnded, onMediaReady,
77
78
  maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
78
79
  JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
79
80
  this.roomId = Number(roomId);
@@ -87,6 +88,7 @@ class KazeeCallSession {
87
88
  this.roomTokenExpiresAt = roomTokenExpiresAt;
88
89
  this.refreshRoomAccess = refreshRoomAccess;
89
90
  this.privateId = null;
91
+ this.publisherId = null;
90
92
  this.greeting = greeting ? String(greeting) : null;
91
93
  this._greeted = false;
92
94
  this._publisherUp = false;
@@ -97,6 +99,9 @@ class KazeeCallSession {
97
99
  this.PeerConnectionImpl = PeerConnectionImpl;
98
100
  this.SpeechStreamImpl = SpeechStreamImpl;
99
101
  this.iceServers = iceServers;
102
+ this.iceCredentialsExpiresAt = iceCredentialsExpiresAt;
103
+ this._transportIceExpiry = new Map();
104
+ this._transportExpiryTimer = null;
100
105
  this.onTranscript = onTranscript || (() => {});
101
106
  this.onEnded = onEnded || (() => {});
102
107
  this.onMediaReady = onMediaReady || (() => {});
@@ -136,15 +141,22 @@ class KazeeCallSession {
136
141
 
137
142
  async _roomAuth(force = false) {
138
143
  if (!this.managedRoom) return this.roomToken ? { token: this.roomToken } : {};
139
- if (force || !this.roomToken || this.roomTokenExpiresAt <= Date.now() + 10000) {
144
+ if (force || !this.roomToken || this.roomTokenExpiresAt <= Date.now() + 10000 || this.iceCredentialsExpiresAt <= Date.now() + 60000) {
140
145
  if (!this.refreshRoomAccess) throw new Error("Call room credentials expired");
141
146
  if (!this._roomRefresh) {
142
147
  this._roomRefresh = Promise.resolve().then(() => this.refreshRoomAccess()).then(access => {
143
148
  if (this.closed) throw new Error("Call ended during credential refresh");
149
+ const assigned = projectAssignedIce(access);
150
+ const iceServers = supportedIceServers(assigned.iceServers);
144
151
  this.roomToken = access.roomToken;
145
152
  this.roomTokenExpiresAt = access.roomTokenExpiresAt;
146
153
  if (access.janusToken) this.janus.token = access.janusToken;
147
154
  if (access.janusTokenExpiresAt !== undefined) this.janusTokenExpiresAt = access.janusTokenExpiresAt;
155
+ // Werift captures TURN auth when each ICE transport is constructed.
156
+ // New credentials apply to future peers, never extend an existing
157
+ // allocation's immutable deadline or mutate its authentication.
158
+ this.iceServers = iceServers;
159
+ this.iceCredentialsExpiresAt = assigned.iceCredentialsExpiresAt;
148
160
  }).finally(() => { this._roomRefresh = null; });
149
161
  }
150
162
  await this._roomRefresh;
@@ -153,6 +165,29 @@ class KazeeCallSession {
153
165
  return { token: this.roomToken };
154
166
  }
155
167
 
168
+ _newPeerConnection() {
169
+ const expiresAt = this.iceCredentialsExpiresAt;
170
+ if (this.managedRoom && (!Number.isFinite(expiresAt) || expiresAt <= Date.now() + 1000)) throw new Error("Call relay credentials unavailable");
171
+ const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
172
+ if (Number.isFinite(expiresAt)) {
173
+ this._transportIceExpiry.set(pc, expiresAt);
174
+ this._armTransportExpiry();
175
+ }
176
+ return pc;
177
+ }
178
+
179
+ _armTransportExpiry() {
180
+ if (this._transportExpiryTimer) clearTimeout(this._transportExpiryTimer);
181
+ this._transportExpiryTimer = null;
182
+ if (this.closed || !this._transportIceExpiry.size) return;
183
+ const deadline = Math.min(...this._transportIceExpiry.values());
184
+ this._transportExpiryTimer = setTimeout(() => {
185
+ this._transportExpiryTimer = null;
186
+ this.close("relay-credentials-expired").catch(() => console.error("Kazee call relay cleanup failed"));
187
+ }, Math.max(0, deadline - Date.now() - 1000));
188
+ this._transportExpiryTimer.unref?.();
189
+ }
190
+
156
191
  async start() {
157
192
  await this.janus.connect();
158
193
  if (this.closed) { await this.janus.close(); throw new Error("Call ended during setup"); }
@@ -213,10 +248,15 @@ class KazeeCallSession {
213
248
  if (data.error_code != null) error.code = Number(data.error_code);
214
249
  throw error;
215
250
  }
216
- if (data.videoroom === "joined") this.privateId = data.private_id ?? null;
251
+ if (data.videoroom === "joined") {
252
+ this.privateId = data.private_id ?? null;
253
+ const publisherId = Number(data.id);
254
+ if (Number.isSafeInteger(publisherId) && publisherId > 0) this.publisherId = publisherId;
255
+ else if (this.managedRoom) throw new Error("Managed publisher identity unavailable");
256
+ }
217
257
  for (const publisher of data.publishers || []) {
218
258
  const feedId = Number(publisher.id);
219
- if (!this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
259
+ if (Number.isSafeInteger(feedId) && feedId > 0 && feedId !== this.publisherId && !this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
220
260
  await this._subscribe(feedId, publisher.display);
221
261
  }
222
262
  }
@@ -264,7 +304,9 @@ class KazeeCallSession {
264
304
 
265
305
  async _publish() {
266
306
  if (this.publisher) return;
267
- const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
307
+ await this._roomAuth();
308
+ if (this.closed || this.publisher) return;
309
+ const pc = this._newPeerConnection();
268
310
  pc.iceConnectionStateChange?.subscribe?.((state) => {
269
311
  clog(`Kazee call publisher ICE: ${state} room=${this.roomId}`);
270
312
  if (state === "connected" || state === "completed") {
@@ -324,9 +366,10 @@ class KazeeCallSession {
324
366
  this.subscribers.set(feedId, null);
325
367
  clog(`Kazee call subscribing: feed=${feedId} display=${String(display || "")}`);
326
368
  try {
369
+ const roomAuth = await this._roomAuth();
327
370
  const handle = await this.janus.attach();
328
371
  if (this.closed) { await this.janus.detach(handle); this.subscribers.delete(feedId); return; }
329
- const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
372
+ const pc = this._newPeerConnection();
330
373
  pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
331
374
  const sub = {
332
375
  handle,
@@ -357,7 +400,7 @@ class KazeeCallSession {
357
400
  if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
358
401
  });
359
402
  this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
360
- const attached = await this.janus.message(handle, { ...(await this._roomAuth()), request: "join", ptype: "subscriber", room: this.roomId, feed: feedId, ...(this.privateId != null ? { private_id: this.privateId } : {}) });
403
+ const attached = await this.janus.message(handle, { ...roomAuth, request: "join", ptype: "subscriber", room: this.roomId, feed: feedId, ...(this.privateId != null ? { private_id: this.privateId } : {}) });
361
404
  await this._consumeSubscriberFrame(feedId, attached);
362
405
  await sub.stt.connect().then(
363
406
  () => clog(`Kazee call STT connected: feed=${feedId}`),
@@ -758,8 +801,8 @@ class KazeeCallSession {
758
801
  async _enforceLimits() {
759
802
  if (this.closed || this._ending) return;
760
803
  const now = Date.now();
761
- if (this.managedRoom && this.janusTokenExpiresAt !== undefined && this.janusTokenExpiresAt <= now + 60000) {
762
- if (this.janusTokenExpiresAt <= now) { await this.close("credentials-expired"); return; }
804
+ if (this.managedRoom && [this.janusTokenExpiresAt, this.roomTokenExpiresAt, this.iceCredentialsExpiresAt].some(expiry => expiry !== undefined && expiry <= now + 60000)) {
805
+ if ([this.janusTokenExpiresAt, this.roomTokenExpiresAt, this.iceCredentialsExpiresAt].some(expiry => expiry !== undefined && expiry <= now)) { await this.close("credentials-expired"); return; }
763
806
  // Uses the same single in-flight request as subscriber room-token refresh.
764
807
  // The next limit tick still closes at expiry if this request stalls.
765
808
  this._roomAuth(true).catch(() => {});
@@ -785,6 +828,8 @@ class KazeeCallSession {
785
828
  const sub = this.subscribers.get(feedId);
786
829
  if (!sub) return;
787
830
  this.subscribers.delete(feedId);
831
+ this._transportIceExpiry.delete(sub.pc);
832
+ this._armTransportExpiry();
788
833
  if (sub.finalTimer) clearTimeout(sub.finalTimer);
789
834
  try { sub.stt.close(); } catch (_) {}
790
835
  try { sub.codec.close(); } catch (_) {}
@@ -795,6 +840,10 @@ class KazeeCallSession {
795
840
  async close(reason = "ended") {
796
841
  if (this.closed) return;
797
842
  this.closed = true;
843
+ if (this._transportExpiryTimer) clearTimeout(this._transportExpiryTimer);
844
+ this._transportExpiryTimer = null;
845
+ const trackedPeers = [...this._transportIceExpiry.keys()];
846
+ this._transportIceExpiry.clear();
798
847
  this._leaseHeartbeat?.close();
799
848
  console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
800
849
  if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
@@ -806,6 +855,7 @@ class KazeeCallSession {
806
855
  try { await this.janus.message(this.publisherHandle, { request: "destroy", room: this.roomId }); } catch (_) {}
807
856
  }
808
857
  try { await this.publisher?.pc?.close(); } catch (_) {}
858
+ for (const pc of trackedPeers) { try { await pc.close(); } catch (_) {} }
809
859
  try { await this.janus.detach(this.publisherHandle); } catch (_) {}
810
860
  try { await this.janus.close(); } catch (_) {}
811
861
  this.codec.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.4.11",
3
+ "version": "3.4.12",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -94,7 +94,7 @@ function fixture() {
94
94
  if (["call:allocate", "call:room-access"].includes(name)) {
95
95
  setImmediate(() => originalEmit(`${name}-result`, { requestId: payload.requestId, ok: true, protocolVersion: 3,
96
96
  callAttemptId: "managed-attempt-123456789", organizationId: "tenant-a", roomId: 100, chatId: "managed-chat",
97
- deviceId: payload.deviceId, generation: 0, ...replyPlacement, janusToken: "root-token", roomToken: "room-token", roomTokenExpiresAt: Date.now() + 300000, leaseExpiresAt: Date.now() + 120000 }));
97
+ deviceId: payload.deviceId, generation: 0, roomAdmissionRequired: true, iceServers: [{ urls: ["turn:secondary:3478", "turns:secondary:5349"], username: "assigned", credential: "assigned" }], iceCredentialsExpiresAt: Date.now() + 3600000, ...replyPlacement, janusToken: "root-token", roomToken: "room-token", roomTokenExpiresAt: Date.now() + 300000, leaseExpiresAt: Date.now() + 120000 }));
98
98
  }
99
99
  return originalEmit(name, payload);
100
100
  };
@@ -105,7 +105,8 @@ function fixture() {
105
105
  assert.strictEqual(f.manager.session.options.janusToken, "root-token");
106
106
  assert.strictEqual(f.manager.session.options.roomToken, "room-token");
107
107
  assert.strictEqual(f.manager.session.options.janusUrl, "wss://assigned/janus");
108
- assert.deepStrictEqual(f.manager.session.options.iceServers[0].urls, ["turn:relay:3478"]);
108
+ assert.strictEqual(f.manager.session.options.iceServers[0].urls, "turn:secondary:3478");
109
+ assert.ok(f.manager.session.options.iceCredentialsExpiresAt > Date.now());
109
110
  const refreshed = await f.manager.session.options.refreshRoomAccess();
110
111
  assert.strictEqual(refreshed.server, "wss://assigned/janus");
111
112
  replyPlacement = {nodeId: "secondary", server: "wss://other/janus"};
@@ -139,8 +140,12 @@ function fixture() {
139
140
  assert.strictEqual(f.manager.session, null, "authoritative end also ends an unanswered modern call");
140
141
  assert.strictEqual(socket.listenerCount("call:allocate-result"), 0);
141
142
  assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0, "ended manager session releases lease listener");
142
- await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "audio", callAttemptId: managedInvite.callAttemptId, organizationId: "tenant-a" });
143
+ f.manager.adapter._request = async () => ({ config: { roomAdmissionEnabled: false, janusServer: "wss://legacy/janus", iceServers: [{ urls: "turn:legacy:3478", username: "legacy", credential: "legacy" }] } });
144
+ await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "audio", callAttemptId: managedInvite.callAttemptId, organizationId: "tenant-a", roomAdmissionRequired: true });
143
145
  const incomingManaged = f.manager.session;
146
+ assert.strictEqual(incomingManaged.options.managedRoom, true);
147
+ assert.strictEqual(incomingManaged.options.janusUrl, "wss://assigned/janus");
148
+ assert.strictEqual(incomingManaged.options.iceServers[0].urls, "turn:secondary:3478");
144
149
  const managedAccept = emitted.filter(event => event.name === "call:accept").at(-1).payload;
145
150
  assert.strictEqual(managedAccept.deviceId, f.manager.deviceId);
146
151
  assert.strictEqual(managedAccept.generation, 0);
@@ -156,6 +161,19 @@ function fixture() {
156
161
  assert.strictEqual(socket.listenerCount("call:heartbeat-result"), 0);
157
162
 
158
163
 
164
+ // Legacy attempts, including those with modern attempt IDs, keep baseline
165
+ // transport even when the organization now enables new managed allocations.
166
+ for (const roomAdmissionRequired of [undefined, false]) {
167
+ f = fixture();
168
+ f.manager.adapter._request = async () => ({ config: { roomAdmissionEnabled: true, janusServer: "wss://legacy/janus", iceServers: [{ urls: "turn:legacy:3478", username: "legacy", credential: "legacy" }] } });
169
+ await f.manager.incoming({ fromUserId: "owner-1", chatId: "legacy", roomId: 99, callType: "audio", callAttemptId: "legacy-attempt-123456789", roomAdmissionRequired });
170
+ assert.strictEqual(f.manager.session.options.managedRoom, false);
171
+ assert.strictEqual(f.manager.session.options.janusUrl, "wss://legacy/janus");
172
+ assert.strictEqual(f.manager.session.options.refreshRoomAccess, undefined);
173
+ assert.ok(!f.events.some(event => event.name === "call:room-access"));
174
+ await f.manager.stop();
175
+ }
176
+
159
177
  f = fixture();
160
178
  await f.manager.incoming({ fromUserId: "owner-1", chatId: "managed-chat", roomId: 100, callType: "video", callAttemptId: "managed-attempt-123456789", organizationId: "tenant-a", generation: 7 });
161
179
  assert.strictEqual(f.events[0].payload.generation, 7, "pre-access decline preserves recipient reinvite generation");
@@ -200,12 +218,23 @@ function fixture() {
200
218
  accessSocket.emit("call:allocate-result", { requestId: requested.requestId, ok: true, protocolVersion: 3, callAttemptId: "attempt-1234567890", organizationId: "tenant-a", roomId: 20, chatId: "chat", deviceId: "different-device", generation: 0, janusToken: "t", roomToken: "t", roomTokenExpiresAt: Date.now() + 300000 });
201
219
  await assert.rejects(accessPromise, /Invalid call room/);
202
220
  assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
203
- for (const server of ["http://assigned/janus", "wss://user:password@assigned/janus", "wss://assigned/janus#fragment", 123]) {
221
+ for (const server of ["http://assigned/janus", "wss://user:password@assigned/janus", "wss://assigned/janus#fragment", "wss://assigned/janus?token=bad", 123]) {
204
222
  const invalid = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
205
223
  accessSocket.emit("call:allocate-result", { requestId: requested.requestId, ok: true, protocolVersion: 3, callAttemptId: "attempt-1234567890", organizationId: "tenant-a", roomId: 20, chatId: "chat", deviceId: "device-1234567890", generation: 0, server, janusToken: "t", roomToken: "t", roomTokenExpiresAt: Date.now() + 300000 });
206
224
  await assert.rejects(invalid, /Invalid call room/);
207
225
  assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
208
226
  }
227
+ for (const malformed of [
228
+ { roomAdmissionRequired: undefined }, { roomAdmissionRequired: false },
229
+ { iceServers: undefined }, { iceServers: [] }, { iceCredentialsExpiresAt: Date.now() - 1 },
230
+ { iceServers: [{ urls: 'turn:user:secret@relay:3478', username: 'u', credential: 'p' }] },
231
+ { iceServers: [{ urls: 'turn:relay:3478' }] },
232
+ ]) {
233
+ const invalid = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" });
234
+ accessSocket.emit("call:allocate-result", { requestId: requested.requestId, ok: true, protocolVersion: 3, callAttemptId: "attempt-1234567890", organizationId: "tenant-a", roomId: 20, chatId: "chat", deviceId: "device-1234567890", generation: 0, server: "wss://assigned/janus", nodeId: "secondary", roomAdmissionRequired: true, iceServers: [{ urls: 'turn:relay:3478', username: 'u', credential: 'p' }], iceCredentialsExpiresAt: Date.now() + 3600000, janusToken: "t", roomToken: "t", roomTokenExpiresAt: Date.now() + 300000, ...malformed });
235
+ await assert.rejects(invalid, /Invalid call room/);
236
+ assert.strictEqual(accessSocket.listenerCount("call:allocate-result"), 0);
237
+ }
209
238
  const controller = new AbortController();
210
239
  const cancelledAccess = requestRoomAccess(accessSocket, "allocate", { deviceId: "device-1234567890", chatId: "chat" }, { signal: controller.signal });
211
240
  controller.abort();
@@ -19,7 +19,7 @@ class FakeJanus {
19
19
  }
20
20
 
21
21
  class FakePC {
22
- constructor() { this.onTrack = { subscribe: () => {} }; }
22
+ constructor(options) { this.options = options; this.onTrack = { subscribe: () => {} }; }
23
23
  addTrack() { return { codec: { payloadType: 111 } }; }
24
24
  async createOffer() { return {}; }
25
25
  async setLocalDescription() {}
@@ -448,19 +448,22 @@ async function testManagedRoomCredentials() {
448
448
  constructor(options) { super(); this.options = options; this.calls = []; }
449
449
  async message(handle, body) {
450
450
  this.calls.push(body);
451
- if (body.request === "join" && body.ptype === "publisher") return { plugindata: { data: { videoroom: "joined", private_id: 999, publishers: [] } } };
451
+ if (body.request === "join" && body.ptype === "publisher") return { plugindata: { data: { videoroom: "joined", id: 41, private_id: 999, publishers: [] } } };
452
452
  return {};
453
453
  }
454
454
  }
455
455
  let refreshes = 0;
456
456
  const session = new KazeeCallSession({ roomId: 7, chatId: "chat", callerId: "owner", botUserId: "bot", janusUrl: "ws://test",
457
- janusToken: "root-token", roomToken: "publisher-token", roomTokenExpiresAt: Date.now() + 300000, managedRoom: true, asCaller: true,
458
- refreshRoomAccess: async () => { refreshes += 1; return { janusToken: "fresh-root", janusTokenExpiresAt: Date.now() + 86400000, roomToken: "subscriber-token", roomTokenExpiresAt: Date.now() + 300000 }; },
457
+ janusToken: "root-token", roomToken: "publisher-token", roomTokenExpiresAt: Date.now() + 300000, managedRoom: true, asCaller: true, iceServers: [{ urls: "turn:relay:3478", username: "fixture", credential: "fixture" }], iceCredentialsExpiresAt: Date.now() + 3600000,
458
+ refreshRoomAccess: async () => { refreshes += 1; return { iceServers: [{ urls: "turn:relay:3478", username: "fresh", credential: "fresh" }], iceCredentialsExpiresAt: Date.now() + 3600000, janusToken: "fresh-root", janusTokenExpiresAt: Date.now() + 86400000, roomToken: "subscriber-token", roomTokenExpiresAt: Date.now() + 300000 }; },
459
459
  JanusClientImpl: SignedJanus, PeerConnectionImpl: FakePC, SpeechStreamImpl: FakeSpeechStream });
460
460
  session._maybePublish = async () => {};
461
461
  await session.start();
462
462
  assert.strictEqual(session.janus.options.token, "root-token");
463
463
  assert.strictEqual(session.janus.calls[0].token, "publisher-token");
464
+ await session._consumePublisherFrame({ plugindata: { data: { publishers: [{ id: 41, display: 'canonical-bot@calling.invalid' }, { id: 42, display: 'open-claudia:legacy-bot' }] } } });
465
+ assert.strictEqual(session.subscribers.size, 0, 'own joined publisher ID is excluded independently of canonical display; legacy bot exclusion remains');
466
+
464
467
  session.roomTokenExpiresAt = Date.now() - 1;
465
468
  await Promise.all([session._subscribe(17, "owner"), session._subscribe(18, "other")]);
466
469
  const subJoin = session.janus.calls.find(body => body.ptype === "subscriber");
@@ -481,7 +484,61 @@ async function testManagedRoomCredentials() {
481
484
  assert.ok(!session.janus.calls.some(body => ["create", "destroy"].includes(body.request)), "server allocated room ownership never transfers to the bot");
482
485
  }
483
486
 
487
+ async function testAssignedRelayTransportLifetime() {
488
+ const { RTCPeerConnection } = require('werift');
489
+ const originalExpiry = Date.now() + 3600000;
490
+ const assigned = (username, expiresAt) => ({ iceServers: [{ urls: 'turn:127.0.0.1:3478', username, credential: username + '-password' }], iceCredentialsExpiresAt: expiresAt });
491
+ const session = new KazeeCallSession({ roomId: 7, chatId: 'chat', callerId: 'owner', botUserId: 'bot', managedRoom: true,
492
+ ...assigned('original-fixture', originalExpiry), roomToken: 'room', roomTokenExpiresAt: Date.now() + 300000,
493
+ refreshRoomAccess: async () => ({ ...assigned('renewed-fixture', originalExpiry + 3600000), roomToken: 'renewed-room', roomTokenExpiresAt: Date.now() + 300000, janusToken: 'renewed-root', janusTokenExpiresAt: Date.now() + 300000 }),
494
+ JanusClientImpl: FakeJanus, PeerConnectionImpl: RTCPeerConnection, SpeechStreamImpl: FakeSpeechStream });
495
+ try {
496
+ const old = session._newPeerConnection(); old.addTransceiver('audio');
497
+ assert.strictEqual(old.iceTransports[0].connection.options.turnUsername, 'original-fixture');
498
+ // Calling Werift's public setter cannot refresh an already-created ICE
499
+ // transport. Verify the installed implementation, without network gathering.
500
+ old.setConfiguration({ iceServers: assigned('setter-fixture', originalExpiry).iceServers });
501
+ assert.strictEqual(old.getConfiguration().iceServers[0].username, 'setter-fixture');
502
+ assert.strictEqual(old.iceTransports[0].connection.options.turnUsername, 'original-fixture');
503
+ await session._roomAuth(true);
504
+ assert.strictEqual(session._transportIceExpiry.get(old), originalExpiry, 'new access never extends an existing allocation deadline');
505
+ assert.strictEqual(old.iceTransports[0].connection.options.turnUsername, 'original-fixture');
506
+ const fresh = session._newPeerConnection(); fresh.addTransceiver('audio');
507
+ assert.strictEqual(fresh.iceTransports[0].connection.options.turnUsername, 'renewed-fixture');
508
+ assert.strictEqual(session._transportIceExpiry.get(fresh), originalExpiry + 3600000);
509
+ } finally { await session.close(); }
510
+ assert.strictEqual(session._transportExpiryTimer, null);
511
+ assert.strictEqual(session._transportIceExpiry.size, 0);
512
+
513
+ let ended;
514
+ const bounded = new KazeeCallSession({ roomId: 7, chatId: 'chat', callerId: 'owner', botUserId: 'bot', managedRoom: true,
515
+ ...assigned('short-fixture', Date.now() + 1500), roomToken: 'room', roomTokenExpiresAt: Date.now() + 300000,
516
+ refreshRoomAccess: async () => ({ ...assigned('fresh-fixture', Date.now() + 3600000), roomToken: 'fresh', roomTokenExpiresAt: Date.now() + 300000 }),
517
+ onEnded: reason => { ended = reason; }, JanusClientImpl: FakeJanus, PeerConnectionImpl: FakePC, SpeechStreamImpl: FakeSpeechStream });
518
+ try {
519
+ const old = bounded._newPeerConnection();
520
+ const deadline = bounded._transportIceExpiry.get(old);
521
+ await bounded._roomAuth(true);
522
+ assert.strictEqual(bounded._transportIceExpiry.get(old), deadline);
523
+ const until = Date.now() + 2000;
524
+ while (!bounded.closed && Date.now() < until) await wait(25);
525
+ assert.strictEqual(ended, 'relay-credentials-expired', 'existing transport closes on its original expiry despite successful refresh');
526
+ } finally { await bounded.close(); }
527
+
528
+ let publications = 0;
529
+ class PublishingPC extends FakePC { constructor(options) { super(options); publications += 1; } }
530
+ class PublishingJanus extends FakeJanus { async message() { return { jsep: { type: 'answer', sdp: 'fixture' } }; } }
531
+ const concurrent = new KazeeCallSession({ roomId: 7, chatId: 'chat', callerId: 'owner', botUserId: 'bot', managedRoom: true,
532
+ ...assigned('original-fixture', Date.now() + 3600000), roomToken: 'room', roomTokenExpiresAt: Date.now() + 300000,
533
+ JanusClientImpl: PublishingJanus, PeerConnectionImpl: PublishingPC });
534
+ try { await Promise.all([concurrent._publish(), concurrent._publish()]); assert.strictEqual(publications, 1, 'concurrent publisher events cannot race across async room authorization'); } finally { await concurrent.close(); }
535
+
536
+ const missing = new KazeeCallSession({ roomId: 7, chatId: 'chat', callerId: 'owner', botUserId: 'bot', managedRoom: true, JanusClientImpl: FakeJanus, PeerConnectionImpl: FakePC });
537
+ try { assert.throws(() => missing._newPeerConnection(), /relay credentials unavailable/); } finally { await missing.close(); }
538
+ }
539
+
484
540
  (async () => {
541
+ await testAssignedRelayTransportLifetime();
485
542
  await testManagedRoomCredentials();
486
543
  await testSpeakSerialization();
487
544
  await testSpeakCancellation();
@@ -463,8 +463,8 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
463
463
  // Codex turn. Live probe: unified-exec shell calls still reach PreToolUse as
464
464
  // tool_name "Bash" and the deny gate caught the probe, so the gate no longer
465
465
  // requires unified_exec off. Push approved by Sumeet before release.
466
- // 3.4.11: owner approved end-to-end calling rollout on 2026-09-05.
467
- assert.strictEqual(pkg.version, "3.4.11", "release version must remain unchanged without explicit approval");
466
+ // 3.4.12: Sumeet approved the full calling rollout on 2026-09-05; preserve assigned media configuration across canary changes.
467
+ assert.strictEqual(pkg.version, "3.4.12", "release version must remain unchanged without explicit approval");
468
468
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
469
469
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
470
470
  }