@yeaft/webchat-agent 1.0.417 → 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.
@@ -31,25 +31,64 @@ export const BROWSER_RUNTIME_CHROME_ARCHIVES = Object.freeze({
31
31
  linux: Object.freeze({
32
32
  fileName: 'chrome-linux64.zip',
33
33
  sha256: '6bd04aab53fba1544ce6027d9daddb24137295033124a61ecdf9840d785792e9',
34
+ downloadBytes: 193_285_407,
34
35
  }),
35
36
  mac: Object.freeze({
36
37
  fileName: 'chrome-mac-x64.zip',
37
38
  sha256: 'bedcd79ae533fed218c26232b74e73cffec2a7277fce42cafcf5ec7280e4f81c',
39
+ downloadBytes: 197_094_599,
38
40
  }),
39
41
  mac_arm: Object.freeze({
40
42
  fileName: 'chrome-mac-arm64.zip',
41
43
  sha256: '1c516b5d6c00a074034d5ce03dc1cc9bd2cde2a09293d9613244e0bc153cb80f',
44
+ downloadBytes: 187_099_652,
42
45
  }),
43
46
  win32: Object.freeze({
44
47
  fileName: 'chrome-win32.zip',
45
48
  sha256: '338f15dcf19d457f93f692c279843477a92324f0f91f78bf5380d3fe00a9796f',
49
+ downloadBytes: 175_083_534,
46
50
  }),
47
51
  win64: Object.freeze({
48
52
  fileName: 'chrome-win64.zip',
49
53
  sha256: '7ea2e94833ef710026c8cb08d0d2dafcb13f5d304d9c475ac07a3fa8c11d846c',
54
+ downloadBytes: 201_082_763,
50
55
  }),
51
56
  });
52
57
 
