agentmb 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +220 -6
  2. package/dist/browser/actions.d.ts +14 -0
  3. package/dist/browser/actions.d.ts.map +1 -1
  4. package/dist/browser/actions.js +55 -1
  5. package/dist/browser/actions.js.map +1 -1
  6. package/dist/browser/manager.d.ts +2 -1
  7. package/dist/browser/manager.d.ts.map +1 -1
  8. package/dist/browser/manager.js +127 -31
  9. package/dist/browser/manager.js.map +1 -1
  10. package/dist/cli/client.d.ts +5 -0
  11. package/dist/cli/client.d.ts.map +1 -1
  12. package/dist/cli/client.js +20 -0
  13. package/dist/cli/client.js.map +1 -1
  14. package/dist/cli/commands/actions.d.ts.map +1 -1
  15. package/dist/cli/commands/actions.js +40 -7
  16. package/dist/cli/commands/actions.js.map +1 -1
  17. package/dist/cli/commands/browser-launch.d.ts.map +1 -1
  18. package/dist/cli/commands/browser-launch.js +54 -5
  19. package/dist/cli/commands/browser-launch.js.map +1 -1
  20. package/dist/cli/commands/profile.d.ts +10 -0
  21. package/dist/cli/commands/profile.d.ts.map +1 -0
  22. package/dist/cli/commands/profile.js +86 -0
  23. package/dist/cli/commands/profile.js.map +1 -0
  24. package/dist/cli/commands/session.d.ts.map +1 -1
  25. package/dist/cli/commands/session.js +166 -4
  26. package/dist/cli/commands/session.js.map +1 -1
  27. package/dist/cli/index.js +3 -1
  28. package/dist/cli/index.js.map +1 -1
  29. package/dist/daemon/config.d.ts +2 -0
  30. package/dist/daemon/config.d.ts.map +1 -1
  31. package/dist/daemon/config.js +5 -0
  32. package/dist/daemon/config.js.map +1 -1
  33. package/dist/daemon/routes/actions.d.ts.map +1 -1
  34. package/dist/daemon/routes/actions.js +45 -2
  35. package/dist/daemon/routes/actions.js.map +1 -1
  36. package/dist/daemon/routes/sessions.d.ts.map +1 -1
  37. package/dist/daemon/routes/sessions.js +518 -37
  38. package/dist/daemon/routes/sessions.js.map +1 -1
  39. package/dist/daemon/server.d.ts.map +1 -1
  40. package/dist/daemon/server.js +1 -0
  41. package/dist/daemon/server.js.map +1 -1
  42. package/dist/daemon/session.d.ts +5 -0
  43. package/dist/daemon/session.d.ts.map +1 -1
  44. package/dist/daemon/session.js +9 -0
  45. package/dist/daemon/session.js.map +1 -1
  46. package/package.json +1 -1
  47. package/skills/agentmb/SKILL.md +112 -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, proxy_url, record_video, allow_dirs, } = 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') {
@@ -116,6 +140,7 @@ function registerSessionRoutes(server, registry) {
116
140
  profile, headless, acceptDownloads: accept_downloads,
117
141
  channel: browser_channel, executablePath: executable_path, ephemeral,
118
142
  proxyUrl: proxy_url, recordVideo: record_video, allowDirs: allow_dirs,
143
+ allowExtensions: allow_extensions,
119
144
  });
120
145
  }
121
146
  }
@@ -134,23 +159,52 @@ function registerSessionRoutes(server, registry) {
134
159
  ephemeral: s.ephemeral ?? false,
135
160
  browser_channel: s.browserChannel ?? null,
136
161
  launch_mode: s.launchMode ?? 'managed',
162
+ allow_extensions: s.allowExtensions ?? false,
137
163
  });
138
164
  });
139
165
  // GET /api/v1/sessions — list (normalized to snake_case for SDK)
