@broberg/lens-daemon 0.1.23 → 0.1.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/lens-daemon",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,8 +15,8 @@
15
15
  "sharp": "^0.33.5",
16
16
  "turndown": "^7.2.4",
17
17
  "zod": "^3.23.8",
18
- "@broberg/lens-engine": "0.13.1",
19
- "@broberg/lens-svg": "0.1.0"
18
+ "@broberg/lens-svg": "0.1.0",
19
+ "@broberg/lens-engine": "0.13.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
@@ -168,7 +168,7 @@ export async function drainUploads(
168
168
  idle_reason: reason,
169
169
  });
170
170
 
171
- if (!cloud) return empty('not signed in to cardmem — run `cardmem login`; nothing was uploaded');
171
+ if (!cloud) return empty('not signed in to cardmem — this daemon has no cloud key (the cardmem session logs it in via the daemon\'s POST /login); nothing was uploaded');
172
172
 
173
173
  const batch = Math.max(1, Math.min(opts.batch ?? 25, 200));
174
174
  const now = opts.now?.() ?? Date.now();
package/src/browser.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // auth (F074.4) and concurrent captures never bleed into each other. The
11
11
  // context + page are always closed in a finally, even on capture error.
12
12
 
13
+ import { claimProfile, profileDir } from './profiles.ts';
13
14
  import {
14
15
  chromium,
15
16
  webkit,
@@ -226,7 +227,7 @@ export interface WithPageOptions {
226
227
  *
227
228
  * This is a SECOND launch path, not a flag on the warm browser: extensions
228
229
  * need launchPersistentContext with a real user-data dir on disk. */
229
- extension?: { path: string };
230
+ extension?: { path: string; /** F009.132 — a kept profile instead of a temp dir. */ profile?: string };
230
231
  }
231
232
 
232
233
  /** Exported for test: F074.57's central promise to everyone who did NOT ask for
@@ -319,7 +320,7 @@ export interface LoadedExtension {
319
320
  * Chromium binary does, in the new headless mode. That distinction is the whole
320
321
  * reason this can run on a headless host (and therefore on Cloud Lens) instead
321
322
  * of needing a screen. */
322
- const EXTENSION_CHANNEL = 'chromium';
323
+ export const EXTENSION_CHANNEL = 'chromium';
323
324
  const EXTENSION_LAUNCH_TIMEOUT_MS = 30_000;
324
325
  const EXTENSION_LAUNCH_ATTEMPTS = 2;
325
326
 
@@ -333,8 +334,10 @@ async function launchWithExtension(
333
334
  let userDataDir = '';
334
335
  let context: BrowserContext | null = null;
335
336
  let lastLaunchError = '';
337
+ const profile = opts.extension?.profile;
336
338
  for (let attempt = 1; attempt <= EXTENSION_LAUNCH_ATTEMPTS && !context; attempt++) {
337
- userDataDir = await mkdtemp(join(tmpdir(), 'lens-ext-'));
339
+ // F009.132 — a named profile is KEPT: that is where a site's login lives.
340
+ userDataDir = profile ? profileDir(profile) : await mkdtemp(join(tmpdir(), 'lens-ext-'));
338
341
  try {
339
342
  context = await chromium.launchPersistentContext(userDataDir, {
340
343
  ...contextOptions(opts),
@@ -350,7 +353,7 @@ async function launchWithExtension(
350
353
  });
351
354
  } catch (err) {
352
355
  lastLaunchError = err instanceof Error ? err.message.split('\n')[0]! : String(err);
353
- await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
356
+ if (!profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
354
357
  }
355
358
  }
356
359
  if (!context) {
@@ -369,7 +372,7 @@ async function launchWithExtension(
369
372
  const bg = worker?.url() ?? context.backgroundPages()[0]?.url();
370
373
  if (!bg) {
371
374
  await context.close().catch(() => {});
372
- await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
375
+ if (!profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
373
376
  throw new Error(
374
377
  `extension at ${extPath} loaded no service worker or background page within 15s — ` +
375
378
  'its id cannot be resolved, so nothing can be captured from it. Check that the path is ' +
@@ -398,7 +401,15 @@ export async function withPage<T>(
398
401
  'Drop the engine option or pass engine:"chromium".',
399
402
  );
400
403
  }
401
- const { context, extension, userDataDir } = await launchWithExtension(opts, opts.extension.path);
404
+ const release = opts.extension.profile ? claimProfile(opts.extension.profile) : () => {};
405
+ let launched: Awaited<ReturnType<typeof launchWithExtension>>;
406
+ try {
407
+ launched = await launchWithExtension(opts, opts.extension.path);
408
+ } catch (err) {
409
+ release();
410
+ throw err;
411
+ }
412
+ const { context, extension, userDataDir } = launched;
402
413
  try {
403
414
  await context.addInitScript(READINESS_INIT_SCRIPT);
404
415
  if (opts.prepareContext) await opts.prepareContext(context);
@@ -414,8 +425,9 @@ export async function withPage<T>(
414
425
  } finally {
415
426
  await withDeadline(context.close(), 5_000, 'extension context close').catch(() => {});
416
427
  // The user-data dir is ours and nobody else's; leaving it leaks a real
417
- // directory per capture.
418
- await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
428
+ // directory per capture. A named profile is the exception: it IS the login.
429
+ if (!opts.extension.profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
430
+ release();
419
431
  }
420
432
  }
421
433
 
@@ -18,6 +18,8 @@
18
18
  // are confined to ~/.lens/state so a bad state_path can't clobber an arbitrary
19
19
  // file. The endpoint is loopback-only (127.0.0.1) behind the daemon's auth.
20
20
 
21
+ import { claimProfile, profileDir } from './profiles.ts';
22
+ import { EXTENSION_CHANNEL } from './browser.ts';
21
23
  import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
22
24
  import { z } from 'zod';
23
25
  import { mkdirSync, existsSync, copyFileSync, writeFileSync } from 'node:fs';
@@ -57,6 +59,18 @@ export interface FinishableSession {
57
59
  * disable the AutomationControlled blink feature. --disable-gpu = software compositing
58
60
  * (a launchd-context window otherwise paints black). Falls back to bundled chromium
59
61
  * (still de-flagged) if real Chrome isn't installed. */
62
+ /** F009.132 — a login for a kept profile must happen in the SAME browser build
63
+ * the extension runs use (Google binds its session to the browser), visibly, so
64
+ * a human can type the password and 2FA. Exported so the build is asserted. */
65
+ export function profileLaunchOptions() {
66
+ return {
67
+ headless: false,
68
+ channel: EXTENSION_CHANNEL,
69
+ ignoreDefaultArgs: ['--enable-automation'],
70
+ args: ['--disable-blink-features=AutomationControlled'],
71
+ };
72
+ }
73
+
60
74
  async function launchHeaded(): Promise<{ browser: Browser; appName: string }> {
61
75
  const opts = {
62
76
  headless: false,
@@ -360,6 +374,8 @@ export async function startCaptureSession(input: {
360
374
  password?: string;
361
375
  /** F240.7 — a storageState file to LOAD so the browser opens already logged in. */
362
376
  loadStatePath?: string;
377
+ /** F009.132 — log in INSIDE the kept profile ~/.lens/profiles/<name>. */
378
+ profile?: string;
363
379
  }): Promise<{
364
380
  sessionId: string;
365
381
  url: string;
@@ -384,17 +400,39 @@ export async function startCaptureSession(input: {
384
400
  mkdirSync(dirname(statePath), { recursive: true });
385
401
  const loadStatePath = input.loadStatePath ? validateLoadStatePath(input.loadStatePath) : undefined;
386
402
 
387
- const { browser, appName } = await launchHeaded();
388
- // Load an existing storageState so the browser opens ALREADY authenticated
389
- // (skip the whole login; drive only the agreement + step-up 2FA). F240.7.
390
- const context = await browser.newContext(loadStatePath ? { storageState: loadStatePath } : undefined);
403
+ let browser: Browser;
404
+ let appName: string;
405
+ let context: BrowserContext;
406
+ let releaseProfile: () => void = () => {};
407
+ if (input.profile) {
408
+ // F009.132 — the login lands in the profile itself; the state file written at
409
+ // finish is a copy for runs that do not use the profile.
410
+ const dir = profileDir(input.profile);
411
+ releaseProfile = claimProfile(input.profile);
412
+ try {
413
+ context = await chromium.launchPersistentContext(dir, profileLaunchOptions());
414
+ } catch (err) {
415
+ releaseProfile();
416
+ throw err;
417
+ }
418
+ const release = releaseProfile;
419
+ browser = { close: async () => { try { await context.close(); } finally { release(); } } } as unknown as Browser;
420
+ // Measured 16 Oct: the channel build runs as this app, not "Chromium" — activating
421
+ // "Chromium" failed and the window stayed behind the others.
422
+ appName = 'Google Chrome for Testing';
423
+ } else {
424
+ ({ browser, appName } = await launchHeaded());
425
+ // Load an existing storageState so the browser opens ALREADY authenticated
426
+ // (skip the whole login; drive only the agreement + step-up 2FA). F240.7.
427
+ context = await browser.newContext(loadStatePath ? { storageState: loadStatePath } : undefined);
428
+ }
391
429
  // Mask the most-checked automation signal before any page script runs — pairs
392
430
  // with the de-flagged launch so Google's "browser may not be secure" bot-check
393
431
  // treats this like a real user's Chrome.
394
432
  await context.addInitScript(() => {
395
433
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
396
434
  });
397
- const page = await context.newPage();
435
+ const page = context.pages()[0] ?? (await context.newPage());
398
436
  // Best-effort nav — a slow login portal shouldn't fail start; the human is
399
437
  // driving from here and can navigate themselves if the first load stalls.
400
438
  await page.goto(input.url, { waitUntil: 'domcontentloaded', timeout: 60_000 }).catch(() => {});
@@ -9,6 +9,8 @@
9
9
  // and `err instanceof SandboxError` became isSandboxError(err), because the
10
10
  // sandbox (and its error class) stays the daemon's — see sandbox.ts.
11
11
 
12
+ import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
12
14
  import type { Hono } from 'hono';
13
15
  import { scrapeArticle } from './scrape.ts';
14
16
  import { startCaptureSession, finishCaptureSession, cancelCaptureSession, execCaptureSession, parseExecBody } from './capture-session.ts';
@@ -107,16 +109,21 @@ export function registerDaemonRoutes(r: Hono, deps: LensDeps): void {
107
109
  email?: string;
108
110
  password?: string;
109
111
  load_state_path?: string;
112
+ profile?: string;
110
113
  };
111
114
  if (!body.url) return c.json({ ok: false, error: 'url required' }, 400);
112
- if (!body.state_path) return c.json({ ok: false, error: 'state_path required' }, 400);
115
+ // F009.132 — with a profile the login lives in the profile; the state file is
116
+ // an optional copy and defaults next to the other states.
117
+ const statePath = body.state_path ?? (typeof body.profile === 'string' ? join(homedir(), '.lens', 'state', `profile-${body.profile}.json`) : undefined);
118
+ if (!statePath) return c.json({ ok: false, error: 'state_path required (or pass profile)' }, 400);
113
119
  try {
114
120
  const r = await startCaptureSession({
115
121
  url: body.url,
116
- statePath: body.state_path,
122
+ statePath,
117
123
  email: body.email,
118
124
  password: body.password,
119
125
  loadStatePath: body.load_state_path,
126
+ ...(typeof body.profile === 'string' ? { profile: body.profile } : {}),
120
127
  });
121
128
  // F240.10 — pass the window verdict through UNCHANGED. A remote caller is the
122
129
  // one who has to tell a human "the browser is open"; it must see what the
package/src/flow.ts CHANGED
@@ -199,6 +199,9 @@ export interface FlowExtension {
199
199
  * token never passes through the calling agent. Wins over storage_local on the
200
200
  * same key. */
201
201
  storage_local_mint?: StorageLocalMint;
202
+ /** F009.132 — run in the kept browser profile ~/.lens/profiles/<name> (where a
203
+ * site's login lives) instead of a fresh temp profile. */
204
+ profile?: string;
202
205
  }
203
206
  export interface StorageLocalMint {
204
207
  key: string;
@@ -518,8 +521,8 @@ export function parseFlow(input: unknown): Flow {
518
521
  if (f.extension !== undefined) {
519
522
  const e = f.extension as unknown as Record<string, unknown>;
520
523
  const keys = e && typeof e === 'object' && !Array.isArray(e) ? Object.keys(e) : null;
521
- if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) => !['path', 'grant_host_access', 'storage_local', 'storage_local_mint'].includes(k))) {
522
- throw new Error('flow.extension must be {path: "<unpacked build dir>", grant_host_access?: boolean, storage_local?: {…}, storage_local_mint?: {…}}');
524
+ if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) => !['path', 'grant_host_access', 'storage_local', 'storage_local_mint', 'profile'].includes(k))) {
525
+ throw new Error('flow.extension must be {path: "<unpacked build dir>", grant_host_access?: boolean, storage_local?: {…}, storage_local_mint?: {…}, profile?: "<name>"}');
523
526
  }
524
527
  if (e.grant_host_access !== undefined && typeof e.grant_host_access !== 'boolean') {
525
528
  throw new Error('flow.extension.grant_host_access must be a boolean');
@@ -529,11 +532,15 @@ export function parseFlow(input: unknown): Flow {
529
532
  throw new Error('flow.extension.storage_local must be an object of {key: JSON value} — it is passed to chrome.storage.local.set as-is');
530
533
  }
531
534
  const mint = e.storage_local_mint === undefined ? undefined : parseStorageLocalMint(e.storage_local_mint);
535
+ if (e.profile !== undefined && (typeof e.profile !== 'string' || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(e.profile))) {
536
+ throw new Error('flow.extension.profile must be 1-40 characters of a-z, 0-9 and dash, starting with a letter or digit');
537
+ }
532
538
  extension = {
533
539
  path: e.path,
534
540
  ...(e.grant_host_access === true ? { grant_host_access: true } : {}),
535
541
  ...(sl !== undefined ? { storage_local: sl as Record<string, unknown> } : {}),
536
542
  ...(mint ? { storage_local_mint: mint } : {}),
543
+ ...(typeof e.profile === 'string' ? { profile: e.profile } : {}),
537
544
  };
538
545
  }
539
546
  persistTarget(f.auth as AuthConfig | undefined); // F009.131 — refuse a persist that cannot be honoured
@@ -3149,7 +3156,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3149
3156
  timezone: opts.timezone,
3150
3157
  reducedMotion: opts.reducedMotion,
3151
3158
  ...(videoDir ? { recordVideoDir: videoDir } : {}),
3152
- ...(prepared ? { extension: { path: prepared.dir } } : {}),
3159
+ ...(prepared ? { extension: { path: prepared.dir, ...(flow.extension?.profile ? { profile: flow.extension.profile } : {}) } } : {}),
3153
3160
  },
3154
3161
  async (page, context, ext) => {
3155
3162
  videoPage = page;
@@ -0,0 +1,31 @@
1
+ // F009.132 — a fixed browser profile per site. Google binds its session to the
2
+ // browser that holds it, so a login only survives if it happens in, and is reused
3
+ // from, ONE profile of ONE browser build. The profile lives on the daemon host.
4
+ import { mkdirSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ // LENS_PROFILE_ROOT exists for tests, so a suite never writes into the real ~/.lens.
9
+ const profileRoot = () => process.env.LENS_PROFILE_ROOT || join(homedir(), '.lens', 'profiles');
10
+ const NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
11
+
12
+ /** The profile's user-data dir. The name is checked because it becomes a path. */
13
+ export function profileDir(name: string): string {
14
+ if (typeof name !== 'string' || !NAME.test(name)) {
15
+ throw new Error(`profile must be 1-40 characters of a-z, 0-9 and dash, starting with a letter or digit (got ${JSON.stringify(name)})`);
16
+ }
17
+ const dir = join(profileRoot(), name);
18
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
19
+ return dir;
20
+ }
21
+
22
+ // One Chromium per profile at a time: a second one on the same dir either fails
23
+ // on Chromium's own lock with an unhelpful message or corrupts it. Refused here,
24
+ // by name, before a browser starts.
25
+ const inUse = new Set<string>();
26
+
27
+ export function claimProfile(name: string): () => void {
28
+ if (inUse.has(name)) throw new Error(`browser profile "${name}" is already in use by another run — wait for it to finish`);
29
+ inUse.add(name);
30
+ return () => inUse.delete(name);
31
+ }
package/src/service.ts CHANGED
@@ -1190,7 +1190,7 @@ export async function promoteEvidence(
1190
1190
  const cloud = deps.cloud?.();
1191
1191
  if (!cloud) {
1192
1192
  throw new Error(
1193
- 'not signed in to cardmem — promoting evidence needs the daemon\'s cloud key (run `cardmem login`). Nothing was copied.',
1193
+ 'not signed in to cardmem — promoting evidence needs the daemon\'s cloud key, and this daemon has none (the cardmem session logs it in via the daemon\'s POST /login). Nothing was copied.',
1194
1194
  );
1195
1195
  }
1196
1196