58
+ function browserPlatformForRuntime(platform, arch) {
59
+ if (platform === 'linux' && arch === 'x64') return 'linux';
60
+ if (platform === 'darwin' && arch === 'x64') return 'mac';
61
+ if (platform === 'darwin' && arch === 'arm64') return 'mac_arm';
62
+ if (platform === 'win32' && arch === 'ia32') return 'win32';
63
+ if (platform === 'win32' && arch === 'x64') return 'win64';
64
+ return null;
65
+ }
66
+
67
+ /** Static, side-effect-free metadata shown before an explicit managed-browser install. */
68
+ export function managedBrowserDownloadInfo({
69
+ platform = process.platform,
70
+ arch = process.arch,
71
+ archives = BROWSER_RUNTIME_CHROME_ARCHIVES,
72
+ } = {}) {
73
+ const browserPlatform = browserPlatformForRuntime(platform, arch);
74
+ const archive = browserPlatform ? archives[browserPlatform] : null;
75
+ if (!archive) {
76
+ return Object.freeze({
77
+ supported: false,
78
+ buildId: BROWSER_RUNTIME_CHROME_BUILD,
79
+ platform: browserPlatform,
80
+ downloadBytes: 0,
81
+ });
82
+ }
83
+ return Object.freeze({
84
+ supported: true,
85
+ buildId: BROWSER_RUNTIME_CHROME_BUILD,
86
+ platform: browserPlatform,
87
+ fileName: archive.fileName,
88
+ downloadBytes: Number(archive.downloadBytes) || 0,
89
+ });
90
+ }
91
+
53
92
  export function defaultBrowserCacheDir(yeaftDir) {
54
93
  if (!yeaftDir) throw new Error('yeaftDir required');
55
94
  return join(yeaftDir, 'managed-browser');
@@ -1,6 +1,10 @@
1
1
  import ctx from '../context.js';
2
+ import { BrowserRuntimeError } from './errors.js';
2
3
 
3
4
  const BROWSER_MESSAGE_TYPES = new Set([
5
+ 'browser_runtime_status',
6
+ 'browser_runtime_install',
7
+ 'browser_runtime_enable',
4
8
  'browser_session_create',
5
9
  'browser_session_get',
6
10
  'browser_session_list',
@@ -12,9 +16,11 @@ const BROWSER_MESSAGE_TYPES = new Set([
12
16
  ]);
13
17
 
14
18
  function errorMessage(msg, error) {
15
- const peerScoped = String(msg?.type || '').startsWith('browser_peer_');
19
+ const type = String(msg?.type || '');
20
+ const peerScoped = type.startsWith('browser_peer_');
21
+ const setupScoped = type.startsWith('browser_runtime_');
16
22
  return {
17
- type: peerScoped ? 'browser_peer_error' : 'browser_session_error',
23
+ type: peerScoped ? 'browser_peer_error' : setupScoped ? 'browser_runtime_error' : 'browser_session_error',
18
24
  requestId: msg?.requestId || null,
19
25
  browserSessionId: msg?.browserSessionId || null,
20
26
  ...(peerScoped ? {
@@ -26,6 +32,15 @@ function errorMessage(msg, error) {
26
32
  };
27
33
  }
28
34
 
35
+ function assertSetupIdentity(message) {
36
+ const identity = message?.serverIdentity;
37
+ if (!identity || typeof identity !== 'object'
38
+ || !identity.ownerUserId || !identity.clientId
39
+ || !identity.webConnectionId || !identity.webConnectionGeneration) {
40
+ throw new BrowserRuntimeError('browser_identity_required');
41
+ }
42
+ }
43
+
29
44
  /** Route one authenticated Server command into the Agent-local Browser Runtime. */
30
45
  export async function handleBrowserRuntimeMessage(msg, dependencies = {}) {
31
46
  if (!BROWSER_MESSAGE_TYPES.has(msg?.type)) return false;
@@ -37,6 +52,41 @@ export async function handleBrowserRuntimeMessage(msg, dependencies = {}) {
37
52
  }
38
53
  try {
39
54
  switch (msg.type) {
55
+ case 'browser_runtime_status':
56
+ assertSetupIdentity(msg);
57
+ await send?.({
58
+ type: 'browser_runtime_status_result',
59
+ requestId: msg.requestId || null,
60
+ ...(await runtime.setupStatus()),
61
+ });
62
+ break;
63
+ case 'browser_runtime_install': {
64
+ assertSetupIdentity(msg);
65
+ const progress = async value => send?.({
66
+ type: 'browser_runtime_install_progress',
67
+ requestId: msg.requestId || null,
68
+ ...value,
69
+ });
70
+ const result = await runtime.installAndEnable({
71
+ confirmedBuildId: msg.confirmedBuildId,
72
+ confirmedDownloadBytes: msg.confirmedDownloadBytes,
73
+ onProgress: progress,
74
+ });
75
+ await send?.({
76
+ type: 'browser_runtime_status_result',
77
+ requestId: msg.requestId || null,
78
+ ...result,
79
+ });
80
+ break;
81
+ }
82
+ case 'browser_runtime_enable':
83
+ assertSetupIdentity(msg);
84
+ await send?.({
85
+ type: 'browser_runtime_status_result',
86
+ requestId: msg.requestId || null,
87
+ ...(await runtime.enableAndProbe()),
88
+ });
89
+ break;
40
90
  case 'browser_session_create':
41
91
  await runtime.createSession(msg);
42
92
  break;
@@ -1,12 +1,18 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { normaliseBrowserRuntimeSection } from './config.js';
3
- import { defaultBrowserCacheDir } from './browser-install.js';
3
+ import {
4
+ defaultBrowserCacheDir,
5
+ installManagedBrowser,
6
+ managedBrowserDownloadInfo,
7
+ resolveBrowserExecutable,
8
+ } from './browser-install.js';
4
9
  import { probeBrowserRuntime } from './probe.js';
5
10
  import { BrowserRuntimeError } from './errors.js';
6
11
  import { BrowserExtensionBridge } from './local-bridge.js';
7
12
  import { launchBrowserSession } from './chromium.js';
8
13
 
9
14
  const REQUEST_TTL_MS = 10 * 60_000;
15
+ const MAX_INSTALL_PROGRESS_LISTENERS = 8;
10
16
 
11
17
  function clean(value, max = 512) {
12
18
  return typeof value === 'string' ? value.trim().slice(0, max) : '';
@@ -89,47 +95,156 @@ export class BrowserRuntimeService {
89
95
  yeaftDir,
90
96
  config,
91
97
  probe = probeBrowserRuntime,
98
+ resolveBrowser = resolveBrowserExecutable,
99
+ installBrowser = installManagedBrowser,
100
+ downloadInfo = managedBrowserDownloadInfo,
101
+ saveSettings = null,
102
+ onCapabilitiesChanged = null,
92
103
  bridge = new BrowserExtensionBridge(),
93
104
  launchSession = launchBrowserSession,
94
105
  send = null,
95
106
  platform = process.platform,
107
+ arch = process.arch,
96
108
  } = {}) {
97
109
  if (!yeaftDir) throw new Error('yeaftDir required');
98
110
  this.yeaftDir = yeaftDir;
99
111
  this.config = normaliseBrowserRuntimeSection(config);
100
112
  this.config.cacheDir ||= defaultBrowserCacheDir(yeaftDir);
101
113
  this.probe = probe;
114
+ this.resolveBrowser = resolveBrowser;
115
+ this.installBrowser = installBrowser;
116
+ this.downloadInfo = downloadInfo;
117
+ this.saveSettings = typeof saveSettings === 'function' ? saveSettings : null;
118
+ this.onCapabilitiesChanged = typeof onCapabilitiesChanged === 'function' ? onCapabilitiesChanged : null;
102
119
  this.bridge = bridge;
103
120
  this.launchSession = launchSession;
104
121
  this.send = typeof send === 'function' ? send : () => 'dropped';
105
122
  this.platform = platform;
123
+ this.arch = arch;
124
+ const download = this.downloadInfo({ platform, arch });
125
+ this.setupInfo = Object.freeze({
126
+ ...download,
127
+ // Phase 1 advertises only the real Linux tab-capture data plane. The CLI
128
+ // may manage pinned binaries on more platforms, but Web must not offer an
129
+ // install that can never produce a ready capability.
130
+ supported: download.supported === true && platform === 'linux' && arch === 'x64',
131
+ });
106
132
  this.sessions = new Map();
107
133
  this.requests = new Map();
108
134
  this.probeResult = null;
135
+ this.installProgress = null;
136
+ this.lastSetupError = null;
109
137
  this.state = this.config.enabled ? 'unprobed' : 'disabled';
110
138
  this.#probePromise = null;
111
139
  this.#probeAbort = null;
140
+ this.#installPromise = null;
141
+ this.#installAbort = null;
142
+ this.#installListeners = new Set();
143
+ this.#lastInstallProgressAt = 0;
112
144
  this.#shutdownPromise = null;
113
145
  }
114
146
 
115
147
  #probePromise;
116
148
  #probeAbort;
149
+ #installPromise;
150
+ #installAbort;
151
+ #installListeners;
152
+ #lastInstallProgressAt;
117
153
  #shutdownPromise;
118
154
 
119
155
  get enabled() { return this.config.enabled === true; }
120
156
  get ready() { return this.state === 'ready' && this.probeResult?.ok === true; }
121
157
 
158
+ setupCapabilities() {
159
+ return this.setupInfo.supported ? ['browser_runtime_setup'] : [];
160
+ }
161
+
122
162
  capabilities() {
123
163
  if (!this.ready || this.platform !== 'linux' || this.probeResult?.captureMode !== 'tab') return [];
124
164
  return ['browser_runtime', 'browser_webrtc', 'browser_capture_tab'];
125
165
  }
126
166
 
127
- async startupProbe() {
167
+ async #notifyCapabilitiesChanged() {
168
+ try {
169
+ await this.onCapabilitiesChanged?.();
170
+ } catch (error) {
171
+ console.warn(`[BrowserRuntime] capability refresh failed: ${error?.message || error}`);
172
+ }
173
+ }
174
+
175
+ async #persistEnabled({ managed = false } = {}) {
176
+ const update = {
177
+ enabled: true,
178
+ ...(managed ? { executablePath: null } : {}),
179
+ };
180
+ if (this.saveSettings) {
181
+ const saved = await this.saveSettings(update);
182
+ if (saved?.error) {
183
+ throw new BrowserRuntimeError('browser_config_update_failed', saved.error);
184
+ }
185
+ this.config = {
186
+ ...this.config,
187
+ ...normaliseBrowserRuntimeSection(saved),
188
+ cacheDir: this.config.cacheDir,
189
+ };
190
+ } else {
191
+ this.config = { ...this.config, ...update };
192
+ }
193
+ this.config.enabled = true;
194
+ if (managed) this.config.executablePath = null;
195
+ }
196
+
197
+ async setupStatus() {
198
+ let executablePath = null;
199
+ if (this.setupInfo.supported) {
200
+ try {
201
+ executablePath = await this.resolveBrowser({
202
+ executablePath: this.config.executablePath,
203
+ cacheDir: this.config.cacheDir,
204
+ });
205
+ if (this.lastSetupError?.source === 'status') this.lastSetupError = null;
206
+ } catch (error) {
207
+ if (!this.lastSetupError || this.lastSetupError.source === 'status') {
208
+ this.lastSetupError = {
209
+ source: 'status',
210
+ code: error?.code || 'browser_status_failed',
211
+ safeError: String(error?.message || error).slice(0, 500),
212
+ };
213
+ }
214
+ }
215
+ }
216
+ const installed = !!executablePath;
217
+ if (this.ready) this.lastSetupError = null;
218
+ const state = !this.setupInfo.supported ? 'unsupported'
219
+ : this.ready ? 'ready'
220
+ : this.#installPromise ? 'installing'
221
+ : this.state === 'probing' ? 'probing'
222
+ : this.probeResult && this.config.enabled ? 'unavailable'
223
+ : installed ? 'disabled'
224
+ : 'not_installed';
225
+ return Object.freeze({
226
+ supported: this.setupInfo.supported,
227
+ state,
228
+ installed,
229
+ enabled: this.enabled,
230
+ ready: this.ready,
231
+ buildId: this.setupInfo.buildId,
232
+ platform: this.setupInfo.platform,
233
+ downloadBytes: this.setupInfo.downloadBytes,
234
+ downloadedBytes: Number(this.installProgress?.downloadedBytes) || 0,
235
+ totalBytes: Number(this.installProgress?.totalBytes) || this.setupInfo.downloadBytes,
236
+ probeCode: this.probeResult?.code || null,
237
+ safeError: this.lastSetupError?.safeError || this.probeResult?.safeError || null,
238
+ });
239
+ }
240
+
241
+ async startupProbe({ force = false } = {}) {
128
242
  if (!this.enabled) return { ok: false, code: 'browser_runtime_disabled' };
129
243
  if (this.#probePromise) return this.#probePromise;
244
+ if (!force && this.ready) return this.probeResult;
130
245
  this.state = 'probing';
131
246
  this.#probeAbort = new AbortController();
132
- this.#probePromise = this.probe({
247
+ const probePromise = this.probe({
133
248
  executablePath: this.config.executablePath,
134
249
  cacheDir: this.config.cacheDir,
135
250
  headless: this.config.headless,
@@ -148,8 +263,105 @@ export class BrowserRuntimeService {
148
263
  });
149
264
  this.state = 'unavailable';
150
265
  return this.probeResult;
266
+ }).finally(() => {
267
+ if (this.#probePromise === probePromise) this.#probePromise = null;
268
+ this.#probeAbort = null;
151
269
  });
152
- return this.#probePromise;
270
+ this.#probePromise = probePromise;
271
+ return probePromise;
272
+ }
273
+
274
+ async enableAndProbe() {
275
+ if (!this.setupInfo.supported) throw new BrowserRuntimeError('browser_platform_unsupported');
276
+ this.lastSetupError = null;
277
+ try {
278
+ const executablePath = await this.resolveBrowser({
279
+ executablePath: this.config.executablePath,
280
+ cacheDir: this.config.cacheDir,
281
+ });
282
+ if (!executablePath) throw new BrowserRuntimeError('browser_executable_missing');
283
+ await this.#persistEnabled();
284
+ this.probeResult = null;
285
+ const probe = await this.startupProbe({ force: true });
286
+ await this.#notifyCapabilitiesChanged();
287
+ return { ...(await this.setupStatus()), probeCode: probe.code || null };
288
+ } catch (error) {
289
+ this.lastSetupError = {
290
+ source: 'enable',
291
+ code: error?.code || 'browser_enable_failed',
292
+ safeError: String(error?.message || error).slice(0, 500),
293
+ };
294
+ throw error;
295
+ }
296
+ }
297
+
298
+ async installAndEnable({ confirmedBuildId, confirmedDownloadBytes, onProgress = null } = {}) {
299
+ if (!this.setupInfo.supported) throw new BrowserRuntimeError('browser_platform_unsupported');
300
+ if (confirmedBuildId !== this.setupInfo.buildId
301
+ || Number(confirmedDownloadBytes) !== this.setupInfo.downloadBytes) {
302
+ throw new BrowserRuntimeError('browser_install_confirmation_stale');
303
+ }
304
+ if (typeof onProgress === 'function') {
305
+ if (this.#installListeners.size >= MAX_INSTALL_PROGRESS_LISTENERS) {
306
+ throw new BrowserRuntimeError('browser_install_observer_limit');
307
+ }
308
+ this.#installListeners.add(onProgress);
309
+ }
310
+ if (!this.#installPromise) {
311
+ this.state = 'installing';
312
+ this.lastSetupError = null;
313
+ this.installProgress = Object.freeze({
314
+ downloadedBytes: 0,
315
+ totalBytes: this.setupInfo.downloadBytes,
316
+ });
317
+ this.#installAbort = new AbortController();
318
+ const installPromise = (async () => {
319
+ await this.installBrowser({
320
+ cacheDir: this.config.cacheDir,
321
+ signal: this.#installAbort.signal,
322
+ onProgress: (downloadedBytes, totalBytes) => {
323
+ const total = Number(totalBytes) || this.setupInfo.downloadBytes;
324
+ this.installProgress = Object.freeze({
325
+ downloadedBytes: Number(downloadedBytes) || 0,
326
+ totalBytes: total,
327
+ });
328
+ const now = Date.now();
329
+ if (now - this.#lastInstallProgressAt < 250 && downloadedBytes < total) return;
330
+ this.#lastInstallProgressAt = now;
331
+ for (const listener of this.#installListeners) {
332
+ try {
333
+ Promise.resolve(listener(this.installProgress)).catch(() => {});
334
+ } catch {}
335
+ }
336
+ },
337
+ });
338
+ await this.#persistEnabled({ managed: true });
339
+ this.probeResult = null;
340
+ await this.startupProbe({ force: true });
341
+ await this.#notifyCapabilitiesChanged();
342
+ return this.setupStatus();
343
+ })().catch(error => {
344
+ this.lastSetupError = {
345
+ source: 'install',
346
+ code: error?.code || 'browser_install_failed',
347
+ safeError: String(error?.message || error).slice(0, 500),
348
+ };
349
+ this.state = 'unavailable';
350
+ throw error;
351
+ }).finally(() => {
352
+ if (this.#installPromise === installPromise) {
353
+ this.#installPromise = null;
354
+ this.#installAbort = null;
355
+ }
356
+ this.#installListeners.clear();
357
+ });
358
+ this.#installPromise = installPromise;
359
+ }
360
+ try {
361
+ return await this.#installPromise;
362
+ } finally {
363
+ if (typeof onProgress === 'function') this.#installListeners.delete(onProgress);
364
+ }
153
365
  }
154
366
 
155
367
  #emit(message) {
@@ -625,7 +837,11 @@ export class BrowserRuntimeService {
625
837
  async shutdown() {
626
838
  if (this.#shutdownPromise) return this.#shutdownPromise;
627
839
  this.#probeAbort?.abort(new BrowserRuntimeError('browser_runtime_shutdown'));
628
- this.#shutdownPromise = Promise.resolve(this.#probePromise).catch(() => {}).then(async () => {
840
+ this.#installAbort?.abort(new BrowserRuntimeError('browser_runtime_shutdown'));
841
+ this.#shutdownPromise = Promise.allSettled([
842
+ Promise.resolve(this.#probePromise),
843
+ Promise.resolve(this.#installPromise),
844
+ ]).then(async () => {
629
845
  await Promise.allSettled([...this.sessions.values()]
630
846
  .map(session => this.closeSessionRecord(session, 'browser_runtime_shutdown', { emit: false })));
631
847
  await this.bridge.close();
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,7 +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');
167
- if (ctx.browserRuntime) capabilities.push(...ctx.browserRuntime.capabilities());
168
+ if (ctx.browserRuntime) {
169
+ capabilities.push(...ctx.browserRuntime.setupCapabilities(), ...ctx.browserRuntime.capabilities());
170
+ }
168
171
 
169
172
  console.log(`[Capabilities] Detected: ${capabilities.join(', ')}`);
170
173
  return capabilities;
@@ -404,6 +407,14 @@ process.on('SIGTERM', async () => {
404
407
  ctx.browserRuntime = await bootBrowserRuntime({
405
408
  yeaftDir: YEAFT_DIR,
406
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
+ },
407
418
  send: message => ctx.sendToServer?.(message) || 'dropped',
408
419
  });
409
420
  const probe = ctx.browserRuntime.snapshot().probe;
@@ -6,6 +6,7 @@ const MAX_ROUTES = 2048;
6
6
  const MAX_PEERS = 4096;
7
7
  const MAX_CREATE_REQUESTS = 4096;
8
8
  const CREATE_REQUEST_TTL_MS = 10 * 60_000;
9
+ const INSTALL_REQUEST_TTL_MS = 60 * 60_000;
9
10
 
10
11
  export const browserRoutes = new Map();
11
12
  export const browserPeers = new Map();
@@ -66,7 +67,13 @@ export function pruneBrowserRuntimeRoutes(now = Date.now()) {
66
67
  }
67
68
  }
68
69
 
69
- export function registerBrowserCreateRequest({ agentId, client, requestId, digest }) {
70
+ export function registerBrowserCreateRequest({
71
+ agentId,
72
+ client,
73
+ requestId,
74
+ digest,
75
+ kind = 'browser_session_create',
76
+ }) {
70
77
  pruneBrowserRuntimeRoutes();
71
78
  const requestKey = createKey(agentId, client.connectionId, requestId);
72
79
  const existing = browserRequests.get(requestKey);
@@ -84,17 +91,29 @@ export function registerBrowserCreateRequest({ agentId, client, requestId, diges
84
91
  webConnectionId: client.connectionId,
85
92
  webConnectionGeneration: client.connectionGeneration,
86
93
  digest,
94
+ kind,
87
95
  state: 'pending',
88
96
  response: null,
89
- expiresAt: Date.now() + CREATE_REQUEST_TTL_MS,
97
+ expiresAt: Date.now() + (kind === 'browser_runtime_install'
98
+ ? INSTALL_REQUEST_TTL_MS
99
+ : CREATE_REQUEST_TTL_MS),
90
100
  };
91
101
  browserRequests.set(requestKey, request);
92
102
  return { request };
93
103
  }
94
104
 
95
- export function completeBrowserRequest(agentId, msg, { consume = false } = {}) {
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]);
96
114
  for (const [requestKey, request] of browserRequests) {
97
115
  if (request.agentId !== agentId || request.serverRequestId !== msg.requestId || request.state !== 'pending') continue;
116
+ if (allowedKinds && !allowedKinds.has(request.kind)) continue;
98
117
  request.state = msg.type.endsWith('_error') ? 'failed' : 'completed';
99
118
  request.response = { ...msg, requestId: request.requestId };
100
119
  request.expiresAt = Date.now() + CREATE_REQUEST_TTL_MS;
@@ -1,5 +1,6 @@
1
1
  export const WORKBENCH_ROUTE_PROTOCOL = 1;
2
2
  export const BROWSER_RUNTIME_PROTOCOL = 1;
3
+ export const BROWSER_RUNTIME_SETUP_PROTOCOL = 1;
3
4
 
4
5
  /**
5
6
  * Apply the explicit browser protocol hello to one Server-owned client record.
@@ -14,5 +15,8 @@ export function applyClientHello(client, message) {
14
15
  if (message.browserRuntimeProtocol === BROWSER_RUNTIME_PROTOCOL) {
15
16
  client.browserRuntimeProtocol = BROWSER_RUNTIME_PROTOCOL;
16
17
  }
18
+ if (message.browserRuntimeSetupProtocol === BROWSER_RUNTIME_SETUP_PROTOCOL) {
19
+ client.browserRuntimeSetupProtocol = BROWSER_RUNTIME_SETUP_PROTOCOL;
20
+ }
17
21
  return true;
18
22
  }
@@ -5,6 +5,7 @@ import {
5
5
  browserRoutes,
6
6
  completeBrowserRequest,
7
7
  deleteBrowserPeer,
8
+ findBrowserRequest,
8
9
  deleteBrowserRoute,
9
10
  getBrowserPeer,
10
11
  getBrowserRoute,
@@ -12,6 +13,9 @@ import {
12
13
  } from '../browser-runtime-routes.js';
13
14
 
14
15
  const AGENT_BROWSER_TYPES = new Set([
16
+ 'browser_runtime_status_result',
17
+ 'browser_runtime_install_progress',
18
+ 'browser_runtime_error',
15
19
  'browser_session_created',
16
20
  'browser_session_error',
17
21
  'browser_session_snapshot',
@@ -56,6 +60,50 @@ function sessionSnapshot(msg) {
56
60
  };
57
61
  }
58
62
 
63
+ function safeByteCount(value) {
64
+ const number = Number(value);
65
+ if (!Number.isSafeInteger(number) || number < 0) return 0;
66
+ return Math.min(number, 4 * 1024 * 1024 * 1024);
67
+ }
68
+
69
+ function publicRuntimeMessage(agentId, requestId, msg) {
70
+ if (msg?.type === 'browser_runtime_error') {
71
+ return {
72
+ type: msg.type,
73
+ agentId,
74
+ requestId,
75
+ code: clean(msg.code, 128) || 'browser_runtime_error',
76
+ safeError: clean(msg.safeError, 500) || null,
77
+ };
78
+ }
79
+ if (msg?.type === 'browser_runtime_install_progress') {
80
+ return {
81
+ type: msg.type,
82
+ agentId,
83
+ requestId,
84
+ downloadedBytes: safeByteCount(msg.downloadedBytes),
85
+ totalBytes: safeByteCount(msg.totalBytes),
86
+ };
87
+ }
88
+ return {
89
+ type: 'browser_runtime_status_result',
90
+ agentId,
91
+ requestId,
92
+ supported: msg?.supported === true,
93
+ state: clean(msg?.state, 32) || 'unknown',
94
+ installed: msg?.installed === true,
95
+ enabled: msg?.enabled === true,
96
+ ready: msg?.ready === true,
97
+ buildId: clean(msg?.buildId, 128) || null,
98
+ platform: clean(msg?.platform, 32) || null,
99
+ downloadBytes: safeByteCount(msg?.downloadBytes),
100
+ downloadedBytes: safeByteCount(msg?.downloadedBytes),
101
+ totalBytes: safeByteCount(msg?.totalBytes),
102
+ probeCode: clean(msg?.probeCode, 128) || null,
103
+ safeError: clean(msg?.safeError, 500) || null,
104
+ };
105
+ }
106
+
59
107
  function publicSessionMessage(agentId, msg) {
60
108
  if (msg?.type === 'browser_session_error') {
61
109
  return {
@@ -84,7 +132,7 @@ function publicSessionMessage(agentId, msg) {
84
132
  }
85
133
 
86
134
  async function sendCreateResponse(agentId, msg) {
87
- const request = completeBrowserRequest(agentId, msg);
135
+ const request = completeBrowserRequest(agentId, msg, { kinds: 'browser_session_create' });
88
136
  if (!request) return true;
89
137
  const client = browserClientForPeer({
90
138
  clientId: request.clientId,
@@ -130,13 +178,47 @@ async function sendCreateResponse(agentId, msg) {
130
178
  /** Agent-authenticated Browser events routed only through Server-owned ledgers. */
131
179
  export async function handleAgentBrowser(agentId, agent, msg) {
132
180
  if (!AGENT_BROWSER_TYPES.has(msg?.type)) return false;
181
+ if (msg.type === 'browser_runtime_install_progress') {
182
+ const request = findBrowserRequest(agentId, msg.requestId);
183
+ if (!request || request.state !== 'pending' || request.kind !== 'browser_runtime_install') return true;
184
+ const client = browserClientForPeer({
185
+ clientId: request.clientId,
186
+ webConnectionId: request.webConnectionId,
187
+ webConnectionGeneration: request.webConnectionGeneration,
188
+ });
189
+ if (client?.userId === request.ownerUserId) {
190
+ await sendToWebClient(client, publicRuntimeMessage(agentId, request.requestId, msg));
191
+ }
192
+ return true;
193
+ }
194
+ if (msg.type === 'browser_runtime_status_result' || msg.type === 'browser_runtime_error') {
195
+ const pendingRequest = findBrowserRequest(agentId, msg.requestId);
196
+ const request = completeBrowserRequest(agentId, msg, {
197
+ consume: pendingRequest?.kind === 'browser_runtime_status',
198
+ kinds: ['browser_runtime_status', 'browser_runtime_install', 'browser_runtime_enable'],
199
+ });
200
+ if (!request) return true;
201
+ const client = browserClientForPeer({
202
+ clientId: request.clientId,
203
+ webConnectionId: request.webConnectionId,
204
+ webConnectionGeneration: request.webConnectionGeneration,
205
+ });
206
+ if (client?.userId !== request.ownerUserId) return true;
207
+ const response = publicRuntimeMessage(agentId, request.requestId, msg);
208
+ request.response = response;
209
+ await sendToWebClient(client, response);
210
+ return true;
211
+ }
133
212
  if (msg.type === 'browser_session_created'
134
213
  || (msg.type === 'browser_session_error' && msg.requestId)) {
135
214
  return sendCreateResponse(agentId, msg);
136
215
  }
137
216
 
138
217
  if (msg.type === 'browser_session_list_result') {
139
- const request = completeBrowserRequest(agentId, msg, { consume: true });
218
+ const request = completeBrowserRequest(agentId, msg, {
219
+ consume: true,
220
+ kinds: 'browser_session_list',
221
+ });
140
222
  if (!request) return true;
141
223
  for (const snapshot of Array.isArray(msg.sessions) ? msg.sessions : []) {
142
224
  if (!snapshot?.browserSessionId) continue;
@@ -154,7 +236,10 @@ export async function handleAgentBrowser(agentId, agent, msg) {
154
236
  }
155
237
 
156
238
  if (msg.type === 'browser_session_snapshot' && msg.requestId) {
157
- const request = completeBrowserRequest(agentId, msg, { consume: true });
239
+ const request = completeBrowserRequest(agentId, msg, {
240
+ consume: true,
241
+ kinds: ['browser_session_get', 'browser_session_close'],
242
+ });
158
243
  if (!request) return true;
159
244
  const client = browserClientForPeer({
160
245
  clientId: request.clientId,