@yeaft/webchat-agent 1.0.416 → 1.0.418

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.
Files changed (31) hide show
  1. package/browser-runtime/browser-install.js +39 -0
  2. package/browser-runtime/chromium.js +192 -0
  3. package/browser-runtime/cli.js +1 -1
  4. package/browser-runtime/extension/manifest.json +3 -0
  5. package/browser-runtime/extension/offscreen.js +201 -25
  6. package/browser-runtime/extension/popup.js +4 -1
  7. package/browser-runtime/extension/service-worker.js +47 -7
  8. package/browser-runtime/extension.js +1 -1
  9. package/browser-runtime/index.js +3 -0
  10. package/browser-runtime/local-bridge.js +194 -0
  11. package/browser-runtime/messages.js +123 -0
  12. package/browser-runtime/service.js +769 -30
  13. package/connection/index.js +2 -0
  14. package/connection/message-router.js +2 -0
  15. package/index.js +13 -0
  16. package/local-runtime/server/.env.example +10 -0
  17. package/local-runtime/server/browser-runtime-routes.js +284 -0
  18. package/local-runtime/server/client-protocol.js +8 -0
  19. package/local-runtime/server/config.js +28 -0
  20. package/local-runtime/server/handlers/agent-browser.js +393 -0
  21. package/local-runtime/server/handlers/agent-sync.js +14 -0
  22. package/local-runtime/server/handlers/client-browser.js +337 -0
  23. package/local-runtime/server/ws-agent.js +9 -1
  24. package/local-runtime/server/ws-client.js +41 -2
  25. package/local-runtime/version.json +1 -1
  26. package/local-runtime/web/app.bundle.js +220 -98
  27. package/local-runtime/web/app.bundle.js.gz +0 -0
  28. package/local-runtime/web/index.html +2 -2
  29. package/local-runtime/web/style.bundle.css +1 -1
  30. package/local-runtime/web/style.bundle.css.gz +0 -0
  31. package/package.json +1 -1
@@ -53,6 +53,7 @@ export function connect(WebSocketImpl = WebSocket) {
53
53
  if (closedTerminals > 0) {
54
54
  console.log(`[PTY] Closed ${closedTerminals} terminal(s) before Agent transport replacement`);
55
55
  }
56
+ void ctx.browserRuntime?.handleTransportDisconnect?.();
56
57
  }
57
58
  ctx.ws = socket;
58
59
 
