agentmb 0.3.1 → 0.4.0
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/README.md +370 -10
- package/dist/browser/actions.d.ts +15 -1
- package/dist/browser/actions.d.ts.map +1 -1
- package/dist/browser/actions.js +59 -4
- package/dist/browser/actions.js.map +1 -1
- package/dist/browser/manager.d.ts +22 -0
- package/dist/browser/manager.d.ts.map +1 -1
- package/dist/browser/manager.js +208 -41
- package/dist/browser/manager.js.map +1 -1
- package/dist/cli/client.d.ts +5 -0
- package/dist/cli/client.d.ts.map +1 -1
- package/dist/cli/client.js +20 -0
- package/dist/cli/client.js.map +1 -1
- package/dist/cli/commands/actions.d.ts.map +1 -1
- package/dist/cli/commands/actions.js +77 -17
- package/dist/cli/commands/actions.js.map +1 -1
- package/dist/cli/commands/browser-launch.d.ts.map +1 -1
- package/dist/cli/commands/browser-launch.js +48 -3
- package/dist/cli/commands/browser-launch.js.map +1 -1
- package/dist/cli/commands/profile.d.ts +10 -0
- package/dist/cli/commands/profile.d.ts.map +1 -0
- package/dist/cli/commands/profile.js +84 -0
- package/dist/cli/commands/profile.js.map +1 -0
- package/dist/cli/commands/session.d.ts.map +1 -1
- package/dist/cli/commands/session.js +145 -3
- package/dist/cli/commands/session.js.map +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/config.d.ts +2 -0
- package/dist/daemon/config.d.ts.map +1 -1
- package/dist/daemon/config.js +5 -0
- package/dist/daemon/config.js.map +1 -1
- package/dist/daemon/routes/actions.d.ts.map +1 -1
- package/dist/daemon/routes/actions.js +142 -14
- package/dist/daemon/routes/actions.js.map +1 -1
- package/dist/daemon/routes/interaction.d.ts.map +1 -1
- package/dist/daemon/routes/interaction.js +10 -1
- package/dist/daemon/routes/interaction.js.map +1 -1
- package/dist/daemon/routes/sessions.d.ts.map +1 -1
- package/dist/daemon/routes/sessions.js +573 -23
- package/dist/daemon/routes/sessions.js.map +1 -1
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +2 -1
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session.d.ts +5 -0
- package/dist/daemon/session.d.ts.map +1 -1
- package/dist/daemon/session.js +9 -0
- package/dist/daemon/session.js.map +1 -1
- package/package.json +4 -2
- package/skills/agentmb/SKILL.md +620 -0
- package/skills/agentmb/references/authentication.md +180 -0
- package/skills/agentmb/references/browser-modes.md +167 -0
- package/skills/agentmb/references/commands.md +231 -0
- package/skills/agentmb/references/locator-modes.md +254 -0
- package/skills/agentmb/references/session-management.md +260 -0
|
@@ -57,10 +57,34 @@ function sanitizeCdpError(raw) {
|
|
|
57
57
|
.trim()
|
|
58
58
|
.slice(0, 300);
|
|
59
59
|
}
|
|
60
|
+
function isRetryableProfileDeleteError(err) {
|
|
61
|
+
const code = String(err?.code ?? '');
|
|
62
|
+
const message = String(err?.message ?? '');
|
|
63
|
+
if (code === 'EBUSY' || code === 'EPERM' || code === 'ENOTEMPTY')
|
|
64
|
+
return true;
|
|
65
|
+
return /resource busy|locked|not empty/i.test(message);
|
|
66
|
+
}
|
|
67
|
+
async function removeProfileDirWithRetry(profilePath, attempts = 6) {
|
|
68
|
+
let lastError;
|
|
69
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
70
|
+
try {
|
|
71
|
+
await fs_1.default.promises.rm(profilePath, { recursive: true, force: true });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
lastError = err;
|
|
76
|
+
if (!isRetryableProfileDeleteError(err) || i === attempts - 1)
|
|
77
|
+
break;
|
|
78
|
+
// Give the browser process a brief window to release filesystem handles.
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 120 * (i + 1)));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw lastError;
|
|
83
|
+
}
|
|
60
84
|
function registerSessionRoutes(server, registry) {
|
|
61
85
|
// POST /api/v1/sessions — create session
|
|
62
86
|
server.post('/api/v1/sessions', async (req, reply) => {
|
|
63
|
-
const { profile, headless = true, agent_id, accept_downloads = false, ephemeral, browser_channel, executable_path, launch_mode, cdp_url, } = req.body ?? {};
|
|
87
|
+
const { profile, headless = true, agent_id, accept_downloads = false, ephemeral, browser_channel, executable_path, launch_mode, cdp_url, proxy_url, record_video, allow_dirs, allow_extensions, } = req.body ?? {};
|
|
64
88
|
const manager = server.browserManager;
|
|
65
89
|
if (!manager) {
|
|
66
90
|
return reply.code(503).send({ error: 'Browser manager not initialized' });
|
|
@@ -87,7 +111,7 @@ function registerSessionRoutes(server, registry) {
|
|
|
87
111
|
const id = registry.create({
|
|
88
112
|
profile, headless, agentId: agent_id,
|
|
89
113
|
ephemeral, browserChannel: browser_channel, executablePath: executable_path,
|
|
90
|
-
launchMode: launch_mode, cdpUrl: cdp_url,
|
|
114
|
+
launchMode: launch_mode, cdpUrl: cdp_url, allowExtensions: allow_extensions,
|
|
91
115
|
});
|
|
92
116
|
try {
|
|
93
117
|
if (launch_mode === 'attach') {
|
|
@@ -115,6 +139,8 @@ function registerSessionRoutes(server, registry) {
|
|
|
115
139
|
await manager.launchSession(id, {
|
|
116
140
|
profile, headless, acceptDownloads: accept_downloads,
|
|
117
141
|
channel: browser_channel, executablePath: executable_path, ephemeral,
|
|
142
|
+
proxyUrl: proxy_url, recordVideo: record_video, allowDirs: allow_dirs,
|
|
143
|
+
allowExtensions: allow_extensions,
|
|
118
144
|
});
|
|
119
145
|
}
|
|
120
146
|
}
|
|
@@ -133,6 +159,7 @@ function registerSessionRoutes(server, registry) {
|
|
|
133
159
|
ephemeral: s.ephemeral ?? false,
|
|
134
160
|
browser_channel: s.browserChannel ?? null,
|
|
135
161
|
launch_mode: s.launchMode ?? 'managed',
|
|
162
|
+
allow_extensions: s.allowExtensions ?? false,
|
|
136
163
|
});
|
|
137
164
|
});
|
|
138
165
|
// GET /api/v1/sessions — list (normalized to snake_case for SDK)
|
|
@@ -175,8 +202,9 @@ function registerSessionRoutes(server, registry) {
|
|
|
175
202
|
const s = registry.get(req.params.id);
|
|
176
203
|
if (!s)
|
|
177
204
|
return reply.code(404).send({ error: 'Not found' });
|
|
178
|
-
|
|
179
|
-
|
|
205
|
+
const force = req.query.force === 'true';
|
|
206
|
+
if (s.sealed && !force) {
|
|
207
|
+
return reply.code(423).send({ error: 'session_sealed', message: 'Session is sealed and cannot be deleted. Use ?force=true or POST /unseal first.' });
|
|
180
208
|
}
|
|
181
209
|
// Clean up BrowserManager internal state first, then registry
|
|
182
210
|
const manager = server.browserManager;
|
|
@@ -223,6 +251,286 @@ function registerSessionRoutes(server, registry) {
|
|
|
223
251
|
warning: 'close will disconnect only; remote browser process is not terminated',
|
|
224
252
|
};
|
|
225
253
|
});
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// R10-T03: session adopt — extract CDP browser state → new managed session
|
|
256
|
+
// NOTE: register /sessions/adopt BEFORE /sessions/:id/* so Fastify matches static first
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
server.post('/api/v1/sessions/adopt', async (req, reply) => {
|
|
259
|
+
const manager = server.browserManager;
|
|
260
|
+
if (!manager)
|
|
261
|
+
return reply.code(503).send({ error: 'Browser manager not initialized' });
|
|
262
|
+
const { cdp_url, profile: targetProfile, headed } = req.body ?? {};
|
|
263
|
+
if (!cdp_url)
|
|
264
|
+
return reply.code(400).send({ error: 'cdp_url is required' });
|
|
265
|
+
if (!cdp_url.startsWith('http://') && !cdp_url.startsWith('https://') && !cdp_url.startsWith('ws://') && !cdp_url.startsWith('wss://')) {
|
|
266
|
+
return reply.code(400).send({ error: 'cdp_url must be http/https/ws/wss' });
|
|
267
|
+
}
|
|
268
|
+
if (!targetProfile)
|
|
269
|
+
return reply.code(400).send({ error: 'profile is required' });
|
|
270
|
+
const headless = !headed;
|
|
271
|
+
// Step 1: NON-INVASIVELY extract state from external browser via CDP
|
|
272
|
+
const { chromium: chromiumPw } = await Promise.resolve().then(() => __importStar(require('playwright-core')));
|
|
273
|
+
let storageState;
|
|
274
|
+
let cdpBrowser;
|
|
275
|
+
try {
|
|
276
|
+
cdpBrowser = await chromiumPw.connectOverCDP(cdp_url);
|
|
277
|
+
const contexts = cdpBrowser.contexts();
|
|
278
|
+
if (contexts.length === 0) {
|
|
279
|
+
await cdpBrowser.close();
|
|
280
|
+
return reply.code(422).send({ error: 'No browser context found at the CDP URL. Make sure the browser is open with --remote-debugging-port.' });
|
|
281
|
+
}
|
|
282
|
+
storageState = await contexts[0].storageState();
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
try {
|
|
286
|
+
await cdpBrowser?.close();
|
|
287
|
+
}
|
|
288
|
+
catch { /* ignore */ }
|
|
289
|
+
return reply.code(502).send({ error: `Failed to connect/extract from CDP: ${err.message}` });
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
// Disconnect WITHOUT closing remote browser
|
|
293
|
+
try {
|
|
294
|
+
await cdpBrowser?.close();
|
|
295
|
+
}
|
|
296
|
+
catch { /* ignore */ }
|
|
297
|
+
}
|
|
298
|
+
// Step 2: Create and launch new managed Chromium session
|
|
299
|
+
const newId = registry.create({ profile: targetProfile, headless, launchMode: 'managed' });
|
|
300
|
+
try {
|
|
301
|
+
await manager.launchSession(newId, { profile: targetProfile, headless });
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
await registry.close(newId);
|
|
305
|
+
return reply.code(500).send({ error: `Failed to launch session: ${err.message}` });
|
|
306
|
+
}
|
|
307
|
+
// Step 3: Inject cookies (immediate)
|
|
308
|
+
const cookies = storageState.cookies ?? [];
|
|
309
|
+
let cookieWarning;
|
|
310
|
+
try {
|
|
311
|
+
if (cookies.length > 0)
|
|
312
|
+
await manager.addCookies(newId, cookies);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
cookieWarning = `Cookie injection failed: ${err.message}`;
|
|
316
|
+
}
|
|
317
|
+
// Step 4: Inject localStorage via initScript (deferred until page load at each origin)
|
|
318
|
+
const origins = (storageState.origins ?? []).filter(o => o.localStorage?.length > 0);
|
|
319
|
+
if (origins.length > 0) {
|
|
320
|
+
const chunks = origins.map(o => {
|
|
321
|
+
const kv = o.localStorage.map(item => `localStorage.setItem(${JSON.stringify(item.name)},${JSON.stringify(item.value)})`).join(';');
|
|
322
|
+
return `if(location.origin===${JSON.stringify(o.origin)}){${kv}}`;
|
|
323
|
+
});
|
|
324
|
+
try {
|
|
325
|
+
await manager.addInitScript(newId, chunks.join(';'));
|
|
326
|
+
}
|
|
327
|
+
catch { /* non-fatal */ }
|
|
328
|
+
}
|
|
329
|
+
getLogger()?.write({
|
|
330
|
+
session_id: newId,
|
|
331
|
+
action_id: 'act_' + crypto_1.default.randomBytes(6).toString('hex'),
|
|
332
|
+
type: 'session',
|
|
333
|
+
action: 'adopt',
|
|
334
|
+
params: { source_cdp_url: cdp_url, profile: targetProfile },
|
|
335
|
+
result: { cookies_injected: cookies.length, origins_pending: origins.length },
|
|
336
|
+
});
|
|
337
|
+
return reply.code(201).send({
|
|
338
|
+
session_id: newId,
|
|
339
|
+
profile: targetProfile,
|
|
340
|
+
channel: 'chromium',
|
|
341
|
+
source_cdp_url: cdp_url,
|
|
342
|
+
cookies_injected: cookies.length,
|
|
343
|
+
origins_pending: origins.length,
|
|
344
|
+
note: 'Source browser untouched — state extracted read-only. New managed session ready.',
|
|
345
|
+
...(cookieWarning ? { warning: cookieWarning } : {}),
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// R10-T03: session fork — clone state from live session into new session
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
server.post('/api/v1/sessions/:id/fork', async (req, reply) => {
|
|
352
|
+
const live = registry.getLive(req.params.id);
|
|
353
|
+
if ('notFound' in live)
|
|
354
|
+
return reply.code(404).send({ error: `Session not found: ${req.params.id}` });
|
|
355
|
+
if ('zombie' in live)
|
|
356
|
+
return reply.code(410).send({ error: 'Session browser is not running', state: 'zombie' });
|
|
357
|
+
const manager = server.browserManager;
|
|
358
|
+
if (!manager)
|
|
359
|
+
return reply.code(503).send({ error: 'Browser manager not initialized' });
|
|
360
|
+
const { channel, profile: targetProfile, headed } = req.body ?? {};
|
|
361
|
+
const headless = !headed;
|
|
362
|
+
const VALID_CHANNELS = ['chromium', 'chrome', 'msedge'];
|
|
363
|
+
if (channel && !VALID_CHANNELS.includes(channel)) {
|
|
364
|
+
return reply.code(400).send({ error: `Invalid channel '${channel}'. Valid: ${VALID_CHANNELS.join(', ')}` });
|
|
365
|
+
}
|
|
366
|
+
// Step 1: Export state from source session
|
|
367
|
+
let storageState;
|
|
368
|
+
try {
|
|
369
|
+
storageState = await manager.getStorageState(req.params.id);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
return reply.code(500).send({ error: `Failed to export storage state: ${err.message}` });
|
|
373
|
+
}
|
|
374
|
+
// Step 2: Create and launch forked session
|
|
375
|
+
const resolvedProfile = targetProfile ?? live.profile;
|
|
376
|
+
// 'chromium' is default channel → pass undefined so launchSession uses default
|
|
377
|
+
const resolvedChannel = (channel && channel !== 'chromium') ? channel : undefined;
|
|
378
|
+
const newId = registry.create({
|
|
379
|
+
profile: resolvedProfile,
|
|
380
|
+
headless,
|
|
381
|
+
browserChannel: resolvedChannel,
|
|
382
|
+
launchMode: 'managed',
|
|
383
|
+
});
|
|
384
|
+
try {
|
|
385
|
+
await manager.launchSession(newId, {
|
|
386
|
+
profile: resolvedProfile,
|
|
387
|
+
headless,
|
|
388
|
+
channel: resolvedChannel,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
await registry.close(newId);
|
|
393
|
+
return reply.code(500).send({ error: `Failed to launch fork session: ${err.message}` });
|
|
394
|
+
}
|
|
395
|
+
// Step 3: Inject cookies (immediate)
|
|
396
|
+
const cookies = storageState.cookies ?? [];
|
|
397
|
+
let cookieWarning;
|
|
398
|
+
try {
|
|
399
|
+
if (cookies.length > 0)
|
|
400
|
+
await manager.addCookies(newId, cookies);
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
cookieWarning = `Cookie injection failed: ${err.message}`;
|
|
404
|
+
}
|
|
405
|
+
// Step 4: Inject localStorage via initScript (runs on each subsequent page load at each origin)
|
|
406
|
+
const origins = (storageState.origins ?? []).filter(o => o.localStorage?.length > 0);
|
|
407
|
+
if (origins.length > 0) {
|
|
408
|
+
const chunks = origins.map(o => {
|
|
409
|
+
const kv = o.localStorage.map(item => `localStorage.setItem(${JSON.stringify(item.name)},${JSON.stringify(item.value)})`).join(';');
|
|
410
|
+
return `if(location.origin===${JSON.stringify(o.origin)}){${kv}}`;
|
|
411
|
+
});
|
|
412
|
+
try {
|
|
413
|
+
await manager.addInitScript(newId, chunks.join(';'));
|
|
414
|
+
}
|
|
415
|
+
catch { /* non-fatal */ }
|
|
416
|
+
}
|
|
417
|
+
getLogger()?.write({
|
|
418
|
+
session_id: newId,
|
|
419
|
+
action_id: 'act_' + crypto_1.default.randomBytes(6).toString('hex'),
|
|
420
|
+
type: 'session',
|
|
421
|
+
action: 'fork',
|
|
422
|
+
params: { source_session_id: req.params.id, channel: channel ?? 'chromium', profile: resolvedProfile },
|
|
423
|
+
result: { cookies_injected: cookies.length, origins_pending: origins.length },
|
|
424
|
+
});
|
|
425
|
+
return reply.code(201).send({
|
|
426
|
+
session_id: newId,
|
|
427
|
+
profile: resolvedProfile,
|
|
428
|
+
channel: channel ?? 'chromium',
|
|
429
|
+
source_session_id: req.params.id,
|
|
430
|
+
cookies_injected: cookies.length,
|
|
431
|
+
origins_pending: origins.length,
|
|
432
|
+
...(cookieWarning ? { warning: cookieWarning } : {}),
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// R10-T04: switch-engine — hot-swap Chromium ↔ Chrome/Edge with state transfer
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
server.put('/api/v1/sessions/:id/switch-engine', async (req, reply) => {
|
|
439
|
+
const live = registry.getLive(req.params.id);
|
|
440
|
+
if ('notFound' in live)
|
|
441
|
+
return reply.code(404).send({ error: `Session not found: ${req.params.id}` });
|
|
442
|
+
if ('zombie' in live)
|
|
443
|
+
return reply.code(410).send({ error: 'Session browser is not running', state: 'zombie' });
|
|
444
|
+
const manager = server.browserManager;
|
|
445
|
+
if (!manager)
|
|
446
|
+
return reply.code(503).send({ error: 'Browser manager not initialized' });
|
|
447
|
+
const { target_channel, keep_source = false, headed = false } = req.body ?? {};
|
|
448
|
+
const headless = !headed;
|
|
449
|
+
const VALID_CHANNELS = ['chromium', 'chrome', 'msedge'];
|
|
450
|
+
if (!target_channel)
|
|
451
|
+
return reply.code(400).send({ error: 'target_channel is required' });
|
|
452
|
+
if (!VALID_CHANNELS.includes(target_channel)) {
|
|
453
|
+
return reply.code(400).send({ error: `Invalid target_channel '${target_channel}'. Valid: ${VALID_CHANNELS.join(', ')}` });
|
|
454
|
+
}
|
|
455
|
+
const sourceSession = registry.get(req.params.id);
|
|
456
|
+
const oldChannel = sourceSession.browserChannel ?? 'chromium';
|
|
457
|
+
// Step 1: Export state from source session (rollback-safe: source untouched until new session launches)
|
|
458
|
+
let storageState;
|
|
459
|
+
try {
|
|
460
|
+
storageState = await manager.getStorageState(req.params.id);
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
return reply.code(500).send({ error: `Failed to export storage state: ${err.message}` });
|
|
464
|
+
}
|
|
465
|
+
// Step 2: Create new session with target channel
|
|
466
|
+
const resolvedChannel = target_channel !== 'chromium' ? target_channel : undefined;
|
|
467
|
+
const newId = registry.create({
|
|
468
|
+
profile: sourceSession.profile,
|
|
469
|
+
headless,
|
|
470
|
+
browserChannel: resolvedChannel,
|
|
471
|
+
launchMode: 'managed',
|
|
472
|
+
});
|
|
473
|
+
try {
|
|
474
|
+
await manager.launchSession(newId, {
|
|
475
|
+
profile: sourceSession.profile,
|
|
476
|
+
headless,
|
|
477
|
+
channel: resolvedChannel,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
// Rollback: clean up temp session, source session untouched
|
|
482
|
+
await registry.close(newId);
|
|
483
|
+
return reply.code(502).send({ error: `Target engine failed to start: ${err.message}`, old_channel: oldChannel, new_channel: target_channel });
|
|
484
|
+
}
|
|
485
|
+
// Step 3: Inject cookies into new session
|
|
486
|
+
const cookies = storageState.cookies ?? [];
|
|
487
|
+
let cookieWarning;
|
|
488
|
+
try {
|
|
489
|
+
if (cookies.length > 0)
|
|
490
|
+
await manager.addCookies(newId, cookies);
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
cookieWarning = `Cookie transfer failed: ${err.message}`;
|
|
494
|
+
}
|
|
495
|
+
// Step 4: Inject localStorage via initScript (deferred per origin)
|
|
496
|
+
const origins = (storageState.origins ?? []).filter(o => o.localStorage?.length > 0);
|
|
497
|
+
if (origins.length > 0) {
|
|
498
|
+
const chunks = origins.map(o => {
|
|
499
|
+
const kv = o.localStorage.map(item => `localStorage.setItem(${JSON.stringify(item.name)},${JSON.stringify(item.value)})`).join(';');
|
|
500
|
+
return `if(location.origin===${JSON.stringify(o.origin)}){${kv}}`;
|
|
501
|
+
});
|
|
502
|
+
try {
|
|
503
|
+
await manager.addInitScript(newId, chunks.join(';'));
|
|
504
|
+
}
|
|
505
|
+
catch { /* non-fatal */ }
|
|
506
|
+
}
|
|
507
|
+
// Step 5: Close source session if not keeping it
|
|
508
|
+
if (!keep_source) {
|
|
509
|
+
if (manager)
|
|
510
|
+
await manager.closeSession(req.params.id);
|
|
511
|
+
await registry.close(req.params.id);
|
|
512
|
+
}
|
|
513
|
+
getLogger()?.write({
|
|
514
|
+
session_id: newId,
|
|
515
|
+
action_id: 'act_' + crypto_1.default.randomBytes(6).toString('hex'),
|
|
516
|
+
type: 'session',
|
|
517
|
+
action: 'switch_engine',
|
|
518
|
+
params: { source_session_id: req.params.id, old_channel: oldChannel, new_channel: target_channel, keep_source },
|
|
519
|
+
result: { cookies_transferred: cookies.length, origins_transferred: origins.length },
|
|
520
|
+
});
|
|
521
|
+
return reply.code(200).send({
|
|
522
|
+
session_id: newId,
|
|
523
|
+
old_session_id: keep_source ? req.params.id : null,
|
|
524
|
+
old_channel: oldChannel,
|
|
525
|
+
new_channel: target_channel,
|
|
526
|
+
profile: sourceSession.profile,
|
|
527
|
+
headless,
|
|
528
|
+
cookies_transferred: cookies.length,
|
|
529
|
+
origins_transferred: origins.length,
|
|
530
|
+
keep_source,
|
|
531
|
+
...(cookieWarning ? { warning: cookieWarning } : {}),
|
|
532
|
+
});
|
|
533
|
+
});
|
|
226
534
|
// POST /api/v1/sessions/:id/seal — mark session as sealed (blocks DELETE)
|
|
227
535
|
server.post('/api/v1/sessions/:id/seal', async (req, reply) => {
|
|
228
536
|
const s = registry.get(req.params.id);
|
|
@@ -236,6 +544,19 @@ function registerSessionRoutes(server, registry) {
|
|
|
236
544
|
}
|
|
237
545
|
return { status: 'ok', session_id: req.params.id, sealed: true };
|
|
238
546
|
});
|
|
547
|
+
// POST /api/v1/sessions/:id/unseal — T06: remove seal so session can be deleted
|
|
548
|
+
server.post('/api/v1/sessions/:id/unseal', async (req, reply) => {
|
|
549
|
+
const s = registry.get(req.params.id);
|
|
550
|
+
if (!s)
|
|
551
|
+
return reply.code(404).send({ error: 'Not found' });
|
|
552
|
+
try {
|
|
553
|
+
registry.unseal(req.params.id);
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
return reply.code(400).send({ error: err.message });
|
|
557
|
+
}
|
|
558
|
+
return { status: 'ok', session_id: req.params.id, sealed: false };
|
|
559
|
+
});
|
|
239
560
|
// POST /api/v1/sessions/:id/mode — switch headless/headed
|
|
240
561
|
server.post('/api/v1/sessions/:id/mode', async (req, reply) => {
|
|
241
562
|
const s = registry.get(req.params.id);
|
|
@@ -649,41 +970,165 @@ function registerSessionRoutes(server, registry) {
|
|
|
649
970
|
};
|
|
650
971
|
});
|
|
651
972
|
// ---------------------------------------------------------------------------
|
|
652
|
-
//
|
|
973
|
+
// R10-T07: Runtime permission grant — browserContext.grantPermissions()
|
|
653
974
|
// ---------------------------------------------------------------------------
|
|
654
|
-
|
|
975
|
+
server.post('/api/v1/sessions/:id/grant-permission', async (req, reply) => {
|
|
976
|
+
const live = registry.getLive(req.params.id);
|
|
977
|
+
if ('notFound' in live)
|
|
978
|
+
return reply.code(404).send({ error: `Session not found: ${req.params.id}` });
|
|
979
|
+
if ('zombie' in live)
|
|
980
|
+
return reply.code(410).send({ error: 'Session browser is not running', state: 'zombie' });
|
|
981
|
+
const { permissions, origin } = req.body ?? {};
|
|
982
|
+
if (!Array.isArray(permissions) || permissions.length === 0) {
|
|
983
|
+
return reply.code(400).send({ error: 'permissions must be a non-empty array' });
|
|
984
|
+
}
|
|
985
|
+
const VALID_PERMISSIONS = new Set([
|
|
986
|
+
'camera', 'microphone', 'notifications', 'geolocation',
|
|
987
|
+
'clipboard-read', 'clipboard-write', 'accelerometer', 'background-sync',
|
|
988
|
+
'magnetometer', 'gyroscope', 'midi', 'payment-handler', 'persistent-storage',
|
|
989
|
+
]);
|
|
990
|
+
const invalid = permissions.filter(p => !VALID_PERMISSIONS.has(p));
|
|
991
|
+
if (invalid.length > 0) {
|
|
992
|
+
return reply.code(400).send({ error: `Unknown permission(s): ${invalid.join(', ')}` });
|
|
993
|
+
}
|
|
994
|
+
try {
|
|
995
|
+
const grantOpts = origin ? { origin } : {};
|
|
996
|
+
await live.context.grantPermissions(permissions, grantOpts);
|
|
997
|
+
return { status: 'ok', session_id: req.params.id, permissions, origin: origin ?? null };
|
|
998
|
+
}
|
|
999
|
+
catch (err) {
|
|
1000
|
+
return reply.code(500).send({ error: err.message });
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
// ---------------------------------------------------------------------------
|
|
1004
|
+
// R08-R14 / R10-T05: Profile lifecycle — list + reset + delete (with zone)
|
|
1005
|
+
// ---------------------------------------------------------------------------
|
|
1006
|
+
/**
|
|
1007
|
+
* zone 'managed' → profiles/ (Playwright-managed Chromium)
|
|
1008
|
+
* zone 'stable' → chrome-profiles/ (Chrome/Edge native browser)
|
|
1009
|
+
*/
|
|
1010
|
+
function getZoneDir(zone) {
|
|
655
1011
|
const dataDir = process.env.AGENTMB_DATA_DIR ?? path_1.default.join(os_1.default.homedir(), '.agentmb');
|
|
656
|
-
return
|
|
1012
|
+
return zone === 'stable'
|
|
1013
|
+
? path_1.default.join(dataDir, 'chrome-profiles')
|
|
1014
|
+
: path_1.default.join(dataDir, 'profiles');
|
|
657
1015
|
}
|
|
658
|
-
|
|
659
|
-
|
|
1016
|
+
/** Recursively sum file sizes in a directory. */
|
|
1017
|
+
async function dirSize(dirPath) {
|
|
1018
|
+
let total = 0;
|
|
660
1019
|
try {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1020
|
+
const items = await fs_1.default.promises.readdir(dirPath, { withFileTypes: true });
|
|
1021
|
+
await Promise.all(items.map(async (item) => {
|
|
1022
|
+
const fullPath = path_1.default.join(dirPath, item.name);
|
|
1023
|
+
if (item.isDirectory()) {
|
|
1024
|
+
total += await dirSize(fullPath);
|
|
1025
|
+
}
|
|
1026
|
+
else if (item.isFile()) {
|
|
1027
|
+
try {
|
|
1028
|
+
total += (await fs_1.default.promises.stat(fullPath)).size;
|
|
1029
|
+
}
|
|
1030
|
+
catch { /* ignore */ }
|
|
672
1031
|
}
|
|
673
|
-
catch { /* ignore */ }
|
|
674
|
-
return { name: e.name, path: profilePath, last_used };
|
|
675
1032
|
}));
|
|
1033
|
+
}
|
|
1034
|
+
catch { /* ignore */ }
|
|
1035
|
+
return total;
|
|
1036
|
+
}
|
|
1037
|
+
/** Scan one zone directory and return enriched profile entries. */
|
|
1038
|
+
async function listZoneProfiles(zone) {
|
|
1039
|
+
const dir = getZoneDir(zone);
|
|
1040
|
+
if (!fs_1.default.existsSync(dir))
|
|
1041
|
+
return [];
|
|
1042
|
+
const entries = await fs_1.default.promises.readdir(dir, { withFileTypes: true });
|
|
1043
|
+
return Promise.all(entries
|
|
1044
|
+
.filter(e => e.isDirectory())
|
|
1045
|
+
.map(async (e) => {
|
|
1046
|
+
const profilePath = path_1.default.join(dir, e.name);
|
|
1047
|
+
let last_modified = null;
|
|
1048
|
+
try {
|
|
1049
|
+
const stat = await fs_1.default.promises.stat(profilePath);
|
|
1050
|
+
last_modified = stat.mtime.toISOString();
|
|
1051
|
+
}
|
|
1052
|
+
catch { /* ignore */ }
|
|
1053
|
+
const size_bytes = await dirSize(profilePath);
|
|
1054
|
+
const liveSessions = registry.list().filter(s => s.profile === e.name && s.state === 'live');
|
|
1055
|
+
return {
|
|
1056
|
+
zone,
|
|
1057
|
+
name: e.name,
|
|
1058
|
+
path: profilePath,
|
|
1059
|
+
size_bytes,
|
|
1060
|
+
sessions_live: liveSessions.length,
|
|
1061
|
+
session_ids: liveSessions.map(s => s.id),
|
|
1062
|
+
last_modified,
|
|
1063
|
+
};
|
|
1064
|
+
}));
|
|
1065
|
+
}
|
|
1066
|
+
// GET /api/v1/profiles[?zone=managed|stable] — list profiles with zone/size/session info
|
|
1067
|
+
server.get('/api/v1/profiles', async (req, reply) => {
|
|
1068
|
+
const { zone } = req.query ?? {};
|
|
1069
|
+
if (zone && zone !== 'managed' && zone !== 'stable') {
|
|
1070
|
+
return reply.code(400).send({ error: "zone must be 'managed' or 'stable'" });
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
let profiles = [];
|
|
1074
|
+
if (!zone || zone === 'managed')
|
|
1075
|
+
profiles = profiles.concat(await listZoneProfiles('managed'));
|
|
1076
|
+
if (!zone || zone === 'stable')
|
|
1077
|
+
profiles = profiles.concat(await listZoneProfiles('stable'));
|
|
676
1078
|
return { profiles, count: profiles.length };
|
|
677
1079
|
}
|
|
678
1080
|
catch (err) {
|
|
679
1081
|
return reply.code(500).send({ error: err.message });
|
|
680
1082
|
}
|
|
681
1083
|
});
|
|
1084
|
+
// DELETE /api/v1/profiles/:name[?zone=managed|stable][&force=true] — T05 profile delete
|
|
1085
|
+
server.delete('/api/v1/profiles/:name', async (req, reply) => {
|
|
1086
|
+
const { name } = req.params;
|
|
1087
|
+
const zone = (req.query.zone ?? 'managed');
|
|
1088
|
+
const force = req.query.force === 'true';
|
|
1089
|
+
if (!/^[\w\-]+$/.test(name))
|
|
1090
|
+
return reply.code(400).send({ error: 'Invalid profile name; only alphanumeric, dash, underscore allowed' });
|
|
1091
|
+
if (zone !== 'managed' && zone !== 'stable') {
|
|
1092
|
+
return reply.code(400).send({ error: "zone must be 'managed' or 'stable'" });
|
|
1093
|
+
}
|
|
1094
|
+
const dir = getZoneDir(zone);
|
|
1095
|
+
const profilePath = path_1.default.join(dir, name);
|
|
1096
|
+
// Safety: no path traversal
|
|
1097
|
+
if (!profilePath.startsWith(dir + path_1.default.sep))
|
|
1098
|
+
return reply.code(400).send({ error: 'Invalid profile name' });
|
|
1099
|
+
if (!fs_1.default.existsSync(profilePath)) {
|
|
1100
|
+
return reply.code(404).send({ error: `Profile '${name}' not found in zone '${zone}'` });
|
|
1101
|
+
}
|
|
1102
|
+
// Destruction protection: check for live sessions
|
|
1103
|
+
const liveSessions = registry.list().filter(s => s.profile === name && s.state === 'live');
|
|
1104
|
+
if (liveSessions.length > 0 && !force) {
|
|
1105
|
+
return reply.code(423).send({
|
|
1106
|
+
error: 'profile_locked',
|
|
1107
|
+
message: `Profile '${name}' has ${liveSessions.length} live session(s). Use --force to override.`,
|
|
1108
|
+
session_ids: liveSessions.map(s => s.id),
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
if (force && liveSessions.length > 0) {
|
|
1113
|
+
const manager = server.browserManager;
|
|
1114
|
+
for (const s of liveSessions) {
|
|
1115
|
+
if (manager)
|
|
1116
|
+
await manager.closeSession(s.id);
|
|
1117
|
+
await registry.close(s.id);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
await removeProfileDirWithRetry(profilePath);
|
|
1121
|
+
return reply.code(204).send();
|
|
1122
|
+
}
|
|
1123
|
+
catch (err) {
|
|
1124
|
+
return reply.code(500).send({ error: err.message });
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
682
1127
|
server.post('/api/v1/profiles/:name/reset', async (req, reply) => {
|
|
683
1128
|
const { name } = req.params;
|
|
684
1129
|
if (!/^[\w\-]+$/.test(name))
|
|
685
1130
|
return reply.code(400).send({ error: 'Invalid profile name; only alphanumeric, dash, underscore allowed' });
|
|
686
|
-
const dir =
|
|
1131
|
+
const dir = getZoneDir('managed');
|
|
687
1132
|
const profilePath = path_1.default.join(dir, name);
|
|
688
1133
|
// Safety: ensure profilePath is inside dir (no path traversal)
|
|
689
1134
|
if (!profilePath.startsWith(dir + path_1.default.sep))
|
|
@@ -708,5 +1153,110 @@ function registerSessionRoutes(server, registry) {
|
|
|
708
1153
|
return reply.code(500).send({ error: err.message });
|
|
709
1154
|
}
|
|
710
1155
|
});
|
|
1156
|
+
async function scanDir(dirPath, depth) {
|
|
1157
|
+
const entries = [];
|
|
1158
|
+
let items;
|
|
1159
|
+
try {
|
|
1160
|
+
items = await fs_1.default.promises.readdir(dirPath, { withFileTypes: true });
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
return [];
|
|
1164
|
+
}
|
|
1165
|
+
for (const item of items) {
|
|
1166
|
+
const fullPath = path_1.default.join(dirPath, item.name);
|
|
1167
|
+
if (item.isDirectory()) {
|
|
1168
|
+
const entry = { name: item.name, type: 'dir', path: fullPath };
|
|
1169
|
+
if (depth > 1)
|
|
1170
|
+
entry.children = await scanDir(fullPath, depth - 1);
|
|
1171
|
+
entries.push(entry);
|
|
1172
|
+
}
|
|
1173
|
+
else if (item.isFile()) {
|
|
1174
|
+
let size;
|
|
1175
|
+
try {
|
|
1176
|
+
size = (await fs_1.default.promises.stat(fullPath)).size;
|
|
1177
|
+
}
|
|
1178
|
+
catch { /* ignore */ }
|
|
1179
|
+
entries.push({ name: item.name, type: 'file', path: fullPath, size });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return entries;
|
|
1183
|
+
}
|
|
1184
|
+
/** Shared ls handler — used by both GET and POST endpoints. */
|
|
1185
|
+
async function handleLs(bm, session_id, reqPath, depthStr, reply) {
|
|
1186
|
+
if (!bm)
|
|
1187
|
+
return reply.code(503).send({ error: 'Browser manager not initialized' });
|
|
1188
|
+
if (!session_id)
|
|
1189
|
+
return reply.code(400).send({ error: 'session_id is required' });
|
|
1190
|
+
if (!reqPath)
|
|
1191
|
+
return reply.code(400).send({ error: 'path is required' });
|
|
1192
|
+
const s = registry.get(session_id);
|
|
1193
|
+
if (!s)
|
|
1194
|
+
return reply.code(404).send({ error: `Session ${session_id} not found` });
|
|
1195
|
+
const allowDirs = bm.getAllowDirs(session_id);
|
|
1196
|
+
if (allowDirs.length === 0) {
|
|
1197
|
+
return reply.code(403).send({ error: 'No allowed directories for this session. Set allow_dirs when creating session.' });
|
|
1198
|
+
}
|
|
1199
|
+
// R09-C07-P0: resolve symlinks via realpath to prevent symlink traversal attacks.
|
|
1200
|
+
// path.resolve() only does string manipulation; fs.realpath follows symlinks on disk.
|
|
1201
|
+
let abs;
|
|
1202
|
+
try {
|
|
1203
|
+
abs = await fs_1.default.promises.realpath(reqPath);
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
return reply.code(404).send({ error: `Path ${reqPath} does not exist or is not accessible.` });
|
|
1207
|
+
}
|
|
1208
|
+
const allowed = allowDirs.some(d => abs === d || abs.startsWith(d + path_1.default.sep));
|
|
1209
|
+
if (!allowed) {
|
|
1210
|
+
return reply.code(403).send({ error: `Path ${reqPath} is not within allowed directories.` });
|
|
1211
|
+
}
|
|
1212
|
+
const depth = Math.min(parseInt(depthStr ?? '1', 10) || 1, 5);
|
|
1213
|
+
const entries = await scanDir(abs, depth);
|
|
1214
|
+
return { path: abs, entries, session_id };
|
|
1215
|
+
}
|
|
1216
|
+
// GET variant (query params — ASCII paths, backward compatible)
|
|
1217
|
+
server.get('/api/v1/utils/ls', async (req, reply) => {
|
|
1218
|
+
const { session_id, path: reqPath, depth: depthStr } = req.query;
|
|
1219
|
+
return handleLs(server.browserManager, session_id, reqPath, depthStr, reply);
|
|
1220
|
+
});
|
|
1221
|
+
// POST variant (JSON body — supports non-ASCII / Unicode paths, R09-C06-P2)
|
|
1222
|
+
server.post('/api/v1/utils/ls', async (req, reply) => {
|
|
1223
|
+
const { session_id, path: reqPath, depth } = req.body ?? {};
|
|
1224
|
+
return handleLs(server.browserManager, session_id, reqPath, depth !== undefined ? String(depth) : undefined, reply);
|
|
1225
|
+
});
|
|
1226
|
+
// ---------------------------------------------------------------------------
|
|
1227
|
+
// R09-C04-T08: Video recording endpoints
|
|
1228
|
+
// ---------------------------------------------------------------------------
|
|
1229
|
+
server.get('/api/v1/sessions/:id/video', async (req, reply) => {
|
|
1230
|
+
const s = registry.get(req.params.id);
|
|
1231
|
+
if (!s)
|
|
1232
|
+
return reply.code(404).send({ error: 'Not found' });
|
|
1233
|
+
const manager = server.browserManager;
|
|
1234
|
+
if (!manager)
|
|
1235
|
+
return reply.code(503).send({ error: 'Browser manager not initialized' });
|
|
1236
|
+
const videoPath = await manager.getVideoPath(req.params.id);
|
|
1237
|
+
return { session_id: req.params.id, video_path: videoPath };
|
|
1238
|
+
});
|
|
1239
|
+
server.post('/api/v1/sessions/:id/video/save', async (req, reply) => {
|
|
1240
|
+
const s = registry.get(req.params.id);
|
|
1241
|
+
if (!s)
|
|
1242
|
+
return reply.code(404).send({ error: 'Not found' });
|
|
1243
|
+
const manager = server.browserManager;
|
|
1244
|
+
if (!manager)
|
|
1245
|
+
return reply.code(503).send({ error: 'Browser manager not initialized' });
|
|
1246
|
+
const videoPath = await manager.getVideoPath(req.params.id);
|
|
1247
|
+
if (!videoPath)
|
|
1248
|
+
return reply.code(404).send({ error: 'No video available for this session. Ensure record_video=true was set on session creation.' });
|
|
1249
|
+
const { dest_path } = req.body ?? {};
|
|
1250
|
+
if (!dest_path)
|
|
1251
|
+
return reply.code(400).send({ error: 'dest_path is required' });
|
|
1252
|
+
try {
|
|
1253
|
+
await fs_1.default.promises.mkdir(path_1.default.dirname(dest_path), { recursive: true });
|
|
1254
|
+
await fs_1.default.promises.copyFile(videoPath, dest_path);
|
|
1255
|
+
return { session_id: req.params.id, saved_to: dest_path, source: videoPath };
|
|
1256
|
+
}
|
|
1257
|
+
catch (err) {
|
|
1258
|
+
return reply.code(500).send({ error: err.message });
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
711
1261
|
}
|
|
712
1262
|
//# sourceMappingURL=sessions.js.map
|