@yeaft/webchat-agent 1.0.415 → 1.0.417
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/browser-runtime/chromium.js +192 -0
- package/browser-runtime/cli.js +1 -1
- package/browser-runtime/extension/manifest.json +3 -0
- package/browser-runtime/extension/offscreen.js +201 -25
- package/browser-runtime/extension/popup.js +4 -1
- package/browser-runtime/extension/service-worker.js +47 -7
- package/browser-runtime/extension.js +1 -1
- package/browser-runtime/index.js +3 -0
- package/browser-runtime/local-bridge.js +194 -0
- package/browser-runtime/messages.js +73 -0
- package/browser-runtime/service.js +549 -26
- package/connection/index.js +2 -0
- package/connection/message-router.js +2 -0
- package/index.js +2 -0
- package/local-runtime/agent/container-manager.js +189 -0
- package/local-runtime/server/.env.example +10 -0
- package/local-runtime/server/browser-runtime-routes.js +265 -0
- package/local-runtime/server/client-protocol.js +4 -0
- package/local-runtime/server/config.js +28 -0
- package/local-runtime/server/handlers/agent-browser.js +308 -0
- package/local-runtime/server/handlers/client-browser.js +290 -0
- package/local-runtime/server/ws-agent.js +8 -1
- package/local-runtime/server/ws-client.js +33 -2
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +152 -95
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/scripts/prepare-local-runtime.js +39 -20
|
@@ -1,22 +1,110 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { normaliseBrowserRuntimeSection } from './config.js';
|
|
3
3
|
import { defaultBrowserCacheDir } from './browser-install.js';
|
|
4
4
|
import { probeBrowserRuntime } from './probe.js';
|
|
5
5
|
import { BrowserRuntimeError } from './errors.js';
|
|
6
|
+
import { BrowserExtensionBridge } from './local-bridge.js';
|
|
7
|
+
import { launchBrowserSession } from './chromium.js';
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const REQUEST_TTL_MS = 10 * 60_000;
|
|
10
|
+
|
|
11
|
+
function clean(value, max = 512) {
|
|
12
|
+
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function positiveInteger(value) {
|
|
16
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeInitialUrl(value) {
|
|
20
|
+
const raw = clean(value, 4096) || 'about:blank';
|
|
21
|
+
if (raw === 'about:blank') return raw;
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(raw);
|
|
24
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
25
|
+
throw new BrowserRuntimeError('browser_url_invalid');
|
|
26
|
+
}
|
|
27
|
+
return url.href;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof BrowserRuntimeError) throw error;
|
|
30
|
+
throw new BrowserRuntimeError('browser_url_invalid');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stableDigest(value) {
|
|
35
|
+
return createHash('sha256').update(JSON.stringify(value || null)).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeIdentity(value) {
|
|
39
|
+
const identity = value && typeof value === 'object' ? value : {};
|
|
40
|
+
const result = Object.freeze({
|
|
41
|
+
ownerUserId: clean(identity.ownerUserId),
|
|
42
|
+
clientId: clean(identity.clientId),
|
|
43
|
+
webConnectionId: clean(identity.webConnectionId),
|
|
44
|
+
webConnectionGeneration: clean(identity.webConnectionGeneration),
|
|
45
|
+
});
|
|
46
|
+
if (!result.ownerUserId || !result.clientId || !result.webConnectionId || !result.webConnectionGeneration) {
|
|
47
|
+
throw new BrowserRuntimeError('browser_identity_required');
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sameOwner(a, b) {
|
|
53
|
+
return a?.ownerUserId === b?.ownerUserId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sameConnection(a, b) {
|
|
57
|
+
return sameOwner(a, b)
|
|
58
|
+
&& a?.clientId === b?.clientId
|
|
59
|
+
&& a?.webConnectionId === b?.webConnectionId
|
|
60
|
+
&& a?.webConnectionGeneration === b?.webConnectionGeneration;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function publicIceServers(value) {
|
|
64
|
+
if (!Array.isArray(value)) return [];
|
|
65
|
+
return value.slice(0, 8).map(server => ({
|
|
66
|
+
urls: Array.isArray(server?.urls)
|
|
67
|
+
? server.urls.slice(0, 8).map(url => clean(url, 2048)).filter(Boolean)
|
|
68
|
+
: clean(server?.urls, 2048),
|
|
69
|
+
...(clean(server?.username, 512) ? { username: clean(server.username, 512) } : {}),
|
|
70
|
+
...(clean(server?.credential, 1024) ? { credential: clean(server.credential, 1024) } : {}),
|
|
71
|
+
})).filter(server => Array.isArray(server.urls) ? server.urls.length > 0 : !!server.urls);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sourceRef(value) {
|
|
75
|
+
if (!value || typeof value !== 'object') return null;
|
|
76
|
+
const kind = ['yeaft-session', 'chat-conversation', 'work-item'].includes(value.kind) ? value.kind : null;
|
|
77
|
+
if (!kind) return null;
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
kind,
|
|
80
|
+
...(clean(value.sessionId) ? { sessionId: clean(value.sessionId) } : {}),
|
|
81
|
+
...(clean(value.conversationId) ? { conversationId: clean(value.conversationId) } : {}),
|
|
82
|
+
...(clean(value.workItemId) ? { workItemId: clean(value.workItemId) } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Agent-local owner for Browser Session, Chromium and WebRTC peer lifecycles. */
|
|
12
87
|
export class BrowserRuntimeService {
|
|
13
|
-
constructor({
|
|
88
|
+
constructor({
|
|
89
|
+
yeaftDir,
|
|
90
|
+
config,
|
|
91
|
+
probe = probeBrowserRuntime,
|
|
92
|
+
bridge = new BrowserExtensionBridge(),
|
|
93
|
+
launchSession = launchBrowserSession,
|
|
94
|
+
send = null,
|
|
95
|
+
platform = process.platform,
|
|
96
|
+
} = {}) {
|
|
14
97
|
if (!yeaftDir) throw new Error('yeaftDir required');
|
|
15
98
|
this.yeaftDir = yeaftDir;
|
|
16
99
|
this.config = normaliseBrowserRuntimeSection(config);
|
|
17
100
|
this.config.cacheDir ||= defaultBrowserCacheDir(yeaftDir);
|
|
18
101
|
this.probe = probe;
|
|
102
|
+
this.bridge = bridge;
|
|
103
|
+
this.launchSession = launchSession;
|
|
104
|
+
this.send = typeof send === 'function' ? send : () => 'dropped';
|
|
105
|
+
this.platform = platform;
|
|
19
106
|
this.sessions = new Map();
|
|
107
|
+
this.requests = new Map();
|
|
20
108
|
this.probeResult = null;
|
|
21
109
|
this.state = this.config.enabled ? 'unprobed' : 'disabled';
|
|
22
110
|
this.#probePromise = null;
|
|
@@ -32,9 +120,8 @@ export class BrowserRuntimeService {
|
|
|
32
120
|
get ready() { return this.state === 'ready' && this.probeResult?.ok === true; }
|
|
33
121
|
|
|
34
122
|
capabilities() {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return [];
|
|
123
|
+
if (!this.ready || this.platform !== 'linux' || this.probeResult?.captureMode !== 'tab') return [];
|
|
124
|
+
return ['browser_runtime', 'browser_webrtc', 'browser_capture_tab'];
|
|
38
125
|
}
|
|
39
126
|
|
|
40
127
|
async startupProbe() {
|
|
@@ -65,29 +152,458 @@ export class BrowserRuntimeService {
|
|
|
65
152
|
return this.#probePromise;
|
|
66
153
|
}
|
|
67
154
|
|
|
155
|
+
#emit(message) {
|
|
156
|
+
return this.send(message);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#pruneRequests(now = Date.now()) {
|
|
160
|
+
for (const [key, request] of this.requests) {
|
|
161
|
+
if (request.expiresAt <= now) this.requests.delete(key);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#requestKey(identity, requestId) {
|
|
166
|
+
return `${identity.ownerUserId}\0${identity.webConnectionId}\0${clean(requestId)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#snapshot(session, extra = {}) {
|
|
170
|
+
return {
|
|
171
|
+
browserSessionId: session.browserSessionId,
|
|
172
|
+
revision: session.revision,
|
|
173
|
+
state: session.state,
|
|
174
|
+
activeUrl: session.activeUrl,
|
|
175
|
+
title: session.title,
|
|
176
|
+
pageRevision: session.pageRevision,
|
|
177
|
+
captureMode: session.captureMode,
|
|
178
|
+
viewport: session.viewport,
|
|
179
|
+
viewerCount: session.peers.size,
|
|
180
|
+
interactivePeerCount: 0,
|
|
181
|
+
authorizedProducerCount: 0,
|
|
182
|
+
expiresAt: session.expiresAt,
|
|
183
|
+
terminalReason: session.terminalReason || null,
|
|
184
|
+
safeError: session.safeError || null,
|
|
185
|
+
sourceRef: session.sourceRef,
|
|
186
|
+
...extra,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#emitSnapshot(session, extra = {}) {
|
|
191
|
+
return this.#emit({ type: 'browser_session_snapshot', ...this.#snapshot(session, extra) });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#dropPeer(session, peer, reason = 'peer_closed') {
|
|
195
|
+
if (!peer || session.peers.get(peer.peerId) !== peer) return false;
|
|
196
|
+
clearTimeout(peer.expiryTimer);
|
|
197
|
+
peer.expiryTimer = null;
|
|
198
|
+
session.peers.delete(peer.peerId);
|
|
199
|
+
this.bridge.send(session.browserSessionId, {
|
|
200
|
+
type: 'peer_close',
|
|
201
|
+
peerId: peer.peerId,
|
|
202
|
+
connectionGeneration: peer.connectionGeneration,
|
|
203
|
+
reason,
|
|
204
|
+
});
|
|
205
|
+
session.revision += 1;
|
|
206
|
+
this.#scheduleNoViewerCleanup(session);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#scheduleNoViewerCleanup(session) {
|
|
211
|
+
clearTimeout(session.noViewerTimer);
|
|
212
|
+
session.noViewerTimer = null;
|
|
213
|
+
if (session.state !== 'ready' || session.peers.size > 0) return;
|
|
214
|
+
const delay = Math.max(10_000, Number(this.config.noViewerIdleMs) || 120_000);
|
|
215
|
+
session.expiresAt = Date.now() + delay;
|
|
216
|
+
session.noViewerTimer = setTimeout(() => {
|
|
217
|
+
if (this.sessions.get(session.browserSessionId) === session && session.peers.size === 0) {
|
|
218
|
+
void this.closeSessionRecord(session, 'no_viewer_timeout');
|
|
219
|
+
}
|
|
220
|
+
}, delay);
|
|
221
|
+
session.noViewerTimer.unref?.();
|
|
222
|
+
}
|
|
223
|
+
|
|
68
224
|
assertCanCreateSession() {
|
|
69
225
|
if (!this.ready) throw new BrowserRuntimeError('browser_runtime_unavailable');
|
|
70
|
-
if (this.sessions.size >= this.config.maxSessions)
|
|
71
|
-
throw new BrowserRuntimeError('browser_session_limit');
|
|
72
|
-
}
|
|
226
|
+
if (this.sessions.size >= this.config.maxSessions) throw new BrowserRuntimeError('browser_session_limit');
|
|
73
227
|
}
|
|
74
228
|
|
|
75
|
-
|
|
76
|
-
|
|
229
|
+
async createSession(message) {
|
|
230
|
+
const identity = normalizeIdentity(message?.serverIdentity);
|
|
231
|
+
const requestId = clean(message?.requestId);
|
|
232
|
+
if (!requestId) throw new BrowserRuntimeError('browser_request_id_required');
|
|
233
|
+
const options = {
|
|
234
|
+
initialUrl: safeInitialUrl(message?.options?.initialUrl),
|
|
235
|
+
viewport: message?.options?.viewport || null,
|
|
236
|
+
locale: clean(message?.options?.locale, 32) || 'en-US',
|
|
237
|
+
capturePreference: clean(message?.options?.capturePreference, 16) || 'auto',
|
|
238
|
+
};
|
|
239
|
+
if (!['auto', 'tab'].includes(options.capturePreference)) {
|
|
240
|
+
throw new BrowserRuntimeError('browser_capture_mode_unsupported');
|
|
241
|
+
}
|
|
242
|
+
this.#pruneRequests();
|
|
243
|
+
const requestKey = this.#requestKey(identity, requestId);
|
|
244
|
+
const digest = stableDigest({ options, sourceRef: sourceRef(message?.sourceRef) });
|
|
245
|
+
const existing = this.requests.get(requestKey);
|
|
246
|
+
if (existing) {
|
|
247
|
+
if (existing.digest !== digest) throw new BrowserRuntimeError('browser_request_conflict');
|
|
248
|
+
return existing.promise;
|
|
249
|
+
}
|
|
77
250
|
this.assertCanCreateSession();
|
|
78
|
-
|
|
251
|
+
|
|
79
252
|
const browserSessionId = randomUUID();
|
|
80
|
-
|
|
253
|
+
const session = {
|
|
81
254
|
browserSessionId,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
255
|
+
owner: identity,
|
|
256
|
+
sourceRef: sourceRef(message?.sourceRef),
|
|
257
|
+
revision: 1,
|
|
258
|
+
pageRevision: 1,
|
|
259
|
+
state: 'starting',
|
|
260
|
+
activeUrl: options.initialUrl,
|
|
261
|
+
title: '',
|
|
262
|
+
captureMode: 'tab',
|
|
263
|
+
viewport: null,
|
|
264
|
+
peers: new Map(),
|
|
265
|
+
runtime: null,
|
|
266
|
+
bridgeRegistration: null,
|
|
267
|
+
noViewerTimer: null,
|
|
268
|
+
expiresAt: null,
|
|
269
|
+
terminalReason: null,
|
|
270
|
+
safeError: null,
|
|
271
|
+
closingPromise: null,
|
|
272
|
+
startupAbort: new AbortController(),
|
|
273
|
+
};
|
|
274
|
+
this.sessions.set(browserSessionId, session);
|
|
275
|
+
const promise = this.#startSession(session, options, requestId);
|
|
276
|
+
this.requests.set(requestKey, { digest, promise, expiresAt: Date.now() + REQUEST_TTL_MS });
|
|
277
|
+
return promise;
|
|
87
278
|
}
|
|
88
279
|
|
|
89
|
-
|
|
90
|
-
|
|
280
|
+
async #startSession(session, options, requestId) {
|
|
281
|
+
try {
|
|
282
|
+
session.bridgeRegistration = await this.bridge.registerSession(session.browserSessionId, {
|
|
283
|
+
onMessage: message => this.#handleBridgeMessage(session, message),
|
|
284
|
+
onDisconnect: () => {
|
|
285
|
+
if (session.state === 'ready') void this.closeSessionRecord(session, 'extension_disconnected');
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
session.runtime = await this.launchSession({
|
|
289
|
+
browserSessionId: session.browserSessionId,
|
|
290
|
+
bridgeUrl: session.bridgeRegistration.bridgeUrl,
|
|
291
|
+
config: this.config,
|
|
292
|
+
initialUrl: options.initialUrl,
|
|
293
|
+
viewport: options.viewport,
|
|
294
|
+
locale: options.locale,
|
|
295
|
+
signal: session.startupAbort.signal,
|
|
296
|
+
});
|
|
297
|
+
if (this.sessions.get(session.browserSessionId) !== session || session.state !== 'starting') {
|
|
298
|
+
await session.runtime.close?.().catch(() => {});
|
|
299
|
+
session.runtime = null;
|
|
300
|
+
throw new BrowserRuntimeError('browser_session_cancelled');
|
|
301
|
+
}
|
|
302
|
+
await session.bridgeRegistration.waitUntilReady(this.config.startupProbeTimeoutMs);
|
|
303
|
+
if (this.sessions.get(session.browserSessionId) !== session || session.state !== 'starting') {
|
|
304
|
+
throw new BrowserRuntimeError('browser_session_cancelled');
|
|
305
|
+
}
|
|
306
|
+
session.viewport = session.runtime.viewport;
|
|
307
|
+
session.captureMode = session.runtime.captureMode;
|
|
308
|
+
session.activeUrl = session.runtime.page.url();
|
|
309
|
+
session.title = await session.runtime.page.title().catch(() => '');
|
|
310
|
+
session.state = 'ready';
|
|
311
|
+
session.revision += 1;
|
|
312
|
+
session.runtime.page.on('framenavigated', frame => {
|
|
313
|
+
if (frame !== session.runtime?.page?.mainFrame?.()) return;
|
|
314
|
+
session.pageRevision += 1;
|
|
315
|
+
session.revision += 1;
|
|
316
|
+
session.activeUrl = frame.url();
|
|
317
|
+
void session.runtime.page.title().then(title => { session.title = title; }).catch(() => {});
|
|
318
|
+
this.#emitSnapshot(session);
|
|
319
|
+
});
|
|
320
|
+
session.runtime.page.on('close', () => {
|
|
321
|
+
if (session.state === 'ready') void this.closeSessionRecord(session, 'page_closed');
|
|
322
|
+
});
|
|
323
|
+
this.#scheduleNoViewerCleanup(session);
|
|
324
|
+
const created = {
|
|
325
|
+
type: 'browser_session_created',
|
|
326
|
+
requestId,
|
|
327
|
+
...this.#snapshot(session),
|
|
328
|
+
};
|
|
329
|
+
await this.#emit(created);
|
|
330
|
+
return created;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
session.state = 'failed';
|
|
333
|
+
session.revision += 1;
|
|
334
|
+
session.terminalReason = error?.code || 'browser_session_start_failed';
|
|
335
|
+
session.safeError = String(error?.message || error).slice(0, 500);
|
|
336
|
+
await this.#emit({
|
|
337
|
+
type: 'browser_session_error',
|
|
338
|
+
requestId,
|
|
339
|
+
browserSessionId: session.browserSessionId,
|
|
340
|
+
code: session.terminalReason,
|
|
341
|
+
safeError: session.safeError,
|
|
342
|
+
});
|
|
343
|
+
await this.closeSessionRecord(session, session.terminalReason, { emit: false });
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#sessionFor(message, { exactConnection = false } = {}) {
|
|
349
|
+
const session = this.sessions.get(clean(message?.browserSessionId));
|
|
350
|
+
if (!session) throw new BrowserRuntimeError('browser_session_not_found');
|
|
351
|
+
const identity = normalizeIdentity(message?.serverIdentity);
|
|
352
|
+
if (!sameOwner(session.owner, identity)) throw new BrowserRuntimeError('browser_owner_mismatch');
|
|
353
|
+
if (exactConnection && !sameConnection(session.owner, identity)) {
|
|
354
|
+
// Peer messages are checked against the peer's connection below. Session
|
|
355
|
+
// creation ownership alone must not grant a sibling browser tab control.
|
|
356
|
+
}
|
|
357
|
+
return { session, identity };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async preparePeer(message) {
|
|
361
|
+
const { session, identity } = this.#sessionFor(message);
|
|
362
|
+
if (session.state !== 'ready') throw new BrowserRuntimeError('browser_session_unavailable');
|
|
363
|
+
const peerId = clean(message?.peerId);
|
|
364
|
+
const connectionGeneration = Number(message?.connectionGeneration);
|
|
365
|
+
if (!peerId || !positiveInteger(connectionGeneration)) throw new BrowserRuntimeError('browser_peer_invalid');
|
|
366
|
+
const existing = session.peers.get(peerId);
|
|
367
|
+
if (existing) {
|
|
368
|
+
if (existing.connectionGeneration !== connectionGeneration || !sameConnection(existing.identity, identity)) {
|
|
369
|
+
throw new BrowserRuntimeError('browser_peer_conflict');
|
|
370
|
+
}
|
|
371
|
+
if (existing.state === 'prepared' || existing.state === 'offered' || existing.state === 'connected') {
|
|
372
|
+
return this.#emit({
|
|
373
|
+
type: 'browser_peer_prepared',
|
|
374
|
+
browserSessionId: session.browserSessionId,
|
|
375
|
+
peerId,
|
|
376
|
+
connectionGeneration,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
if (session.peers.size >= this.config.maxPeersPerSession) throw new BrowserRuntimeError('browser_peer_limit');
|
|
382
|
+
const peer = {
|
|
383
|
+
peerId,
|
|
384
|
+
connectionGeneration,
|
|
385
|
+
identity,
|
|
386
|
+
state: 'preparing',
|
|
387
|
+
webCandidateCount: 0,
|
|
388
|
+
agentCandidateCount: 0,
|
|
389
|
+
expiresAt: Number(message?.routeExpiresAt) || Date.now() + 10 * 60_000,
|
|
390
|
+
expiryTimer: null,
|
|
391
|
+
};
|
|
392
|
+
session.peers.set(peerId, peer);
|
|
393
|
+
peer.expiryTimer = setTimeout(() => {
|
|
394
|
+
if (this.#dropPeer(session, peer, 'peer_route_expired')) this.#emitSnapshot(session);
|
|
395
|
+
}, Math.max(1, peer.expiresAt - Date.now()));
|
|
396
|
+
peer.expiryTimer.unref?.();
|
|
397
|
+
clearTimeout(session.noViewerTimer);
|
|
398
|
+
session.noViewerTimer = null;
|
|
399
|
+
session.expiresAt = null;
|
|
400
|
+
const sent = this.bridge.send(session.browserSessionId, {
|
|
401
|
+
type: 'peer_prepare',
|
|
402
|
+
peerId,
|
|
403
|
+
connectionGeneration,
|
|
404
|
+
iceServers: publicIceServers(message?.agentIceServers),
|
|
405
|
+
iceTransportPolicy: message?.iceTransportPolicy === 'relay' ? 'relay' : 'all',
|
|
406
|
+
maxBitrate: this.config.maxBitrate,
|
|
407
|
+
maxFps: this.config.maxFps,
|
|
408
|
+
});
|
|
409
|
+
if (!sent) {
|
|
410
|
+
clearTimeout(peer.expiryTimer);
|
|
411
|
+
session.peers.delete(peerId);
|
|
412
|
+
this.#scheduleNoViewerCleanup(session);
|
|
413
|
+
throw new BrowserRuntimeError('browser_extension_unavailable');
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#peerFor(message) {
|
|
419
|
+
const { session, identity } = this.#sessionFor(message);
|
|
420
|
+
const peer = session.peers.get(clean(message?.peerId));
|
|
421
|
+
if (!peer || peer.connectionGeneration !== Number(message?.connectionGeneration)) {
|
|
422
|
+
throw new BrowserRuntimeError('browser_peer_stale');
|
|
423
|
+
}
|
|
424
|
+
if (!sameConnection(peer.identity, identity)) throw new BrowserRuntimeError('browser_peer_owner_mismatch');
|
|
425
|
+
return { session, peer };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
answerPeer(message) {
|
|
429
|
+
const { session, peer } = this.#peerFor(message);
|
|
430
|
+
const description = message?.description;
|
|
431
|
+
if (description?.type !== 'answer' || typeof description.sdp !== 'string' || description.sdp.length > 96 * 1024) {
|
|
432
|
+
throw new BrowserRuntimeError('browser_sdp_invalid');
|
|
433
|
+
}
|
|
434
|
+
if (!this.bridge.send(session.browserSessionId, {
|
|
435
|
+
type: 'peer_answer',
|
|
436
|
+
peerId: peer.peerId,
|
|
437
|
+
connectionGeneration: peer.connectionGeneration,
|
|
438
|
+
description: { type: 'answer', sdp: description.sdp },
|
|
439
|
+
})) throw new BrowserRuntimeError('browser_extension_unavailable');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
addPeerIceCandidate(message) {
|
|
443
|
+
const { session, peer } = this.#peerFor(message);
|
|
444
|
+
if (peer.webCandidateCount >= 128) throw new BrowserRuntimeError('browser_candidate_limit');
|
|
445
|
+
const candidate = message?.candidate;
|
|
446
|
+
if (candidate != null && (typeof candidate !== 'object'
|
|
447
|
+
|| typeof candidate.candidate !== 'string'
|
|
448
|
+
|| candidate.candidate.length > 4096)) {
|
|
449
|
+
throw new BrowserRuntimeError('browser_candidate_invalid');
|
|
450
|
+
}
|
|
451
|
+
peer.webCandidateCount += 1;
|
|
452
|
+
if (!this.bridge.send(session.browserSessionId, {
|
|
453
|
+
type: 'peer_ice_candidate',
|
|
454
|
+
peerId: peer.peerId,
|
|
455
|
+
connectionGeneration: peer.connectionGeneration,
|
|
456
|
+
candidate: candidate == null ? null : {
|
|
457
|
+
candidate: candidate.candidate,
|
|
458
|
+
sdpMid: clean(candidate.sdpMid, 256) || null,
|
|
459
|
+
sdpMLineIndex: Number.isInteger(candidate.sdpMLineIndex) ? candidate.sdpMLineIndex : null,
|
|
460
|
+
usernameFragment: clean(candidate.usernameFragment, 256) || null,
|
|
461
|
+
},
|
|
462
|
+
})) throw new BrowserRuntimeError('browser_extension_unavailable');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
detachPeer(message, reason = 'peer_detached') {
|
|
466
|
+
const { session, peer } = this.#peerFor(message);
|
|
467
|
+
const dropped = this.#dropPeer(session, peer, reason);
|
|
468
|
+
if (dropped) this.#emitSnapshot(session);
|
|
469
|
+
return dropped;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async closeSession(message) {
|
|
473
|
+
const { session } = this.#sessionFor(message);
|
|
474
|
+
if (message?.expectedRevision != null && Number(message.expectedRevision) !== session.revision) {
|
|
475
|
+
throw new BrowserRuntimeError('browser_revision_conflict');
|
|
476
|
+
}
|
|
477
|
+
await this.closeSessionRecord(session, 'user_closed', { emit: false });
|
|
478
|
+
return this.#emit({
|
|
479
|
+
type: 'browser_session_snapshot',
|
|
480
|
+
requestId: clean(message?.requestId) || null,
|
|
481
|
+
...this.#snapshot(session),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async closeSessionRecord(session, reason = 'closed', { emit = true } = {}) {
|
|
486
|
+
if (!session || this.sessions.get(session.browserSessionId) !== session) return false;
|
|
487
|
+
if (session.closingPromise) return session.closingPromise;
|
|
488
|
+
session.closingPromise = (async () => {
|
|
489
|
+
session.state = 'closing';
|
|
490
|
+
session.revision += 1;
|
|
491
|
+
session.startupAbort?.abort(new BrowserRuntimeError('browser_session_cancelled'));
|
|
492
|
+
session.terminalReason = reason;
|
|
493
|
+
clearTimeout(session.noViewerTimer);
|
|
494
|
+
session.noViewerTimer = null;
|
|
495
|
+
for (const peer of session.peers.values()) clearTimeout(peer.expiryTimer);
|
|
496
|
+
session.peers.clear();
|
|
497
|
+
this.bridge.send(session.browserSessionId, { type: 'session_close', reason });
|
|
498
|
+
this.bridge.unregisterSession(session.browserSessionId, reason);
|
|
499
|
+
try { await session.runtime?.close?.(); } catch {}
|
|
500
|
+
session.runtime = null;
|
|
501
|
+
if (this.sessions.get(session.browserSessionId) === session) {
|
|
502
|
+
this.sessions.delete(session.browserSessionId);
|
|
503
|
+
}
|
|
504
|
+
session.state = 'closed';
|
|
505
|
+
session.revision += 1;
|
|
506
|
+
session.expiresAt = null;
|
|
507
|
+
if (emit) await this.#emitSnapshot(session);
|
|
508
|
+
return true;
|
|
509
|
+
})();
|
|
510
|
+
return session.closingPromise;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
getSession(message) {
|
|
514
|
+
const { session } = this.#sessionFor(message);
|
|
515
|
+
return this.#emit({
|
|
516
|
+
type: 'browser_session_snapshot',
|
|
517
|
+
requestId: clean(message?.requestId) || null,
|
|
518
|
+
...this.#snapshot(session),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
listSessions(message) {
|
|
523
|
+
const identity = normalizeIdentity(message?.serverIdentity);
|
|
524
|
+
const sessions = [...this.sessions.values()]
|
|
525
|
+
.filter(session => sameOwner(session.owner, identity))
|
|
526
|
+
.map(session => this.#snapshot(session));
|
|
527
|
+
return this.#emit({
|
|
528
|
+
type: 'browser_session_list_result',
|
|
529
|
+
requestId: clean(message?.requestId) || null,
|
|
530
|
+
sessions,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
#handleBridgeMessage(session, message) {
|
|
535
|
+
if (this.sessions.get(session.browserSessionId) !== session) return;
|
|
536
|
+
if (message.type === 'peer_prepared' || message.type === 'peer_offer'
|
|
537
|
+
|| message.type === 'peer_ice_candidate' || message.type === 'peer_state'
|
|
538
|
+
|| message.type === 'peer_error') {
|
|
539
|
+
const peer = session.peers.get(clean(message.peerId));
|
|
540
|
+
if (!peer || peer.connectionGeneration !== Number(message.connectionGeneration)) return;
|
|
541
|
+
if (message.type === 'peer_prepared') {
|
|
542
|
+
peer.state = 'prepared';
|
|
543
|
+
void this.#emit({
|
|
544
|
+
type: 'browser_peer_prepared',
|
|
545
|
+
browserSessionId: session.browserSessionId,
|
|
546
|
+
peerId: peer.peerId,
|
|
547
|
+
connectionGeneration: peer.connectionGeneration,
|
|
548
|
+
});
|
|
549
|
+
} else if (message.type === 'peer_offer') {
|
|
550
|
+
const description = message.description;
|
|
551
|
+
if (description?.type !== 'offer' || typeof description.sdp !== 'string' || description.sdp.length > 96 * 1024) {
|
|
552
|
+
void this.closeSessionRecord(session, 'invalid_extension_offer');
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
peer.state = 'offered';
|
|
556
|
+
void this.#emit({
|
|
557
|
+
type: 'browser_peer_offer',
|
|
558
|
+
browserSessionId: session.browserSessionId,
|
|
559
|
+
peerId: peer.peerId,
|
|
560
|
+
connectionGeneration: peer.connectionGeneration,
|
|
561
|
+
description: { type: 'offer', sdp: description.sdp },
|
|
562
|
+
});
|
|
563
|
+
} else if (message.type === 'peer_ice_candidate') {
|
|
564
|
+
if (peer.agentCandidateCount >= 128) return;
|
|
565
|
+
peer.agentCandidateCount += 1;
|
|
566
|
+
void this.#emit({
|
|
567
|
+
type: 'browser_peer_ice_candidate',
|
|
568
|
+
browserSessionId: session.browserSessionId,
|
|
569
|
+
peerId: peer.peerId,
|
|
570
|
+
connectionGeneration: peer.connectionGeneration,
|
|
571
|
+
candidate: message.candidate || null,
|
|
572
|
+
});
|
|
573
|
+
} else if (message.type === 'peer_error') {
|
|
574
|
+
this.#dropPeer(session, peer, 'peer_failed');
|
|
575
|
+
void this.#emit({
|
|
576
|
+
type: 'browser_peer_error',
|
|
577
|
+
browserSessionId: session.browserSessionId,
|
|
578
|
+
peerId: peer.peerId,
|
|
579
|
+
connectionGeneration: peer.connectionGeneration,
|
|
580
|
+
code: clean(message.code, 128) || 'peer_failed',
|
|
581
|
+
safeError: clean(message.safeError, 500) || 'Browser peer failed',
|
|
582
|
+
});
|
|
583
|
+
void this.#emitSnapshot(session);
|
|
584
|
+
} else {
|
|
585
|
+
const nextState = clean(message.state, 32) || peer.state;
|
|
586
|
+
if (nextState === 'connected') {
|
|
587
|
+
clearTimeout(peer.expiryTimer);
|
|
588
|
+
peer.expiryTimer = null;
|
|
589
|
+
peer.expiresAt = null;
|
|
590
|
+
peer.state = nextState;
|
|
591
|
+
} else if (['failed', 'disconnected', 'closed'].includes(nextState)) {
|
|
592
|
+
this.#dropPeer(session, peer, `peer_${nextState}`);
|
|
593
|
+
} else {
|
|
594
|
+
peer.state = nextState;
|
|
595
|
+
}
|
|
596
|
+
void this.#emit({
|
|
597
|
+
type: 'browser_peer_state',
|
|
598
|
+
browserSessionId: session.browserSessionId,
|
|
599
|
+
peerId: peer.peerId,
|
|
600
|
+
connectionGeneration: peer.connectionGeneration,
|
|
601
|
+
state: nextState,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (message.type === 'capture_ended') void this.closeSessionRecord(session, 'capture_ended');
|
|
91
607
|
}
|
|
92
608
|
|
|
93
609
|
snapshot() {
|
|
@@ -101,11 +617,18 @@ export class BrowserRuntimeService {
|
|
|
101
617
|
});
|
|
102
618
|
}
|
|
103
619
|
|
|
620
|
+
async handleTransportDisconnect() {
|
|
621
|
+
await Promise.allSettled([...this.sessions.values()]
|
|
622
|
+
.map(session => this.closeSessionRecord(session, 'agent_transport_disconnected', { emit: false })));
|
|
623
|
+
}
|
|
624
|
+
|
|
104
625
|
async shutdown() {
|
|
105
626
|
if (this.#shutdownPromise) return this.#shutdownPromise;
|
|
106
627
|
this.#probeAbort?.abort(new BrowserRuntimeError('browser_runtime_shutdown'));
|
|
107
|
-
this.#shutdownPromise = Promise.resolve(this.#probePromise).catch(() => {}).then(() => {
|
|
108
|
-
this.sessions.
|
|
628
|
+
this.#shutdownPromise = Promise.resolve(this.#probePromise).catch(() => {}).then(async () => {
|
|
629
|
+
await Promise.allSettled([...this.sessions.values()]
|
|
630
|
+
.map(session => this.closeSessionRecord(session, 'browser_runtime_shutdown', { emit: false })));
|
|
631
|
+
await this.bridge.close();
|
|
109
632
|
this.state = 'closed';
|
|
110
633
|
});
|
|
111
634
|
return this.#shutdownPromise;
|
package/connection/index.js
CHANGED
|
@@ -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
|
@@ -164,6 +164,7 @@ async function detectCapabilities() {
|
|
|
164
164
|
if (process.platform === 'linux') capabilities.push('work_item_attachments');
|
|
165
165
|
const pty = await loadNodePty();
|
|
166
166
|
if (pty) capabilities.push('terminal');
|
|
167
|
+
if (ctx.browserRuntime) capabilities.push(...ctx.browserRuntime.capabilities());
|
|
167
168
|
|
|
168
169
|
console.log(`[Capabilities] Detected: ${capabilities.join(', ')}`);
|
|
169
170
|
return capabilities;
|
|
@@ -403,6 +404,7 @@ process.on('SIGTERM', async () => {
|
|
|
403
404
|
ctx.browserRuntime = await bootBrowserRuntime({
|
|
404
405
|
yeaftDir: YEAFT_DIR,
|
|
405
406
|
config: runtimeConfig,
|
|
407
|
+
send: message => ctx.sendToServer?.(message) || 'dropped',
|
|
406
408
|
});
|
|
407
409
|
const probe = ctx.browserRuntime.snapshot().probe;
|
|
408
410
|
if (probe?.ok) {
|