@melaya/runner 1.1.0 → 1.1.2
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/dist/assistantHost.py +954 -954
- package/dist/browserAuthz.js +48 -48
- package/dist/browserBridge.d.ts +41 -1
- package/dist/browserBridge.js +197 -22
- package/dist/codeWorker.js +68 -68
- package/dist/connection.js +32 -0
- package/package.json +1 -1
package/dist/browserAuthz.js
CHANGED
|
@@ -332,54 +332,54 @@ async function interceptWebSockets(page, policy, hooks) {
|
|
|
332
332
|
* IP leakage via STUN/TURN. Combined with the CDP Network.enable /
|
|
333
333
|
* setRTCConfiguration override (applied separately via CDP below), this
|
|
334
334
|
* provides layered defence. */
|
|
335
|
-
const PAGE_GUARD_INIT_SCRIPT = `(function() {
|
|
336
|
-
// WebRTC neutralisation: remove peer connection constructors.
|
|
337
|
-
try { delete window.RTCPeerConnection; } catch (_) {}
|
|
338
|
-
try { delete window.RTCSessionDescription; } catch (_) {}
|
|
339
|
-
try { delete window.RTCIceCandidate; } catch (_) {}
|
|
340
|
-
// Also zero out webkit/moz-prefixed variants some legacy code still uses.
|
|
341
|
-
try { delete window.webkitRTCPeerConnection; } catch (_) {}
|
|
342
|
-
try { delete window.mozRTCPeerConnection; } catch (_) {}
|
|
343
|
-
// Override navigator.mediaDevices.getUserMedia to prevent media/camera
|
|
344
|
-
// capture that could be combined with a data channel.
|
|
345
|
-
try {
|
|
346
|
-
if (navigator.mediaDevices && typeof navigator.mediaDevices === 'object') {
|
|
347
|
-
Object.defineProperty(navigator, 'mediaDevices', { value: Object.assign({}, navigator.mediaDevices, { getUserMedia: () => Promise.reject(new DOMException('NotAllowedError')) }), configurable: false, writable: false });
|
|
348
|
-
}
|
|
349
|
-
} catch (_) {}
|
|
350
|
-
// WebSocket URL-blocking fallback (defence-in-depth, supplements
|
|
351
|
-
// routeWebSocket interception which fires at the Playwright layer).
|
|
352
|
-
var _NativeWS = window.WebSocket;
|
|
353
|
-
function _isForbiddenWsHost(url) {
|
|
354
|
-
try {
|
|
355
|
-
var u = new URL(url);
|
|
356
|
-
var h = u.hostname.replace(/^\\[|\\]$/g,'');
|
|
357
|
-
// Loopback / localhost
|
|
358
|
-
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
|
|
359
|
-
// Link-local (169.254.x.x) — cloud metadata endpoints live here
|
|
360
|
-
if (/^169\\.254\\./.test(h)) return true;
|
|
361
|
-
// RFC1918
|
|
362
|
-
if (/^10\\./.test(h)) return true;
|
|
363
|
-
if (/^172\\.(1[6-9]|2[0-9]|3[01])\\./.test(h)) return true;
|
|
364
|
-
if (/^192\\.168\\./.test(h)) return true;
|
|
365
|
-
// CGNAT
|
|
366
|
-
if (/^100\\.(6[4-9]|[7-9][0-9]|1([01][0-9]|2[0-7]))\\./.test(h)) return true;
|
|
367
|
-
// Explicit metadata hosts
|
|
368
|
-
if (h === '100.100.100.200' || h === 'metadata.google.internal' || h === 'metadata.goog') return true;
|
|
369
|
-
} catch (_) {}
|
|
370
|
-
return false;
|
|
371
|
-
}
|
|
372
|
-
window.WebSocket = function WrappedWebSocket(url, protocols) {
|
|
373
|
-
if (_isForbiddenWsHost(String(url))) {
|
|
374
|
-
throw new DOMException('WebSocket connection to ' + url + ' is blocked by Melaya egress policy', 'SecurityError');
|
|
375
|
-
}
|
|
376
|
-
return protocols !== undefined ? new _NativeWS(url, protocols) : new _NativeWS(url);
|
|
377
|
-
};
|
|
378
|
-
window.WebSocket.prototype = _NativeWS.prototype;
|
|
379
|
-
window.WebSocket.CONNECTING = _NativeWS.CONNECTING;
|
|
380
|
-
window.WebSocket.OPEN = _NativeWS.OPEN;
|
|
381
|
-
window.WebSocket.CLOSING = _NativeWS.CLOSING;
|
|
382
|
-
window.WebSocket.CLOSED = _NativeWS.CLOSED;
|
|
335
|
+
const PAGE_GUARD_INIT_SCRIPT = `(function() {
|
|
336
|
+
// WebRTC neutralisation: remove peer connection constructors.
|
|
337
|
+
try { delete window.RTCPeerConnection; } catch (_) {}
|
|
338
|
+
try { delete window.RTCSessionDescription; } catch (_) {}
|
|
339
|
+
try { delete window.RTCIceCandidate; } catch (_) {}
|
|
340
|
+
// Also zero out webkit/moz-prefixed variants some legacy code still uses.
|
|
341
|
+
try { delete window.webkitRTCPeerConnection; } catch (_) {}
|
|
342
|
+
try { delete window.mozRTCPeerConnection; } catch (_) {}
|
|
343
|
+
// Override navigator.mediaDevices.getUserMedia to prevent media/camera
|
|
344
|
+
// capture that could be combined with a data channel.
|
|
345
|
+
try {
|
|
346
|
+
if (navigator.mediaDevices && typeof navigator.mediaDevices === 'object') {
|
|
347
|
+
Object.defineProperty(navigator, 'mediaDevices', { value: Object.assign({}, navigator.mediaDevices, { getUserMedia: () => Promise.reject(new DOMException('NotAllowedError')) }), configurable: false, writable: false });
|
|
348
|
+
}
|
|
349
|
+
} catch (_) {}
|
|
350
|
+
// WebSocket URL-blocking fallback (defence-in-depth, supplements
|
|
351
|
+
// routeWebSocket interception which fires at the Playwright layer).
|
|
352
|
+
var _NativeWS = window.WebSocket;
|
|
353
|
+
function _isForbiddenWsHost(url) {
|
|
354
|
+
try {
|
|
355
|
+
var u = new URL(url);
|
|
356
|
+
var h = u.hostname.replace(/^\\[|\\]$/g,'');
|
|
357
|
+
// Loopback / localhost
|
|
358
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
|
|
359
|
+
// Link-local (169.254.x.x) — cloud metadata endpoints live here
|
|
360
|
+
if (/^169\\.254\\./.test(h)) return true;
|
|
361
|
+
// RFC1918
|
|
362
|
+
if (/^10\\./.test(h)) return true;
|
|
363
|
+
if (/^172\\.(1[6-9]|2[0-9]|3[01])\\./.test(h)) return true;
|
|
364
|
+
if (/^192\\.168\\./.test(h)) return true;
|
|
365
|
+
// CGNAT
|
|
366
|
+
if (/^100\\.(6[4-9]|[7-9][0-9]|1([01][0-9]|2[0-7]))\\./.test(h)) return true;
|
|
367
|
+
// Explicit metadata hosts
|
|
368
|
+
if (h === '100.100.100.200' || h === 'metadata.google.internal' || h === 'metadata.goog') return true;
|
|
369
|
+
} catch (_) {}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
window.WebSocket = function WrappedWebSocket(url, protocols) {
|
|
373
|
+
if (_isForbiddenWsHost(String(url))) {
|
|
374
|
+
throw new DOMException('WebSocket connection to ' + url + ' is blocked by Melaya egress policy', 'SecurityError');
|
|
375
|
+
}
|
|
376
|
+
return protocols !== undefined ? new _NativeWS(url, protocols) : new _NativeWS(url);
|
|
377
|
+
};
|
|
378
|
+
window.WebSocket.prototype = _NativeWS.prototype;
|
|
379
|
+
window.WebSocket.CONNECTING = _NativeWS.CONNECTING;
|
|
380
|
+
window.WebSocket.OPEN = _NativeWS.OPEN;
|
|
381
|
+
window.WebSocket.CLOSING = _NativeWS.CLOSING;
|
|
382
|
+
window.WebSocket.CLOSED = _NativeWS.CLOSED;
|
|
383
383
|
})();`;
|
|
384
384
|
/** Disable WebRTC at the CDP Network domain level in addition to the
|
|
385
385
|
* init-script layer (belt-and-suspenders). Silently skips if CDP
|
package/dist/browserBridge.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BrowserGrant } from "./browserGrantVerify.js";
|
|
2
|
-
import { type SpaceSpec } from "./sessionManager.js";
|
|
2
|
+
import { type BrowserSessionRecord, type SpaceSpec } from "./sessionManager.js";
|
|
3
3
|
import { type BrowserEngineId } from "./browserProvisioner.js";
|
|
4
4
|
export interface BrowserRunSpec {
|
|
5
5
|
runId: string;
|
|
@@ -24,6 +24,42 @@ export interface BrowserBridge {
|
|
|
24
24
|
teardownRun(runId: string, reason: string): Promise<void>;
|
|
25
25
|
teardownAll(reason: string): Promise<void>;
|
|
26
26
|
hasRun(runId: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Launch a headed browser as an interactive (grant-free, read-only-watch)
|
|
29
|
+
* session tracked in sessionManager under `sessionId`.
|
|
30
|
+
*
|
|
31
|
+
* The session is governed by browserProvisioner for engine resolution
|
|
32
|
+
* and by sessionManager for lifecycle. It is discoverable by
|
|
33
|
+
* setWatchLease(sessionId, ...) and by getInteractiveSession(sessionId),
|
|
34
|
+
* which is the attach seam for the grant/run path.
|
|
35
|
+
*
|
|
36
|
+
* State transitions are reported via `onState(sessionId, state, engine)`:
|
|
37
|
+
* "active" - browser is open and ready to watch
|
|
38
|
+
* "closed" - browser was closed by the user or system
|
|
39
|
+
* "failed" - launch failed (engine not found, Playwright error, etc.)
|
|
40
|
+
*
|
|
41
|
+
* Errors during launch are caught internally; the caller receives
|
|
42
|
+
* "failed" via onState rather than a thrown exception.
|
|
43
|
+
*/
|
|
44
|
+
launchInteractive(sessionId: string, engine: string, profile: string, onState: (sessionId: string, state: "active" | "closed" | "failed", engine: string) => void): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Look up an active interactive session by the sessionId that was passed
|
|
47
|
+
* to launchInteractive. Returns the BrowserSessionRecord if the session
|
|
48
|
+
* is live, or undefined if it was never launched, has closed, or crashed.
|
|
49
|
+
*
|
|
50
|
+
* ATTACH SEAM: the grant/run path in ensureSession should call this
|
|
51
|
+
* before launching a new browser. If a record is returned the run can
|
|
52
|
+
* reuse rec.context and the existing page rather than opening a second
|
|
53
|
+
* browser for the same sessionId. Concretely, in ensureSession the
|
|
54
|
+
* "LAUNCH mode" block should check:
|
|
55
|
+
*
|
|
56
|
+
* const interactive = bridge.getInteractiveSession(spec.sessionId);
|
|
57
|
+
* if (interactive) { ... reuse interactive.context ... }
|
|
58
|
+
*
|
|
59
|
+
* This seam is left as a documented hook; full grant-attach wiring is
|
|
60
|
+
* a follow-up task once the grant schema carries a sessionId.
|
|
61
|
+
*/
|
|
62
|
+
getInteractiveSession(sessionId: string): BrowserSessionRecord | undefined;
|
|
27
63
|
/**
|
|
28
64
|
* Set the watch-lease state for a session (plan Section 10.f).
|
|
29
65
|
*
|
|
@@ -36,6 +72,10 @@ export interface BrowserBridge {
|
|
|
36
72
|
*
|
|
37
73
|
* The producer runs only while a viewer is watching and stops
|
|
38
74
|
* automatically when the run is torn down.
|
|
75
|
+
*
|
|
76
|
+
* This method now finds both grant-scoped run sessions (looked up by
|
|
77
|
+
* runId) AND interactive sessions launched via launchInteractive
|
|
78
|
+
* (looked up by sessionId directly).
|
|
39
79
|
*/
|
|
40
80
|
setWatchLease(sessionId: string, active: boolean, framePostUrl: string, runAuthHeader: string): void;
|
|
41
81
|
shutdown(): Promise<void>;
|
package/dist/browserBridge.js
CHANGED
|
@@ -82,6 +82,12 @@ export async function startBrowserBridge(opts) {
|
|
|
82
82
|
// Per-lease transient state the sessionManager types stay clean of.
|
|
83
83
|
const frameMaps = new WeakMap();
|
|
84
84
|
const cdpByPage = new WeakMap();
|
|
85
|
+
// Interactive sessions: launched via launchInteractive(), tracked by the
|
|
86
|
+
// sessionId from the browser:launch socket event. The sessionManager record
|
|
87
|
+
// is stored here as well as in sessions (keyed by sessionId as runId) so
|
|
88
|
+
// getInteractiveSession and setWatchLease can find them without iterating
|
|
89
|
+
// the full grant-scoped run registry.
|
|
90
|
+
const interactiveSessions = new Map();
|
|
85
91
|
let playwrightMod = null;
|
|
86
92
|
async function pw() {
|
|
87
93
|
if (!playwrightMod)
|
|
@@ -198,6 +204,105 @@ export async function startBrowserBridge(opts) {
|
|
|
198
204
|
void disableWebRtcViaCdp(cdp);
|
|
199
205
|
return cdp;
|
|
200
206
|
}
|
|
207
|
+
// -- Interactive launch (browser:launch, no grant) ---------------------
|
|
208
|
+
//
|
|
209
|
+
// Opens a headed browser tracked in sessionManager so setWatchLease can
|
|
210
|
+
// find and stream frames from it. The session is READ-ONLY from the
|
|
211
|
+
// bridge's perspective (no CDP intercept effects, no grant gates). The
|
|
212
|
+
// sessionId from the socket event is used directly as the runId in the
|
|
213
|
+
// session manager so the setWatchLease lookup (which already checks
|
|
214
|
+
// `runId === sessionId`) finds the record without any additional mapping.
|
|
215
|
+
async function launchInteractive(sessionId, engine, profile, onState) {
|
|
216
|
+
// Normalise engine to a known BrowserEngineId; default to "chrome".
|
|
217
|
+
const knownEngines = new Set(["chrome", "edge", "brave", "chromium"]);
|
|
218
|
+
const engineId = (knownEngines.has(engine) ? engine : "chrome");
|
|
219
|
+
// Resolve profile directory, mirroring simpleBrowserLauncher semantics:
|
|
220
|
+
// "dedicated" => ephemeral per-session dir under tmpdir
|
|
221
|
+
// anything else => persistent named dir under ~/.melaya-runner/browser-profiles/
|
|
222
|
+
// spaceUserDataDir() already handles ephemeral vs persistent; map to SpaceSpec.
|
|
223
|
+
const spaceSpec = profile === "dedicated"
|
|
224
|
+
? { kind: "ephemeral" }
|
|
225
|
+
: { kind: "persistent", id: `browser-profiles-${engineId}-${profile}` };
|
|
226
|
+
// Reject duplicate launches for the same sessionId.
|
|
227
|
+
if (interactiveSessions.has(sessionId)) {
|
|
228
|
+
log(`[browser-launch] sessionId=${sessionId.slice(0, 16)} already active; ignoring duplicate launch`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const playwright = await pw();
|
|
233
|
+
const resolved = await ensureEngine(engineId, { log });
|
|
234
|
+
const { dir: userDataDir, ephemeral } = spaceUserDataDir(spaceSpec, sessionId);
|
|
235
|
+
// Create the session record BEFORE launch so teardownAll can find it
|
|
236
|
+
// even if the launch is still in progress.
|
|
237
|
+
const rec = sessions.createSession({
|
|
238
|
+
runId: sessionId,
|
|
239
|
+
ownership: "owned",
|
|
240
|
+
engine: engineId,
|
|
241
|
+
space: spaceSpec,
|
|
242
|
+
// Interactive sessions have a longer idle TTL (30 min) because the
|
|
243
|
+
// user may leave the browser open without actively using the runner.
|
|
244
|
+
idleTtlMs: 30 * 60 * 1000,
|
|
245
|
+
maxLifetimeMs: 4 * 60 * 60 * 1000,
|
|
246
|
+
});
|
|
247
|
+
interactiveSessions.set(sessionId, rec);
|
|
248
|
+
let context;
|
|
249
|
+
try {
|
|
250
|
+
context = await playwright.chromium.launchPersistentContext(userDataDir, {
|
|
251
|
+
executablePath: resolved.executablePath,
|
|
252
|
+
headless: false,
|
|
253
|
+
viewport: { width: 1280, height: 800 },
|
|
254
|
+
acceptDownloads: false,
|
|
255
|
+
args: [
|
|
256
|
+
"--no-first-run",
|
|
257
|
+
"--no-default-browser-check",
|
|
258
|
+
"--disable-background-networking",
|
|
259
|
+
"--disable-sync",
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch (launchErr) {
|
|
264
|
+
// Clean up the session record we created before failing.
|
|
265
|
+
interactiveSessions.delete(sessionId);
|
|
266
|
+
await sessions.teardownRun(sessionId, "interactive_launch_failed").catch(() => { });
|
|
267
|
+
log(`[browser-launch] launch failed sessionId=${sessionId.slice(0, 16)}: ${launchErr?.message || launchErr}`);
|
|
268
|
+
onState(sessionId, "failed", engine);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
sessions.attachHandles(rec, {
|
|
272
|
+
context,
|
|
273
|
+
ephemeralUserDataDir: ephemeral ? userDataDir : null,
|
|
274
|
+
});
|
|
275
|
+
log(`[browser-launch] interactive session active sessionId=${sessionId.slice(0, 16)} engine=${engineId}`);
|
|
276
|
+
onState(sessionId, "active", engine);
|
|
277
|
+
// Detect browser close: context 'close' fires when the user shuts
|
|
278
|
+
// the browser window or when teardown closes it.
|
|
279
|
+
context.once("close", () => {
|
|
280
|
+
interactiveSessions.delete(sessionId);
|
|
281
|
+
// Only emit "closed" if the session was not already torn down by
|
|
282
|
+
// teardownAll/shutdown (rec.state will be "closed" in that case).
|
|
283
|
+
if (rec.state !== "closed") {
|
|
284
|
+
sessions.teardownRun(sessionId, "interactive_context_closed").catch(() => { });
|
|
285
|
+
log(`[browser-launch] interactive session closed sessionId=${sessionId.slice(0, 16)}`);
|
|
286
|
+
onState(sessionId, "closed", engine);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
interactiveSessions.delete(sessionId);
|
|
292
|
+
log(`[browser-launch] unexpected error sessionId=${sessionId.slice(0, 16)}: ${err?.message || err}`);
|
|
293
|
+
onState(sessionId, "failed", engine);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function getInteractiveSession(sessionId) {
|
|
297
|
+
const rec = interactiveSessions.get(sessionId);
|
|
298
|
+
if (!rec)
|
|
299
|
+
return undefined;
|
|
300
|
+
if (rec.state === "closed" || rec.state === "crashed") {
|
|
301
|
+
interactiveSessions.delete(sessionId);
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
return rec;
|
|
305
|
+
}
|
|
201
306
|
async function captureSnapshot(rec, lease, scope) {
|
|
202
307
|
const page = lease.page;
|
|
203
308
|
const generation = sessions.beginSnapshot(lease);
|
|
@@ -708,19 +813,57 @@ export async function startBrowserBridge(opts) {
|
|
|
708
813
|
async function captureAndSchedule(lease) {
|
|
709
814
|
if (!lease.active)
|
|
710
815
|
return;
|
|
711
|
-
const reg = byRunId.get(lease.runId);
|
|
712
|
-
if (!reg || reg.cancelled) {
|
|
713
|
-
lease.active = false;
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
816
|
try {
|
|
717
817
|
const rec = sessions.peekSession(lease.runId);
|
|
718
818
|
if (!rec || rec.state === "crashed" || rec.state === "closed") {
|
|
719
819
|
lease.active = false;
|
|
720
820
|
return;
|
|
721
821
|
}
|
|
722
|
-
|
|
723
|
-
|
|
822
|
+
// Resolve the target page. Interactive sessions (no grant, no registered
|
|
823
|
+
// run) use the first available page in the context. Grant-scoped run
|
|
824
|
+
// sessions use the leased target ref from the registration.
|
|
825
|
+
let targetLease = null;
|
|
826
|
+
const isInteractive = interactiveSessions.has(lease.runId) ||
|
|
827
|
+
(interactiveSessions.has(lease.sessionId) && lease.sessionId === lease.runId);
|
|
828
|
+
if (isInteractive) {
|
|
829
|
+
// Interactive: grab the first page in the context, or create one.
|
|
830
|
+
const context = rec.context;
|
|
831
|
+
if (!context) {
|
|
832
|
+
lease.active = false;
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
let page = context.pages()[0];
|
|
836
|
+
if (!page) {
|
|
837
|
+
try {
|
|
838
|
+
page = await context.newPage();
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
lease.active = false;
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
// Lease the page under a stable "interactive" ref. leaseTarget is
|
|
846
|
+
// idempotent (returns existing lease if the ref already exists).
|
|
847
|
+
targetLease = sessions.leaseTarget(rec, "interactive", page);
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
const reg = byRunId.get(lease.runId);
|
|
851
|
+
if (!reg || reg.cancelled) {
|
|
852
|
+
lease.active = false;
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
try {
|
|
856
|
+
targetLease = sessions.getLease(rec, reg.spec.grant.target.ref);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
// Target lease not yet established (session still initialising) or gone.
|
|
860
|
+
if (lease.active) {
|
|
861
|
+
lease.timer = setTimeout(() => { void captureAndSchedule(lease); }, 1000);
|
|
862
|
+
}
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const { image_b64: jpegB64 } = await takeScreenshot(targetLease);
|
|
724
867
|
const changed = jpegB64 !== lease.lastJpegB64;
|
|
725
868
|
if (changed) {
|
|
726
869
|
lease.lastJpegB64 = jpegB64;
|
|
@@ -757,24 +900,39 @@ export async function startBrowserBridge(opts) {
|
|
|
757
900
|
stopWatchLease(sessionId);
|
|
758
901
|
return;
|
|
759
902
|
}
|
|
760
|
-
// Find which runId corresponds to this sessionId
|
|
761
|
-
//
|
|
903
|
+
// Find which runId corresponds to this sessionId.
|
|
904
|
+
// Priority order:
|
|
905
|
+
// 1. Interactive session: sessionId is both the key in interactiveSessions
|
|
906
|
+
// AND the runId used in sessionManager (so peek will find it directly).
|
|
907
|
+
// 2. Grant-scoped run session: iterate byRunId and check rec.id or runId.
|
|
762
908
|
let matchedRunId = "";
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
909
|
+
// Check interactive sessions first (launched via launchInteractive).
|
|
910
|
+
if (interactiveSessions.has(sessionId)) {
|
|
911
|
+
const rec = interactiveSessions.get(sessionId);
|
|
912
|
+
if (rec && rec.state !== "closed" && rec.state !== "crashed") {
|
|
913
|
+
matchedRunId = sessionId; // sessionId is used as runId for interactive sessions
|
|
768
914
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
915
|
+
}
|
|
916
|
+
// Fall back to grant-scoped run sessions (session ids map 1:1 to runIds
|
|
917
|
+
// in Phase 1, one session per run).
|
|
918
|
+
if (!matchedRunId) {
|
|
919
|
+
for (const [runId, reg] of byRunId) {
|
|
920
|
+
const rec = sessions.peekSession(runId);
|
|
921
|
+
if (rec && rec.id === sessionId) {
|
|
922
|
+
matchedRunId = runId;
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
// Also accept runId directly as sessionId (used when the server
|
|
926
|
+
// addresses the watch by runId rather than the session UUID).
|
|
927
|
+
if (runId === sessionId) {
|
|
928
|
+
matchedRunId = runId;
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
void reg; // suppress unused variable warning
|
|
774
932
|
}
|
|
775
933
|
}
|
|
776
934
|
if (!matchedRunId) {
|
|
777
|
-
log(`[watch] browser:watch active=true for unknown sessionId ${sessionId.slice(0, 16)}
|
|
935
|
+
log(`[watch] browser:watch active=true for unknown sessionId ${sessionId.slice(0, 16)} - ignored`);
|
|
778
936
|
return;
|
|
779
937
|
}
|
|
780
938
|
// Stop any existing lease for this session before starting a new one.
|
|
@@ -985,8 +1143,23 @@ export async function startBrowserBridge(opts) {
|
|
|
985
1143
|
log(`browser run torn down: ${runId.slice(0, 10)} reason=${reason}`);
|
|
986
1144
|
};
|
|
987
1145
|
const teardownAll = async (reason) => {
|
|
988
|
-
|
|
989
|
-
|
|
1146
|
+
// Tear down grant-scoped run sessions.
|
|
1147
|
+
const runIds = [...byRunId.keys()];
|
|
1148
|
+
// Tear down interactive sessions (those not already covered by byRunId).
|
|
1149
|
+
const interactiveIds = [...interactiveSessions.keys()].filter((id) => !byRunId.has(id));
|
|
1150
|
+
await Promise.all([
|
|
1151
|
+
...runIds.map((id) => teardownRun(id, reason)),
|
|
1152
|
+
...interactiveIds.map(async (id) => {
|
|
1153
|
+
// Stop watch-lease producers for this interactive session.
|
|
1154
|
+
for (const [sid, wl] of watchLeases) {
|
|
1155
|
+
if (wl.runId === id)
|
|
1156
|
+
stopWatchLease(sid);
|
|
1157
|
+
}
|
|
1158
|
+
interactiveSessions.delete(id);
|
|
1159
|
+
await sessions.teardownRun(id, reason);
|
|
1160
|
+
log(`[browser-launch] interactive session torn down: ${id.slice(0, 16)} reason=${reason}`);
|
|
1161
|
+
}),
|
|
1162
|
+
]);
|
|
990
1163
|
};
|
|
991
1164
|
return {
|
|
992
1165
|
url: `http://127.0.0.1:${port}`,
|
|
@@ -997,6 +1170,8 @@ export async function startBrowserBridge(opts) {
|
|
|
997
1170
|
hasRun(runId) {
|
|
998
1171
|
return byRunId.has(runId);
|
|
999
1172
|
},
|
|
1173
|
+
launchInteractive,
|
|
1174
|
+
getInteractiveSession,
|
|
1000
1175
|
setWatchLease,
|
|
1001
1176
|
async shutdown() {
|
|
1002
1177
|
// Stop all watch-lease producers before closing sessions.
|
package/dist/codeWorker.js
CHANGED
|
@@ -95,74 +95,74 @@ const WORKER_OP_ALLOWLIST = new Set([
|
|
|
95
95
|
// boundary is the process + OS sandbox around it. The vm context exposes
|
|
96
96
|
// exactly: browser.<op>(), console.log/warn/error, sleep(ms), and
|
|
97
97
|
// JSON/Math/Date via the fresh realm.
|
|
98
|
-
const WORKER_ENTRY_SOURCE = `"use strict";
|
|
99
|
-
const vm = require("node:vm");
|
|
100
|
-
const readline = require("node:readline");
|
|
101
|
-
|
|
102
|
-
let seq = 0;
|
|
103
|
-
const pending = new Map();
|
|
104
|
-
function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); }
|
|
105
|
-
|
|
106
|
-
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
107
|
-
let scriptSource = null;
|
|
108
|
-
rl.on("line", (line) => {
|
|
109
|
-
let m;
|
|
110
|
-
try { m = JSON.parse(line); } catch { return; }
|
|
111
|
-
if (m.type === "start" && typeof m.script === "string" && scriptSource === null) {
|
|
112
|
-
scriptSource = m.script;
|
|
113
|
-
run(scriptSource);
|
|
114
|
-
} else if (m.type === "op_result" && pending.has(m.id)) {
|
|
115
|
-
const { resolve, reject } = pending.get(m.id);
|
|
116
|
-
pending.delete(m.id);
|
|
117
|
-
if (m.ok) resolve(m.result);
|
|
118
|
-
else reject(new Error(m.error || "op failed"));
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
function facadeOp(op) {
|
|
123
|
-
return (args) => new Promise((resolve, reject) => {
|
|
124
|
-
const id = ++seq;
|
|
125
|
-
pending.set(id, { resolve, reject });
|
|
126
|
-
send({ type: "op", id, op, args: args && typeof args === "object" ? args : {} });
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function run(source) {
|
|
131
|
-
const browser = Object.freeze({
|
|
132
|
-
currentTarget: facadeOp("current_target"),
|
|
133
|
-
getScreenTree: facadeOp("get_screen_tree"),
|
|
134
|
-
screenshot: facadeOp("screenshot"),
|
|
135
|
-
act: facadeOp("act"),
|
|
136
|
-
wait: facadeOp("wait"),
|
|
137
|
-
});
|
|
138
|
-
const consoleShim = {
|
|
139
|
-
log: (...a) => send({ type: "console", line: a.map(String).join(" ") }),
|
|
140
|
-
warn: (...a) => send({ type: "console", line: "[warn] " + a.map(String).join(" ") }),
|
|
141
|
-
error: (...a) => send({ type: "console", line: "[error] " + a.map(String).join(" ") }),
|
|
142
|
-
};
|
|
143
|
-
const sleep = (ms) => facadeOp("wait")({ ms });
|
|
144
|
-
const ctx = vm.createContext(Object.create(null), { codeGeneration: { strings: false, wasm: false } });
|
|
145
|
-
ctx.browser = browser;
|
|
146
|
-
ctx.console = consoleShim;
|
|
147
|
-
ctx.sleep = sleep;
|
|
148
|
-
ctx.JSON = JSON;
|
|
149
|
-
try {
|
|
150
|
-
const script = new vm.Script(
|
|
151
|
-
"(async () => {\\n" + source + "\\n})()",
|
|
152
|
-
{ filename: "model-script.js" },
|
|
153
|
-
);
|
|
154
|
-
const result = await script.runInContext(ctx, { timeout: 30000 });
|
|
155
|
-
send({ type: "done", result: safeJson(result) });
|
|
156
|
-
} catch (e) {
|
|
157
|
-
send({ type: "error", message: String((e && e.message) || e) });
|
|
158
|
-
}
|
|
159
|
-
process.exit(0);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function safeJson(v) {
|
|
163
|
-
try { return JSON.parse(JSON.stringify(v === undefined ? null : v)); }
|
|
164
|
-
catch { return String(v); }
|
|
165
|
-
}
|
|
98
|
+
const WORKER_ENTRY_SOURCE = `"use strict";
|
|
99
|
+
const vm = require("node:vm");
|
|
100
|
+
const readline = require("node:readline");
|
|
101
|
+
|
|
102
|
+
let seq = 0;
|
|
103
|
+
const pending = new Map();
|
|
104
|
+
function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); }
|
|
105
|
+
|
|
106
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
107
|
+
let scriptSource = null;
|
|
108
|
+
rl.on("line", (line) => {
|
|
109
|
+
let m;
|
|
110
|
+
try { m = JSON.parse(line); } catch { return; }
|
|
111
|
+
if (m.type === "start" && typeof m.script === "string" && scriptSource === null) {
|
|
112
|
+
scriptSource = m.script;
|
|
113
|
+
run(scriptSource);
|
|
114
|
+
} else if (m.type === "op_result" && pending.has(m.id)) {
|
|
115
|
+
const { resolve, reject } = pending.get(m.id);
|
|
116
|
+
pending.delete(m.id);
|
|
117
|
+
if (m.ok) resolve(m.result);
|
|
118
|
+
else reject(new Error(m.error || "op failed"));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function facadeOp(op) {
|
|
123
|
+
return (args) => new Promise((resolve, reject) => {
|
|
124
|
+
const id = ++seq;
|
|
125
|
+
pending.set(id, { resolve, reject });
|
|
126
|
+
send({ type: "op", id, op, args: args && typeof args === "object" ? args : {} });
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function run(source) {
|
|
131
|
+
const browser = Object.freeze({
|
|
132
|
+
currentTarget: facadeOp("current_target"),
|
|
133
|
+
getScreenTree: facadeOp("get_screen_tree"),
|
|
134
|
+
screenshot: facadeOp("screenshot"),
|
|
135
|
+
act: facadeOp("act"),
|
|
136
|
+
wait: facadeOp("wait"),
|
|
137
|
+
});
|
|
138
|
+
const consoleShim = {
|
|
139
|
+
log: (...a) => send({ type: "console", line: a.map(String).join(" ") }),
|
|
140
|
+
warn: (...a) => send({ type: "console", line: "[warn] " + a.map(String).join(" ") }),
|
|
141
|
+
error: (...a) => send({ type: "console", line: "[error] " + a.map(String).join(" ") }),
|
|
142
|
+
};
|
|
143
|
+
const sleep = (ms) => facadeOp("wait")({ ms });
|
|
144
|
+
const ctx = vm.createContext(Object.create(null), { codeGeneration: { strings: false, wasm: false } });
|
|
145
|
+
ctx.browser = browser;
|
|
146
|
+
ctx.console = consoleShim;
|
|
147
|
+
ctx.sleep = sleep;
|
|
148
|
+
ctx.JSON = JSON;
|
|
149
|
+
try {
|
|
150
|
+
const script = new vm.Script(
|
|
151
|
+
"(async () => {\\n" + source + "\\n})()",
|
|
152
|
+
{ filename: "model-script.js" },
|
|
153
|
+
);
|
|
154
|
+
const result = await script.runInContext(ctx, { timeout: 30000 });
|
|
155
|
+
send({ type: "done", result: safeJson(result) });
|
|
156
|
+
} catch (e) {
|
|
157
|
+
send({ type: "error", message: String((e && e.message) || e) });
|
|
158
|
+
}
|
|
159
|
+
process.exit(0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function safeJson(v) {
|
|
163
|
+
try { return JSON.parse(JSON.stringify(v === undefined ? null : v)); }
|
|
164
|
+
catch { return String(v); }
|
|
165
|
+
}
|
|
166
166
|
`;
|
|
167
167
|
function nodePermissionFlags(scratchDir, entryPath) {
|
|
168
168
|
const major = Number(process.version.replace(/^v/, "").split(".")[0]) || 0;
|
package/dist/connection.js
CHANGED
|
@@ -295,6 +295,38 @@ export async function connect(opts) {
|
|
|
295
295
|
console.log(chalk.gray(` [browser-bridge] watch lease sessionId=${sessionId.slice(0, 16)} active=${active}`));
|
|
296
296
|
}
|
|
297
297
|
});
|
|
298
|
+
// ── BrowserControlPage interactive browser launch (plan BrowserControlPage) ──
|
|
299
|
+
// The server emits browser:launch when the user clicks "Launch controllable
|
|
300
|
+
// browser" in the BrowserControlPage UI. Unlike runner:run browser grants,
|
|
301
|
+
// this is an unprivileged direct-launch (no grant, no CDP intercept) — the
|
|
302
|
+
// server just wants us to open a browser process the user can observe.
|
|
303
|
+
// State transitions are relayed back via browser:session so the server can
|
|
304
|
+
// update agents.browser_sessions.
|
|
305
|
+
socket.on("browser:launch", async (payload) => {
|
|
306
|
+
const sessionId = String(payload?.sessionId || "");
|
|
307
|
+
const engine = String(payload?.engine || "chrome");
|
|
308
|
+
const profile = String(payload?.profile || "dedicated");
|
|
309
|
+
if (!sessionId)
|
|
310
|
+
return;
|
|
311
|
+
if (opts.verbose) {
|
|
312
|
+
console.log(chalk.gray(` [browser-launch] sessionId=${sessionId.slice(0, 16)} engine=${engine} profile=${profile}`));
|
|
313
|
+
}
|
|
314
|
+
// Ensure the governed bridge is running before delegating.
|
|
315
|
+
const bridge = await _ensureBrowserBridge();
|
|
316
|
+
if (!bridge) {
|
|
317
|
+
console.log(chalk.yellow(` ! browser:launch: bridge unavailable for sessionId=${sessionId.slice(0, 16)}`));
|
|
318
|
+
socket.emit("browser:session", { sessionId, state: "failed", engine });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Delegate to the governed bridge. State transitions (active/closed/failed)
|
|
322
|
+
// are relayed back through the existing browser:session socket event.
|
|
323
|
+
await bridge.launchInteractive(sessionId, engine, profile, (sid, state, eng) => {
|
|
324
|
+
socket.emit("browser:session", { sessionId: sid, state, engine: eng });
|
|
325
|
+
if (opts.verbose) {
|
|
326
|
+
console.log(chalk.gray(` [browser-launch] browser:session sessionId=${sid.slice(0, 16)} state=${state}`));
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
298
330
|
// ── Heartbeat ──────────────────────────────────────────────────────
|
|
299
331
|
setInterval(() => {
|
|
300
332
|
if (socket.connected)
|