140
166
  server.get('/api/v1/sessions', async () => {
141
- return registry.list().map((s) => ({
142
- session_id: s.id,
143
- profile: s.profile,
144
- headless: s.headless,
145
- created_at: s.createdAt,
146
- state: s.state,
147
- agent_id: s.agentId ?? null,
148
- ephemeral: s.ephemeral ?? false,
149
- browser_channel: s.browserChannel ?? null,
150
- launch_mode: s.launchMode ?? 'managed',
151
- cdp_url: s.cdpUrl ?? null,
152
- sealed: s.sealed ?? false,
153
- }));
167
+ return registry.list().map((s) => {
168
+ // R10-C07: infer zone from browserChannel (Issue #15)
169
+ const zone = (s.browserChannel === 'chrome' || s.browserChannel === 'msedge') ? 'stable' : 'managed';
170
+ return {
171
+ session_id: s.id,
172
+ profile: s.profile,
173
+ headless: s.headless,
174
+ created_at: s.createdAt,
175
+ state: s.state,
176
+ zone,
177
+ agent_id: s.agentId ?? null,
178
+ ephemeral: s.ephemeral ?? false,
179
+ browser_channel: s.browserChannel ?? null,
180
+ launch_mode: s.launchMode ?? 'managed',
181
+ cdp_url: s.cdpUrl ?? null,
182
+ sealed: s.sealed ?? false,
183
+ };
184
+ });
185
+ });
186
+ // DELETE /api/v1/sessions?state=zombie — bulk prune zombie sessions (Issue #14)
187
+ server.delete('/api/v1/sessions', async (req, reply) => {
188
+ if (req.query.state !== 'zombie') {
189
+ return reply.code(400).send({ error: "state query param must be 'zombie'" });
190
+ }
191
+ const dryRun = req.query.dry_run === 'true';
192
+ const olderThanDays = req.query.older_than_days ? parseInt(req.query.older_than_days, 10) : undefined;
193
+ const candidates = registry.list().filter((s) => {
194
+ if (s.state !== 'zombie')
195
+ return false;
196
+ if (olderThanDays !== undefined) {
197
+ const ageMs = Date.now() - new Date(s.createdAt).getTime();
198
+ return ageMs > olderThanDays * 24 * 60 * 60 * 1000;
199
+ }
200
+ return true;
201
+ });
202
+ if (!dryRun) {
203
+ for (const s of candidates) {
204
+ await registry.close(s.id);
205
+ }
206
+ }
207
+ return { pruned: candidates.length, ids: candidates.map((s) => s.id), dry_run: dryRun };
154
208
  });
155
209
  // GET /api/v1/sessions/:id