@@ -111,6 +112,7 @@ export function connect(WebSocketImpl = WebSocket) {
111
112
  if (closedTerminals > 0) {
112
113
  console.log(`[PTY] Closed ${closedTerminals} terminal(s) after Agent transport disconnect`);
113
114
  }
115
+ void ctx.browserRuntime?.handleTransportDisconnect?.();
114
116
 
115
117
  if (code === 1008) {
116
118
  console.error('Authentication failed. Check AGENT_SECRET configuration.');
@@ -33,6 +33,7 @@ import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
33
33
  import { handleYeaftSessionSend, handleYeaftAskUserAnswer, handleYeaftSubAgentPrompt, handleYeaftTaskCancel, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, refreshLiveSessionConfig, handleYeaftLoadHistory, handleYeaftLoadHistoryOutline, handleYeaftSearchHistory, handleYeaftLoadHistoryWindow, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftProjectContextSync, handleYeaftProjectMutation, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, handleYeaftPluginCatalog, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager, broadcastYeaftVpSnapshotEager, preloadYeaftSkillSlashCommands } from '../yeaft/web-bridge.js';
34
34
  import { startYeaftStatusRefresh, forceRefreshYeaftStatus } from '../yeaft/status-cache.js';
35
35
  import { handleWorkCenterRequest } from '../yeaft/work-center/bridge.js';
36
+ import { handleBrowserRuntimeMessage } from '../browser-runtime/messages.js';
36
37
 
37
38
  export async function applyLlmConfigUpdate(msg, dependencies = {}) {
38
39
  const updateConfig = dependencies.updateLlmConfig || updateLlmConfig;
@@ -94,6 +95,7 @@ export function applyRegisteredTransport(msg) {
94
95
  }
95
96
 
96
97
  export async function handleMessage(msg) {
98
+ if (await handleBrowserRuntimeMessage(msg)) return;
97
99
  switch (msg.type) {
98
100
  case 'registered':
99
101
  applyRegisteredTransport(msg);
package/index.js CHANGED
@@ -23,6 +23,7 @@ import { connect } from './connection.js';
23
23
  import { loadMcpServers } from './mcp.js';
24
24
  import { SAFE_REMOTE_UPGRADE_CAPABILITY } from './upgrade-command.js';
25
25
  import { loadConfig as loadYeaftConfig } from './yeaft/config.js';
26
+ import { updateBrowserRuntimeSettings } from './yeaft/config-api.js';
26
27
  import { bootBrowserRuntime, shutdownBrowserRuntime } from './browser-runtime/index.js';
27
28
  import {
28
29
  ensureManagedCliTools,
@@ -164,6 +165,9 @@ async function detectCapabilities() {
164
165
  if (process.platform === 'linux') capabilities.push('work_item_attachments');
165
166
  const pty = await loadNodePty();
166
167
  if (pty) capabilities.push('terminal');
168
+ if (ctx.browserRuntime) {
169
+ capabilities.push(...ctx.browserRuntime.setupCapabilities(), ...ctx.browserRuntime.capabilities());
170
+ }
167
171
 
168
172
  console.log(`[Capabilities] Detected: ${capabilities.join(', ')}`);
169
173
  return capabilities;
@@ -403,6 +407,15 @@ process.on('SIGTERM', async () => {
403
407
  ctx.browserRuntime = await bootBrowserRuntime({
404
408
  yeaftDir: YEAFT_DIR,
405
409
  config: runtimeConfig,
410
+ saveSettings: update => updateBrowserRuntimeSettings(update, YEAFT_DIR),
411
+ onCapabilitiesChanged: async () => {
412
+ ctx.agentCapabilities = await detectCapabilities();
413
+ ctx.sendToServer?.({
414
+ type: 'agent_capabilities_updated',
415
+ capabilities: ctx.agentCapabilities,
416
+ });
417
+ },
418
+ send: message => ctx.sendToServer?.(message) || 'dropped',
406
419
  });
407
420
  const probe = ctx.browserRuntime.snapshot().probe;
408
421
  if (probe?.ok) {
@@ -44,6 +44,16 @@ EMAIL_CODE_EXPIRES_IN=300000
44
44
  # Agents must provide this secret to connect
45
45
  AGENT_SECRET=agent-shared-secret
46
46
 
47
+ # Browser Runtime (disabled by default)
48
+ # Production deployments should configure endpoint-scoped TURN credentials.
49
+ # BROWSER_RUNTIME_ENABLED=false
50
+ # BROWSER_STUN_URLS=stun:stun.example.com:3478
51
+ # BROWSER_TURN_URLS=turn:turn.example.com:3478,turns:turn.example.com:443?transport=tcp
52
+ # BROWSER_TURN_SECRET=replace-with-coturn-rest-api-secret
53
+ # BROWSER_TURN_TTL_SECONDS=600
54
+ # BROWSER_ICE_TRANSPORT_POLICY=all
55
+ # BROWSER_ROUTE_TTL_MS=900000
56
+
47
57
  # Server-managed Sandbox (disabled by default)
48
58
  # Docker socket access is equivalent to Host root. For Docker Compose, use the
49
59
  # explicit docker-compose.sandbox.yml override documented in docs/operations/sandbox-agent.md.
@@ -0,0 +1,284 @@
1
+ import { createHmac, randomUUID } from 'node:crypto';
2
+ import { CONFIG } from './config.js';
3
+ import { agents, webClients } from './context.js';
4
+
5
+ const MAX_ROUTES = 2048;
6
+ const MAX_PEERS = 4096;
7
+ const MAX_CREATE_REQUESTS = 4096;
8
+ const CREATE_REQUEST_TTL_MS = 10 * 60_000;
9
+ const INSTALL_REQUEST_TTL_MS = 60 * 60_000;
10
+
11
+ export const browserRoutes = new Map();
12
+ export const browserPeers = new Map();
13
+ const browserRequests = new Map();
14
+
15
+ function key(agentId, browserSessionId) {
16
+ return `${String(agentId || '')}\0${String(browserSessionId || '')}`;
17
+ }
18
+
19
+ function createKey(agentId, connectionId, requestId) {
20
+ return `${String(agentId || '')}\0${String(connectionId || '')}\0${String(requestId || '')}`;
21
+ }
22
+
23
+ function scopeUsername({ ownerUserId, agentId, browserSessionId, peerId, connectionGeneration, endpointRole, expiresAt }) {
24
+ const scope = Buffer.from(JSON.stringify({
25
+ ownerUserId,
26
+ agentId,
27
+ browserSessionId,
28
+ peerId,
29
+ connectionGeneration,
30
+ endpointRole,
31
+ credentialId: randomUUID(),
32
+ })).toString('base64url');
33
+ return `${Math.floor(expiresAt / 1000)}:${scope}`;
34
+ }
35
+
36
+ export function mintBrowserIceServers(scope, config = CONFIG.browserRuntime) {
37
+ const stunServers = config.stunUrls.length > 0 ? [{ urls: [...config.stunUrls] }] : [];
38
+ if (config.turnUrls.length === 0) return stunServers;
39
+ const expiresAt = Date.now() + config.credentialTtlSeconds * 1000;
40
+ const username = scopeUsername({ ...scope, expiresAt });
41
+ const credential = createHmac('sha1', config.turnSecret).update(username).digest('base64');
42
+ return [
43
+ ...stunServers,
44
+ { urls: [...config.turnUrls], username, credential, expiresAt },
45
+ ];
46
+ }
47
+
48
+ export function browserServerIdentity(client) {
49
+ return Object.freeze({
50
+ ownerUserId: client.userId,
51
+ clientId: client.id,
52
+ webConnectionId: client.connectionId,
53
+ webConnectionGeneration: client.connectionGeneration,
54
+ });
55
+ }
56
+
57
+ export function pruneBrowserRuntimeRoutes(now = Date.now()) {
58
+ for (const [requestKey, request] of browserRequests) {
59
+ if (request.expiresAt <= now || !webClients.has(request.clientId)) browserRequests.delete(requestKey);
60
+ }
61
+ for (const [peerId, peer] of browserPeers) {
62
+ const client = webClients.get(peer.clientId);
63
+ if ((peer.expiresAt != null && peer.expiresAt <= now)
64
+ || !client || client.connectionId !== peer.webConnectionId) {
65
+ browserPeers.delete(peerId);
66
+ }
67
+ }
68
+ }
69
+
70
+ export function registerBrowserCreateRequest({
71
+ agentId,
72
+ client,
73
+ requestId,
74
+ digest,
75
+ kind = 'browser_session_create',
76
+ }) {
77
+ pruneBrowserRuntimeRoutes();
78
+ const requestKey = createKey(agentId, client.connectionId, requestId);
79
+ const existing = browserRequests.get(requestKey);
80
+ if (existing) {
81
+ if (existing.digest !== digest) return { conflict: true, request: existing };
82
+ return { duplicate: true, request: existing };
83
+ }
84
+ if (browserRequests.size >= MAX_CREATE_REQUESTS) return { capacity: true, request: null };
85
+ const request = {
86
+ agentId,
87
+ requestId,
88
+ serverRequestId: randomUUID(),
89
+ clientId: client.id,
90
+ ownerUserId: client.userId,
91
+ webConnectionId: client.connectionId,
92
+ webConnectionGeneration: client.connectionGeneration,
93
+ digest,
94
+ kind,
95
+ state: 'pending',
96
+ response: null,
97
+ expiresAt: Date.now() + (kind === 'browser_runtime_install'
98
+ ? INSTALL_REQUEST_TTL_MS
99
+ : CREATE_REQUEST_TTL_MS),
100
+ };
101
+ browserRequests.set(requestKey, request);
102
+ return { request };
103
+ }
104
+
105
+ export function findBrowserRequest(agentId, serverRequestId) {
106
+ for (const request of browserRequests.values()) {
107
+ if (request.agentId === agentId && request.serverRequestId === serverRequestId) return request;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ export function completeBrowserRequest(agentId, msg, { consume = false, kinds = null } = {}) {
113
+ const allowedKinds = kinds == null ? null : new Set(Array.isArray(kinds) ? kinds : [kinds]);
114
+ for (const [requestKey, request] of browserRequests) {
115
+ if (request.agentId !== agentId || request.serverRequestId !== msg.requestId || request.state !== 'pending') continue;
116
+ if (allowedKinds && !allowedKinds.has(request.kind)) continue;
117
+ request.state = msg.type.endsWith('_error') ? 'failed' : 'completed';
118
+ request.response = { ...msg, requestId: request.requestId };
119
+ request.expiresAt = Date.now() + CREATE_REQUEST_TTL_MS;
120
+ if (consume) browserRequests.delete(requestKey);
121
+ return request;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ export function registerBrowserRequest({ agentId, client, requestId, kind, browserSessionId = null }) {
127
+ pruneBrowserRuntimeRoutes();
128
+ const requestKey = createKey(agentId, client.connectionId, requestId);
129
+ if (browserRequests.has(requestKey) || browserRequests.size >= MAX_CREATE_REQUESTS) return null;
130
+ const request = {
131
+ agentId,
132
+ requestId,
133
+ serverRequestId: randomUUID(),
134
+ kind,
135
+ browserSessionId,
136
+ clientId: client.id,
137
+ ownerUserId: client.userId,
138
+ webConnectionId: client.connectionId,
139
+ webConnectionGeneration: client.connectionGeneration,
140
+ digest: '',
141
+ state: 'pending',
142
+ response: null,
143
+ expiresAt: Date.now() + CREATE_REQUEST_TTL_MS,
144
+ };
145
+ browserRequests.set(requestKey, request);
146
+ return request;
147
+ }
148
+
149
+ export function installBrowserRoute({ agentId, ownerUserId, msg }) {
150
+ if (!msg.browserSessionId) return null;
151
+ const routeKey = key(agentId, msg.browserSessionId);
152
+ const existing = browserRoutes.get(routeKey);
153
+ if (existing && existing.ownerUserId !== ownerUserId) return null;
154
+ if (!existing && browserRoutes.size >= MAX_ROUTES) return null;
155
+ const route = {
156
+ ownerUserId,
157
+ agentId,
158
+ browserSessionId: msg.browserSessionId,
159
+ revision: Number(msg.revision) || 1,
160
+ state: msg.state || 'ready',
161
+ updatedAt: Date.now(),
162
+ };
163
+ browserRoutes.set(routeKey, route);
164
+ return route;
165
+ }
166
+
167
+ export function getBrowserRoute(agentId, browserSessionId) {
168
+ return browserRoutes.get(key(agentId, browserSessionId)) || null;
169
+ }
170
+
171
+ export function deleteBrowserRoute(agentId, browserSessionId) {
172
+ const routeKey = key(agentId, browserSessionId);
173
+ const deleted = browserRoutes.delete(routeKey);
174
+ for (const [peerId, peer] of browserPeers) {
175
+ if (peer.agentId === agentId && peer.browserSessionId === browserSessionId) browserPeers.delete(peerId);
176
+ }
177
+ return deleted;
178
+ }
179
+
180
+ export function reserveBrowserPeer({ agentId, browserSessionId, client, requestId, connectionGeneration, role = 'viewer' }) {
181
+ pruneBrowserRuntimeRoutes();
182
+ const route = getBrowserRoute(agentId, browserSessionId);
183
+ if (!route || route.ownerUserId !== client.userId || route.state !== 'ready') return { error: 'browser_session_not_found' };
184
+ if (!Number.isSafeInteger(connectionGeneration) || connectionGeneration <= 0) return { error: 'browser_generation_invalid' };
185
+ const duplicate = [...browserPeers.values()].find(peer => (
186
+ peer.agentId === agentId
187
+ && peer.browserSessionId === browserSessionId
188
+ && peer.clientId === client.id
189
+ && peer.webConnectionId === client.connectionId
190
+ && peer.requestId === requestId
191
+ && peer.connectionGeneration === connectionGeneration
192
+ ));
193
+ if (duplicate) return { peer: duplicate, duplicate: true };
194
+ if (browserPeers.size >= MAX_PEERS) return { error: 'browser_peer_capacity' };
195
+ const peerId = randomUUID();
196
+ const expiresAt = Date.now() + CONFIG.browserRuntime.routeTtlMs;
197
+ const peer = {
198
+ peerId,
199
+ requestId,
200
+ ownerUserId: client.userId,
201
+ agentId,
202
+ browserSessionId,
203
+ clientId: client.id,
204
+ webConnectionId: client.connectionId,
205
+ webConnectionGeneration: client.connectionGeneration,
206
+ connectionGeneration,
207
+ role: role === 'interactive' ? 'interactive' : 'viewer',
208
+ state: 'preparing',
209
+ pendingOffer: null,
210
+ pendingCandidates: [],
211
+ agentCandidateCount: 0,
212
+ webCandidateCount: 0,
213
+ iceTransportPolicy: CONFIG.browserRuntime.iceTransportPolicy,
214
+ expiresAt,
215
+ };
216
+ browserPeers.set(peerId, peer);
217
+ return { peer };
218
+ }
219
+
220
+ export function getBrowserPeer(peerId) {
221
+ pruneBrowserRuntimeRoutes();
222
+ return browserPeers.get(String(peerId || '')) || null;
223
+ }
224
+
225
+ export function browserPeerMatchesClient(peer, client, message = {}) {
226
+ return !!peer && !!client
227
+ && peer.ownerUserId === client.userId
228
+ && peer.clientId === client.id
229
+ && peer.webConnectionId === client.connectionId
230
+ && peer.webConnectionGeneration === client.connectionGeneration
231
+ && peer.agentId === message.agentId
232
+ && peer.browserSessionId === message.browserSessionId
233
+ && peer.connectionGeneration === Number(message.connectionGeneration);
234
+ }
235
+
236
+ export function deleteBrowserPeer(peerId) {
237
+ return browserPeers.delete(String(peerId || ''));
238
+ }
239
+
240
+ export function clearBrowserRuntimeForClient(client) {
241
+ if (!client) return [];
242
+ const peers = [];
243
+ for (const [peerId, peer] of browserPeers) {
244
+ if (peer.clientId === client.id && peer.webConnectionId === client.connectionId) {
245
+ browserPeers.delete(peerId);
246
+ peers.push(peer);
247
+ }
248
+ }
249
+ for (const [requestKey, request] of browserRequests) {
250
+ if (request.clientId === client.id && request.webConnectionId === client.connectionId) {
251
+ browserRequests.delete(requestKey);
252
+ }
253
+ }
254
+ return peers;
255
+ }
256
+
257
+ export function clearBrowserRuntimeForAgent(agentId) {
258
+ for (const [routeKey, route] of browserRoutes) {
259
+ if (route.agentId === agentId) browserRoutes.delete(routeKey);
260
+ }
261
+ for (const [peerId, peer] of browserPeers) {
262
+ if (peer.agentId === agentId) browserPeers.delete(peerId);
263
+ }
264
+ for (const [requestKey, request] of browserRequests) {
265
+ if (request.agentId === agentId) browserRequests.delete(requestKey);
266
+ }
267
+ }
268
+
269
+ export function browserClientForPeer(peer) {
270
+ const client = webClients.get(peer?.clientId);
271
+ if (!client || client.connectionId !== peer.webConnectionId
272
+ || client.connectionGeneration !== peer.webConnectionGeneration) return null;
273
+ return client;
274
+ }
275
+
276
+ export function browserAgentForPeer(peer) {
277
+ return agents.get(peer?.agentId) || null;
278
+ }
279
+
280
+ export function __testResetBrowserRuntimeRoutes() {
281
+ browserRoutes.clear();
282
+ browserPeers.clear();
283
+ browserRequests.clear();
284
+ }
@@ -1,4 +1,6 @@
1
1
  export const WORKBENCH_ROUTE_PROTOCOL = 1;
2
+ export const BROWSER_RUNTIME_PROTOCOL = 1;
3
+ export const BROWSER_RUNTIME_SETUP_PROTOCOL = 1;
2
4
 
3
5
  /**
4
6
  * Apply the explicit browser protocol hello to one Server-owned client record.
@@ -10,5 +12,11 @@ export function applyClientHello(client, message) {
10
12
  if (message.workbenchRouteProtocol === WORKBENCH_ROUTE_PROTOCOL) {
11
13
  client.workbenchRouteProtocol = WORKBENCH_ROUTE_PROTOCOL;
12
14
  }
15
+ if (message.browserRuntimeProtocol === BROWSER_RUNTIME_PROTOCOL) {
16
+ client.browserRuntimeProtocol = BROWSER_RUNTIME_PROTOCOL;
17
+ }
18
+ if (message.browserRuntimeSetupProtocol === BROWSER_RUNTIME_SETUP_PROTOCOL) {
19
+ client.browserRuntimeSetupProtocol = BROWSER_RUNTIME_SETUP_PROTOCOL;
20
+ }
13
21
  return true;
14
22
  }
@@ -80,6 +80,10 @@ function loadUsers() {
80
80
  const DEFAULT_JWT_SECRET = 'default-secret-change-in-production';
81
81
  const DEFAULT_AGENT_SECRET = 'agent-shared-secret';
82
82
 
83
+ function commaList(value) {
84
+ return String(value || '').split(',').map(item => item.trim()).filter(Boolean);
85
+ }
86
+
83
87
  export const CONFIG = {
84
88
  // Server settings
85
89
  port: parseInt(process.env.PORT, 10) || 3456,
@@ -115,6 +119,19 @@ export const CONFIG = {
115
119
  // Agent authentication (global fallback — per-user agent_secret is preferred)
116
120
  agentSecret: process.env.AGENT_SECRET || DEFAULT_AGENT_SECRET,
117
121
 
122
+ // Browser Runtime stays fail-closed until the Server rollout gate is enabled.
123
+ // TURN credentials use the standard time-limited HMAC username scheme; Web and
124
+ // Agent endpoints receive separately scoped usernames from the route ledger.
125
+ browserRuntime: {
126
+ enabled: process.env.BROWSER_RUNTIME_ENABLED === 'true',
127
+ iceTransportPolicy: process.env.BROWSER_ICE_TRANSPORT_POLICY === 'relay' ? 'relay' : 'all',
128
+ stunUrls: commaList(process.env.BROWSER_STUN_URLS),
129
+ turnUrls: commaList(process.env.BROWSER_TURN_URLS),
130
+ turnSecret: process.env.BROWSER_TURN_SECRET || '',
131
+ credentialTtlSeconds: Math.min(3600, Math.max(60, parseInt(process.env.BROWSER_TURN_TTL_SECONDS, 10) || 600)),
132
+ routeTtlMs: Math.min(60 * 60_000, Math.max(60_000, parseInt(process.env.BROWSER_ROUTE_TTL_MS, 10) || 15 * 60_000)),
133
+ },
134
+
118
135
  // A Sandbox is an ordinary yeaft-agent container managed by this Server's Docker daemon.
119
136
  // The Server controls only the container lifecycle; Agent behavior stays on the existing wire.
120
137
  sandbox: {
@@ -273,6 +290,17 @@ export function validateProductionConfig() {
273
290
  if (CONFIG.sandbox.enabled && !/^wss?:\/\//.test(CONFIG.sandbox.serverUrl)) {
274
291
  errors.push('SANDBOX_SERVER_URL must be the ws:// or wss:// URL that container Agents use to connect');
275
292
  }
293
+ if (CONFIG.browserRuntime.enabled) {
294
+ const invalidIceUrl = [...CONFIG.browserRuntime.stunUrls, ...CONFIG.browserRuntime.turnUrls]
295
+ .find(url => !/^(?:stun|stuns|turn|turns):/i.test(url));
296
+ if (invalidIceUrl) errors.push(`Invalid Browser Runtime ICE URL: ${invalidIceUrl}`);
297
+ if (CONFIG.browserRuntime.turnUrls.length > 0 && !CONFIG.browserRuntime.turnSecret) {
298
+ errors.push('BROWSER_TURN_SECRET is required when BROWSER_TURN_URLS is configured');
299
+ }
300
+ if (CONFIG.browserRuntime.iceTransportPolicy === 'relay' && CONFIG.browserRuntime.turnUrls.length === 0) {
301
+ errors.push('BROWSER_TURN_URLS is required when BROWSER_ICE_TRANSPORT_POLICY=relay');
302
+ }
303
+ }
276
304
 
277
305
  // Check that at least one user with a password exists (in DB or config)
278
306
  // Only warn (don't block startup) — allows first-time setup via create-user.js