156
210
  server.get('/api/v1/sessions/:id', async (req, reply) => {
@@ -176,8 +230,9 @@ function registerSessionRoutes(server, registry) {
176
230
  const s = registry.get(req.params.id);
177
231
  if (!s)
178
232
  return reply.code(404).send({ error: 'Not found' });
179
- if (s.sealed) {
180
- return reply.code(423).send({ error: 'session_sealed', message: 'Session is sealed and cannot be deleted. Use seal=false if you need to unseal.' });
233
+ const force = req.query.force === 'true';
234
+ if (s.sealed && !force) {
235
+ return reply.code(423).send({ error: 'session_sealed', message: 'Session is sealed and cannot be deleted. Use ?force=true or POST /unseal first.' });
181
236
  }
182
237
  // Clean up BrowserManager internal state first, then registry
183
238
  const manager = server.browserManager;
@@ -224,6 +279,286 @@ function registerSessionRoutes(server, registry) {
224
279
  warning: 'close will disconnect only; remote browser process is not terminated',
225
280
  };
226
281
  });
282
+ // ---------------------------------------------------------------------------
283
+ // R10-T03: session adopt — extract CDP browser state → new managed session
284
+ // NOTE: register /sessions/adopt BEFORE /sessions/:id/* so Fastify matches static first
285
+ // ---------------------------------------------------------------------------
286
+ server.post('/api/v1/sessions/adopt', async (req, reply) => {
287
+ const manager = server.browserManager;
288
+ if (!manager)
289
+ return reply.code(503).send({ error: 'Browser manager not initialized' });
290
+ const { cdp_url, profile: targetProfile, headed } = req.body ?? {};
291
+ if (!cdp_url)
292
+ return reply.code(400).send({ error: 'cdp_url is required' });
293
+ if (!cdp_url.startsWith('http://') && !cdp_url.startsWith('https://') && !cdp_url.startsWith('ws://') && !cdp_url.startsWith('wss://')) {
294
+ return reply.code(400).send({ error: 'cdp_url must be http/https/ws/wss' });
295
+ }
296
+ if (!targetProfile)
297
+ return reply.code(400).send({ error: 'profile is required' });
298
+ const headless = !headed;
299
+ // Step 1: NON-INVASIVELY extract state from external browser via CDP
300
+ const { chromium: chromiumPw } = await Promise.resolve().then(() => __importStar(require('playwright-core')));
301
+ let storageState;
302
+ let cdpBrowser;
303
+ try {
304
+ cdpBrowser = await chromiumPw.connectOverCDP(cdp_url);
305
+ const contexts = cdpBrowser.contexts();
306
+ if (contexts.length === 0) {
307
+ await cdpBrowser.close();
308
+ return reply.code(422).send({ error: 'No browser context found at the CDP URL. Make sure the browser is open with --remote-debugging-port.' });
309
+ }
310
+ storageState = await contexts[0].storageState();
311
+ }
312
+ catch (err) {
313
+ try {
314
+ await cdpBrowser?.close();
315
+ }
316
+ catch { /* ignore */ }
317
+ return reply.code(502).send({ error: `Failed to connect/extract from CDP: ${err.message}` });
318
+ }
319
+ finally {
320
+ // Disconnect WITHOUT closing remote browser
321
+ try {
322
+ await cdpBrowser?.close();
323
+ }
324
+ catch { /* ignore */ }
325
+ }
326
+ // Step 2: Create and launch new managed Chromium session
327
+ const newId = registry.create({ profile: targetProfile, headless, launchMode: 'managed' });
328
+ try {
329
+ await manager.launchSession(newId, { profile: targetProfile, headless });
330
+ }
331
+ catch (err) {
332
+ await registry.close(newId);
333
+ return reply.code(500).send({ error: `Failed to launch session: ${err.message}` });
334
+ }
335
+ // Step 3: Inject cookies (immediate)
336
+ const cookies = storageState.cookies ?? [];
337
+ let cookieWarning;
338
+ try {
339
+ if (cookies.length > 0)
340
+ await manager.addCookies(newId, cookies);
341
+ }
342
+ catch (err) {
343
+ cookieWarning = `Cookie injection failed: ${err.message}`;
344
+ }
345
+ // Step 4: Inject localStorage via initScript (deferred until page load at each origin)
346
+ const origins = (storageState.origins ?? []).filter(o => o.localStorage?.length > 0);
347
+ if (origins.length > 0) {
348
+ const chunks = origins.map(o => {
349
+ const kv = o.localStorage.map(item => `localStorage.setItem(${JSON.stringify(item.name)},${JSON.stringify(item.value)})`).join(';');
350
+ return `if(location.origin===${JSON.stringify(o.origin)}){${kv}}`;
351
+ });
352
+ try {
353
+ await manager.addInitScript(newId, chunks.join(';'));
354
+ }
355
+ catch { /* non-fatal */ }
356
+ }
357
+ getLogger()?.write({
358
+ session_id: newId,
359
+ action_id: 'act_' + crypto_1.default.randomBytes(6).toString('hex'),
360
+ type: 'session',
361
+ action: 'adopt',
362
+ params: { source_cdp_url: cdp_url, profile: targetProfile },
363
+ result: { cookies_injected: cookies.length, origins_pending: origins.length },
364
+ });
365
+ return reply.code(201).send({
366
+ session_id: newId,
367
+ profile: targetProfile,
368
+ channel: 'chromium',
369
+ source_cdp_url: cdp_url,
370
+ cookies_injected: cookies.length,
371
+ origins_pending: origins.length,
372
+ note: 'Source browser untouched — state extracted read-only. New managed session ready.',
373
+ ...(cookieWarning ? { warning: cookieWarning } : {}),
374
+ });
375
+ });
376
+ // ---------------------------------------------------------------------------
377
+ // R10-T03: session fork — clone state from live session into new session
378
+ // ---------------------------------------------------------------------------
379
+ server.post('/api/v1/sessions/:id/fork', async (req, reply) => {
380
+ const live = registry.getLive(req.params.id);
381
+ if ('notFound' in live)
382
+ return reply.code(404).send({ error: `Session not found: ${req.params.id}` });
383
+ if ('zombie' in live)
384
+ return reply.code(410).send({ error: 'Session browser is not running', state: 'zombie' });
385
+ const manager = server.browserManager;
386
+ if (!manager)
387
+ return reply.code(503).send({ error: 'Browser manager not initialized' });
388
+ const { channel, profile: targetProfile, headed } = req.body ?? {};
389
+ const headless = !headed;
390
+ const VALID_CHANNELS = ['chromium', 'chrome', 'msedge'];
391
+ if (channel && !VALID_CHANNELS.includes(channel)) {
392
+ return reply.code(400).send({ error: `Invalid channel '${channel}'. Valid: ${VALID_CHANNELS.join(', ')}` });
393
+ }
394
+ // Step 1: Export state from source session
395
+ let storageState;
396
+ try {
397
+ storageState = await manager.getStorageState(req.params.id);
398
+ }
399
+ catch (err) {
400
+ return reply.code(500).send({ error: `Failed to export storage state: ${err.message}` });
401
+ }
402
+ // Step 2: Create and launch forked session
403
+ const resolvedProfile = targetProfile ?? live.profile;
404
+ // 'chromium' is default channel → pass undefined so launchSession uses default
405
+ const resolvedChannel = (channel && channel !== 'chromium') ? channel : undefined;
406
+ const newId = registry.create({
407
+ profile: resolvedProfile,
408
+ headless,
409
+ browserChannel: resolvedChannel,
410
+ launchMode: 'managed',
411
+ });
412
+ try {
413
+ await manager.launchSession(newId, {
414
+ profile: resolvedProfile,
415
+ headless,
416
+ channel: resolvedChannel,
417
+ });
418
+ }
419
+ catch (err) {
420
+ await registry.close(newId);
421
+ return reply.code(500).send({ error: `Failed to launch fork session: ${err.message}` });
422
+ }
423
+ // Step 3: Inject cookies (immediate)
424
+ const cookies = storageState.cookies ?? [];
425
+ let cookieWarning;
426
+ try {
427
+ if (cookies.length > 0)
428
+ await manager.addCookies(newId, cookies);
429
+ }
430
+ catch (err) {
431
+ cookieWarning = `Cookie injection failed: ${err.message}`;
432
+ }
433
+ // Step 4: Inject localStorage via initScript (runs on each subsequent page load at each origin)
434
+ const origins = (storageState.origins ?? []).filter(o => o.localStorage?.length > 0);
435
+ if (origins.length > 0) {
436
+ const chunks = origins.map(o => {
437
+ const kv = o.localStorage.map(item => `localStorage.setItem(${JSON.stringify(item.name)},${JSON.stringify(item.value)})`).join(';');
438
+ return `if(location.origin===${JSON.stringify(o.origin)}){${kv}}`;
439
+ });
440
+ try {
441
+ await manager.addInitScript(newId, chunks.join(';'));
442
+ }
443
+ catch { /* non-fatal */ }
444
+ }
445
+ getLogger()?.write({
446
+ session_id: newId,
447
+ action_id: 'act_' + crypto_1.default.randomBytes(6).toString('hex'),
448
+ type: 'session',
449
+ action: 'fork',
450
+ params: { source_session_id: req.params.id, channel: channel ?? 'chromium', profile: resolvedProfile },
451
+ result: { cookies_injected: cookies.length, origins_pending: origins.length },
452
+ });
453
+ return reply.code(201).send({
454
+ session_id: newId,
455
+ profile: resolvedProfile,
456
+ channel: channel ?? 'chromium',
457
+ source_session_id: req.params.id,
458
+ cookies_injected: cookies.length,
459
+ origins_pending: origins.length,
460
+ ...(cookieWarning ? { warning: cookieWarning } : {}),
461
+ });
462
+ });
463
+ // ---------------------------------------------------------------------------
464
+ // R10-T04: switch-engine — hot-swap Chromium ↔ Chrome/Edge with state transfer
465
+ // ---------------------------------------------------------------------------
466
+ server.put('/api/v1/sessions/:id/switch-engine', async (req, reply) => {
467
+ const live = registry.getLive(req.params.id);
468
+ if ('notFound' in live)
469
+ return reply.code(404).send({ error: `Session not found: ${req.params.id}` });
470
+ if ('zombie' in live)
471
+ return reply.code(410).send({ error: 'Session browser is not running', state: 'zombie' });
472
+ const manager = server.browserManager;
473
+ if (!manager)
474
+ return reply.code(503).send({ error: 'Browser manager not initialized' });
475
+ const { target_channel, keep_source = false, headed = false } = req.body ?? {};
476
+ const headless = !headed;
477
+ const VALID_CHANNELS = ['chromium', 'chrome', 'msedge'];
478
+ if (!target_channel)
479
+ return reply.code(400).send({ error: 'target_channel is required' });
480
+ if (!VALID_CHANNELS.includes(target_channel)) {
481
+ return reply.code(400).send({ error: `Invalid target_channel '${target_channel}'. Valid: ${VALID_CHANNELS.join(', ')}` });
482
+ }
483
+ const sourceSession = registry.get(req.params.id);
484
+ const oldChannel = sourceSession.browserChannel ?? 'chromium';
485
+ // Step 1: Export state from source session (rollback-safe: source untouched until new session launches)
486
+ let storageState;
487
+ try {
488
+ storageState = await manager.getStorageState(req.params.id);
489
+ }
490
+ catch (err) {
491
+ return reply.code(500).send({ error: `Failed to export storage state: ${err.message}` });
492
+ }
493
+ // Step 2: Create new session with target channel
494
+ const resolvedChannel = target_channel !== 'chromium' ? target_channel : undefined;
495
+ const newId = registry.create({
496
+ profile: sourceSession.profile,
497
+ headless,
498
+ browserChannel: resolvedChannel,
499
+ launchMode: 'managed',
500
+ });
501
+ try {
502
+ await manager.launchSession(newId, {
503
+ profile: sourceSession.profile,
504
+ headless,
505
+ channel: resolvedChannel,
506
+ });
507
+ }
508
+ catch (err) {
509
+ // Rollback: clean up temp session, source session untouched
510
+ await registry.close(newId);
511
+ return reply.code(502).send({ error: `Target engine failed to start: ${err.message}`, old_channel: oldChannel, new_channel: target_channel });
512
+ }
513
+ // Step 3: Inject cookies into new session
514
+ const cookies = storageState.cookies ?? [];
515
+ let cookieWarning;
516
+ try {
517
+ if (cookies.length > 0)
518
+ await manager.addCookies(newId, cookies);
519
+ }
520
+ catch (err) {
521
+ cookieWarning = `Cookie transfer failed: ${err.message}`;
522
+ }
523
+ // Step 4: Inject localStorage via initScript (deferred per origin)
524
+ const origins = (storageState.origins ?? []).filter(o => o.localStorage?.length > 0);
525
+ if (origins.length > 0) {
526
+ const chunks = origins.map(o => {
527
+ const kv = o.localStorage.map(item => `localStorage.setItem(${JSON.stringify(item.name)},${JSON.stringify(item.value)})`).join(';');
528
+ return `if(location.origin===${JSON.stringify(o.origin)}){${kv}}`;
529
+ });
530
+ try {
531
+ await manager.addInitScript(newId, chunks.join(';'));
532
+ }
533
+ catch { /* non-fatal */ }
534
+ }
535
+ // Step 5: Close source session if not keeping it
536
+ if (!keep_source) {
537
+ if (manager)
538
+ await manager.closeSession(req.params.id);
539
+ await registry.close(req.params.id);
540
+ }
541
+ getLogger()?.write({
542
+ session_id: newId,
543
+ action_id: 'act_' + crypto_1.default.randomBytes(6).toString('hex'),
544
+ type: 'session',
545
+ action: 'switch_engine',
546
+ params: { source_session_id: req.params.id, old_channel: oldChannel, new_channel: target_channel, keep_source },
547
+ result: { cookies_transferred: cookies.length, origins_transferred: origins.length },
548
+ });
549
+ return reply.code(200).send({
550
+ session_id: newId,
551
+ old_session_id: keep_source ? req.params.id : null,
552
+ old_channel: oldChannel,
553
+ new_channel: target_channel,
554
+ profile: sourceSession.profile,
555
+ headless,
556
+ cookies_transferred: cookies.length,
557
+ origins_transferred: origins.length,
558
+ keep_source,
559
+ ...(cookieWarning ? { warning: cookieWarning } : {}),
560
+ });
561
+ });
227
562
  // POST /api/v1/sessions/:id/seal — mark session as sealed (blocks DELETE)
228
563
  server.post('/api/v1/sessions/:id/seal', async (req, reply) => {
229
564
  const s = registry.get(req.params.id);
@@ -237,6 +572,19 @@ function registerSessionRoutes(server, registry) {
237
572
  }
238
573
  return { status: 'ok', session_id: req.params.id, sealed: true };
239
574
  });
575
+ // POST /api/v1/sessions/:id/unseal — T06: remove seal so session can be deleted
576
+ server.post('/api/v1/sessions/:id/unseal', async (req, reply) => {
577
+ const s = registry.get(req.params.id);
578
+ if (!s)
579
+ return reply.code(404).send({ error: 'Not found' });
580
+ try {
581
+ registry.unseal(req.params.id);
582
+ }
583
+ catch (err) {
584
+ return reply.code(400).send({ error: err.message });
585
+ }
586
+ return { status: 'ok', session_id: req.params.id, sealed: false };
587
+ });
240
588
  // POST /api/v1/sessions/:id/mode — switch headless/headed
241
589
  server.post('/api/v1/sessions/:id/mode', async (req, reply) => {
242
590
  const s = registry.get(req.params.id);
@@ -312,7 +660,7 @@ function registerSessionRoutes(server, registry) {
312
660
  if (!manager)
313
661
  return reply.code(503).send({ error: 'Browser manager not initialized' });
314
662
  try {
315
- manager.switchPage(req.params.id, req.body.page_id);
663
+ await manager.switchPage(req.params.id, req.body.page_id);
316
664
  }
317
665
  catch (err) {
318
666
  return reply.code(404).send({ error: err.message });
@@ -650,41 +998,174 @@ function registerSessionRoutes(server, registry) {
650
998
  };
651
999
  });
652
1000
  // ---------------------------------------------------------------------------
653
- // R08-R14: Profile lifecyclelist + reset
1001
+ // R10-T07: Runtime permission grant browserContext.grantPermissions()
654
1002
  // ---------------------------------------------------------------------------
655
- function getProfilesDir() {
1003
+ server.post('/api/v1/sessions/:id/grant-permission', async (req, reply) => {
1004
+ const live = registry.getLive(req.params.id);
1005
+ if ('notFound' in live)
1006
+ return reply.code(404).send({ error: `Session not found: ${req.params.id}` });
1007
+ if ('zombie' in live)
1008
+ return reply.code(410).send({ error: 'Session browser is not running', state: 'zombie' });
1009
+ const { permissions, origin } = req.body ?? {};
1010
+ if (!Array.isArray(permissions) || permissions.length === 0) {
1011
+ return reply.code(400).send({ error: 'permissions must be a non-empty array' });
1012
+ }
1013
+ const VALID_PERMISSIONS = new Set([
1014
+ 'camera', 'microphone', 'notifications', 'geolocation',
1015
+ 'clipboard-read', 'clipboard-write', 'accelerometer', 'background-sync',
1016
+ 'magnetometer', 'gyroscope', 'midi', 'payment-handler', 'persistent-storage',
1017
+ ]);
1018
+ const invalid = permissions.filter(p => !VALID_PERMISSIONS.has(p));
1019
+ if (invalid.length > 0) {
1020
+ return reply.code(400).send({ error: `Unknown permission(s): ${invalid.join(', ')}` });
1021
+ }
1022
+ try {
1023
+ const grantOpts = origin ? { origin } : {};
1024
+ await live.context.grantPermissions(permissions, grantOpts);
1025
+ return { status: 'ok', session_id: req.params.id, permissions, origin: origin ?? null };
1026
+ }
1027
+ catch (err) {
1028
+ return reply.code(500).send({ error: err.message });
1029
+ }
1030
+ });
1031
+ // ---------------------------------------------------------------------------
1032
+ // R08-R14 / R10-T05: Profile lifecycle — list + reset + delete (with zone)
1033
+ // ---------------------------------------------------------------------------
1034
+ /**
1035
+ * zone 'managed' → profiles/ (Playwright-managed Chromium)
1036
+ * zone 'stable' → chrome-profiles/ (Chrome/Edge native browser)
1037
+ */
1038
+ function getZoneDir(zone) {
656
1039
  const dataDir = process.env.AGENTMB_DATA_DIR ?? path_1.default.join(os_1.default.homedir(), '.agentmb');
657
- return path_1.default.join(dataDir, 'profiles');
1040
+ return zone === 'stable'
1041
+ ? path_1.default.join(dataDir, 'chrome-profiles')
1042
+ : path_1.default.join(dataDir, 'profiles');
658
1043
  }
659
- server.get('/api/v1/profiles', async (_req, reply) => {
660
- const dir = getProfilesDir();
1044
+ /** Recursively sum file sizes in a directory. */
1045
+ async function dirSize(dirPath) {
1046
+ let total = 0;
661
1047
  try {
662
- if (!fs_1.default.existsSync(dir))
663
- return { profiles: [], count: 0 };
664
- const entries = await fs_1.default.promises.readdir(dir, { withFileTypes: true });
665
- const profiles = await Promise.all(entries
666
- .filter(e => e.isDirectory())
667
- .map(async (e) => {
668
- const profilePath = path_1.default.join(dir, e.name);
669
- let last_used = null;
670
- try {
671
- const stat = await fs_1.default.promises.stat(profilePath);
672
- last_used = stat.mtime.toISOString();
1048
+ const items = await fs_1.default.promises.readdir(dirPath, { withFileTypes: true });
1049
+ await Promise.all(items.map(async (item) => {
1050
+ const fullPath = path_1.default.join(dirPath, item.name);
1051
+ if (item.isDirectory()) {
1052
+ total += await dirSize(fullPath);
1053
+ }
1054
+ else if (item.isFile()) {
1055
+ try {
1056
+ total += (await fs_1.default.promises.stat(fullPath)).size;
1057
+ }
1058
+ catch { /* ignore */ }
673
1059
  }
674
- catch { /* ignore */ }
675
- return { name: e.name, path: profilePath, last_used };
676
1060
  }));
1061
+ }
1062
+ catch { /* ignore */ }
1063
+ return total;
1064
+ }
1065
+ /** Scan one zone directory and return enriched profile entries. */
1066
+ async function listZoneProfiles(zone) {
1067
+ const dir = getZoneDir(zone);
1068
+ if (!fs_1.default.existsSync(dir))
1069
+ return [];
1070
+ const entries = await fs_1.default.promises.readdir(dir, { withFileTypes: true });
1071
+ return Promise.all(entries
1072
+ .filter(e => e.isDirectory())
1073
+ .map(async (e) => {
1074
+ const profilePath = path_1.default.join(dir, e.name);
1075
+ let last_modified = null;
1076
+ try {
1077
+ const stat = await fs_1.default.promises.stat(profilePath);
1078
+ last_modified = stat.mtime.toISOString();
1079
+ }
1080
+ catch { /* ignore */ }
1081
+ const size_bytes = await dirSize(profilePath);
1082
+ const liveSessions = registry.list().filter(s => s.profile === e.name && s.state === 'live');
1083
+ return {
1084
+ zone,
1085
+ name: e.name,
1086
+ path: profilePath,
1087
+ size_bytes,
1088
+ sessions_live: liveSessions.length,
1089
+ session_ids: liveSessions.map(s => s.id),
1090
+ last_modified,
1091
+ };
1092
+ }));
1093
+ }
1094
+ // GET /api/v1/profiles[?zone=managed|stable] — list profiles with zone/size/session info
1095
+ server.get('/api/v1/profiles', async (req, reply) => {
1096
+ const { zone } = req.query ?? {};
1097
+ if (zone && zone !== 'managed' && zone !== 'stable') {
1098
+ return reply.code(400).send({ error: "zone must be 'managed' or 'stable'" });
1099
+ }
1100
+ try {
1101
+ let profiles = [];
1102
+ if (!zone || zone === 'managed')
1103
+ profiles = profiles.concat(await listZoneProfiles('managed'));
1104
+ if (!zone || zone === 'stable')
1105
+ profiles = profiles.concat(await listZoneProfiles('stable'));
677
1106
  return { profiles, count: profiles.length };
678
1107
  }
679
1108
  catch (err) {
680
1109
  return reply.code(500).send({ error: err.message });
681
1110
  }
682
1111
  });
1112
+ // DELETE /api/v1/profiles/:name[?zone=managed|stable][&force=true] — T05 profile delete
1113
+ server.delete('/api/v1/profiles/:name', async (req, reply) => {
1114
+ const { name } = req.params;
1115
+ const zone = (req.query.zone ?? 'managed');
1116
+ const force = req.query.force === 'true';
1117
+ if (!/^[\w\-]+$/.test(name))
1118
+ return reply.code(400).send({ error: 'Invalid profile name; only alphanumeric, dash, underscore allowed' });
1119
+ if (zone !== 'managed' && zone !== 'stable') {
1120
+ return reply.code(400).send({ error: "zone must be 'managed' or 'stable'" });
1121
+ }
1122
+ const dir = getZoneDir(zone);
1123
+ const profilePath = path_1.default.join(dir, name);
1124
+ // Safety: no path traversal
1125
+ if (!profilePath.startsWith(dir + path_1.default.sep))
1126
+ return reply.code(400).send({ error: 'Invalid profile name' });
1127
+ if (!fs_1.default.existsSync(profilePath)) {
1128
+ // R10-C07: cross-zone hint — check if profile exists in the other zone (Issue #13)
1129
+ const otherZone = zone === 'managed' ? 'stable' : 'managed';
1130
+ const otherPath = path_1.default.join(getZoneDir(otherZone), name);
1131
+ const hint = fs_1.default.existsSync(otherPath)
1132
+ ? `Profile '${name}' exists in zone '${otherZone}'. Try adding --zone ${otherZone}.`
1133
+ : undefined;
1134
+ const body = { error: `Profile '${name}' not found in zone '${zone}'` };
1135
+ if (hint)
1136
+ body.hint = hint;
1137
+ return reply.code(404).send(body);
1138
+ }
1139
+ // Destruction protection: check for live sessions
1140
+ const liveSessions = registry.list().filter(s => s.profile === name && s.state === 'live');
1141
+ if (liveSessions.length > 0 && !force) {
1142
+ return reply.code(423).send({
1143
+ error: 'profile_locked',
1144
+ message: `Profile '${name}' has ${liveSessions.length} live session(s). Use --force to override.`,
1145
+ session_ids: liveSessions.map(s => s.id),
1146
+ });
1147
+ }
1148
+ try {
1149
+ if (force && liveSessions.length > 0) {
1150
+ const manager = server.browserManager;
1151
+ for (const s of liveSessions) {
1152
+ if (manager)
1153
+ await manager.closeSession(s.id);
1154
+ await registry.close(s.id);
1155
+ }
1156
+ }
1157
+ await removeProfileDirWithRetry(profilePath);
1158
+ return reply.code(204).send();
1159
+ }
1160
+ catch (err) {
1161
+ return reply.code(500).send({ error: err.message });
1162
+ }
1163
+ });
683
1164
  server.post('/api/v1/profiles/:name/reset', async (req, reply) => {
684
1165
  const { name } = req.params;
685
1166
  if (!/^[\w\-]+$/.test(name))
686
1167
  return reply.code(400).send({ error: 'Invalid profile name; only alphanumeric, dash, underscore allowed' });
687
- const dir = getProfilesDir();
1168
+ const dir = getZoneDir('managed');
688
1169
  const profilePath = path_1.default.join(dir, name);
689
1170
  // Safety: ensure profilePath is inside dir (no path traversal)
690
1171
  if (!profilePath.startsWith(dir + path_1.default.sep))