@broberg/lens-daemon 0.1.22 → 0.1.24

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.22",
3
+ "version": "0.1.24",
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-svg": "0.1.0",
19
- "@broberg/lens-engine": "0.13.1"
18
+ "@broberg/lens-engine": "0.13.1",
19
+ "@broberg/lens-svg": "0.1.0"
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/auth/index.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  // reuses the artifact F037's auth.setup.ts already produces.
19
19
  // - cookieJwt : a single named cookie holding a JWT. Covers cms.
20
20
 
21
- import { readFileSync } from 'node:fs';
21
+ import { chmodSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
22
22
  import { isAbsolute } from 'node:path';
23
23
  import type { BrowserContext } from 'playwright';
24
24
 
@@ -36,6 +36,11 @@ export interface StorageStateAuth {
36
36
  * the gitignored file it points at is — so this preserves "secret never in
37
37
  * the call". Takes precedence over stateEnv. */
38
38
  statePath?: string;
39
+ /** F009.131 — write the context's cookies + storage BACK to statePath when the
40
+ * run ends. Sites that rotate their session while you use it (Google, measured
41
+ * 15 Oct) kill the cookies a run started with, so a saved login lasted exactly
42
+ * one run. statePath only: there is nothing to write back to otherwise. */
43
+ persist?: boolean;
39
44
  }
40
45
 
41
46
  export interface CookieJwtAuth {
@@ -287,6 +292,33 @@ async function applyCookieJwt(
287
292
  ]);
288
293
  }
289
294
 
295
+ /** F009.131 — the statePath a run should write back to, or null. A persist that
296
+ * cannot be honoured is refused rather than ignored: the caller believes the
297
+ * login will survive, and a silent no-op is the next run's mystery logout. */
298
+ export function persistTarget(cfg: AuthConfig | undefined): string | null {
299
+ if (!cfg || (cfg as { persist?: unknown }).persist === undefined) return null;
300
+ const persist = (cfg as { persist?: unknown }).persist;
301
+ if (typeof persist !== 'boolean') throw new Error('auth.persist must be a boolean');
302
+ if (!persist) return null;
303
+ if (cfg.adapter !== 'storageState') {
304
+ throw new Error(`auth.persist works with adapter "storageState" only — "${cfg.adapter}" mints or sets its credential per run, so there is nothing to keep`);
305
+ }
306
+ if (!cfg.statePath) {
307
+ throw new Error('auth.persist needs auth.statePath — a state read from an env var or inline JSON has no file to write the renewed login back to');
308
+ }
309
+ return cfg.statePath;
310
+ }
311
+
312
+ /** F009.131 — write the context's current state to `path`: temp file + rename so a
313
+ * crash never leaves half a login, mode 0600 because it IS a login. */
314
+ export async function persistStorageState(context: BrowserContext, path: string): Promise<void> {
315
+ const state = await context.storageState();
316
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
317
+ writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
318
+ chmodSync(tmp, 0o600);
319
+ renameSync(tmp, path);
320
+ }
321
+
290
322
  /** Run the configured auth adapter against a fresh context. */
291
323
  export async function applyAuth(
292
324
  context: BrowserContext,
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,37 @@ 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
+ appName = 'Chromium';
421
+ } else {
422
+ ({ browser, appName } = await launchHeaded());
423
+ // Load an existing storageState so the browser opens ALREADY authenticated
424
+ // (skip the whole login; drive only the agreement + step-up 2FA). F240.7.
425
+ context = await browser.newContext(loadStatePath ? { storageState: loadStatePath } : undefined);
426
+ }
391
427
  // Mask the most-checked automation signal before any page script runs — pairs
392
428
  // with the de-flagged launch so Google's "browser may not be secure" bot-check
393
429
  // treats this like a real user's Chrome.
394
430
  await context.addInitScript(() => {
395
431
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
396
432
  });
397
- const page = await context.newPage();
433
+ const page = context.pages()[0] ?? (await context.newPage());
398
434
  // Best-effort nav — a slow login portal shouldn't fail start; the human is
399
435
  // driving from here and can navigate themselves if the first load stalls.
400
436
  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-store.ts CHANGED
@@ -150,6 +150,8 @@ export async function recordFlowRun(
150
150
  source: auth.source,
151
151
  adapter: auth.adapter,
152
152
  ...(auth.note ? { note: auth.note } : {}),
153
+ // F009.131 — whether the renewed login was saved; only when asked for.
154
+ ...(result.auth_persisted ? { persisted: result.auth_persisted.ok, ...(result.auth_persisted.reason ? { persisted_reason: result.auth_persisted.reason } : {}) } : {}),
153
155
  // F074.36 — the measured verdict, not the intent: "a credential was
154
156
  // configured" and "a credential can reach this host" are different
155
157
  // facts, and only the second one means anything.
package/src/flow.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  } from '@broberg/lens-engine';
26
26
  import { withPage, type LensEngine } from './browser.ts';
27
27
  import { deadline, defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
28
- import { authPrepare, fetchMintJson, type AuthConfig, type AuthEnv } from './auth/index.ts';
28
+ import { authPrepare, fetchMintJson, persistStorageState, persistTarget, type AuthConfig, type AuthEnv } from './auth/index.ts';
29
29
  import { takeShot, settleForShot } from './capture.ts';
30
30
  import { runCritic, topFindingMessage, type CriticMode, type CriticReport } from './critic.ts';
31
31
  import { waitForBuild, observeBuild } from './build-marker.ts';
@@ -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;
@@ -402,6 +405,9 @@ export async function finishDownloads(pending: Promise<void>[], downloads: FlowD
402
405
  export interface FlowResult {
403
406
  /** F009.128 — files the run downloaded. Always present on a finished run. */
404
407
  downloads?: FlowDownload[];
408
+ /** F009.131 — set when auth.persist was asked for: whether the renewed login was
409
+ * written back to statePath, and why not when it was not. Never a value. */
410
+ auth_persisted?: { ok: boolean; reason?: string };
405
411
  /** F009.125 — the extension this run loaded: its id (read at load time), the
406
412
  * build path the caller named, and whether host access was SIMULATED on a copy. */
407
413
  extension?: {
@@ -515,8 +521,8 @@ export function parseFlow(input: unknown): Flow {
515
521
  if (f.extension !== undefined) {
516
522
  const e = f.extension as unknown as Record<string, unknown>;
517
523
  const keys = e && typeof e === 'object' && !Array.isArray(e) ? Object.keys(e) : null;
518
- if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) => !['path', 'grant_host_access', 'storage_local', 'storage_local_mint'].includes(k))) {
519
- 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>"}');
520
526
  }
521
527
  if (e.grant_host_access !== undefined && typeof e.grant_host_access !== 'boolean') {
522
528
  throw new Error('flow.extension.grant_host_access must be a boolean');
@@ -526,13 +532,18 @@ export function parseFlow(input: unknown): Flow {
526
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');
527
533
  }
528
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
+ }
529
538
  extension = {
530
539
  path: e.path,
531
540
  ...(e.grant_host_access === true ? { grant_host_access: true } : {}),
532
541
  ...(sl !== undefined ? { storage_local: sl as Record<string, unknown> } : {}),
533
542
  ...(mint ? { storage_local_mint: mint } : {}),
543
+ ...(typeof e.profile === 'string' ? { profile: e.profile } : {}),
534
544
  };
535
545
  }
546
+ persistTarget(f.auth as AuthConfig | undefined); // F009.131 — refuse a persist that cannot be honoured
536
547
  validateAndNormalizeSteps(f.steps);
537
548
  if (!extension && usesPopup(f.steps as FlowStep[])) {
538
549
  throw new Error('openPopup / in:"popup" need flow.extension — there is no extension loaded to open a popup from');
@@ -3017,6 +3028,8 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3017
3028
  // F009.128 — files the run downloads. A download is awaited before the context
3018
3029
  // closes; otherwise the bytes die with the browser and the report says nothing.
3019
3030
  const downloads: FlowDownload[] = [];
3031
+ const persistPath = persistTarget(flow.auth);
3032
+ let authPersisted: FlowResult['auth_persisted'] = persistPath ? { ok: false, reason: 'the run ended before the login could be saved' } : undefined;
3020
3033
  const pendingDownloads: Promise<void>[] = [];
3021
3034
  let currentStep: number | null = null;
3022
3035
  const watchDownloads = (context: BrowserContext): void => {
@@ -3143,7 +3156,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3143
3156
  timezone: opts.timezone,
3144
3157
  reducedMotion: opts.reducedMotion,
3145
3158
  ...(videoDir ? { recordVideoDir: videoDir } : {}),
3146
- ...(prepared ? { extension: { path: prepared.dir } } : {}),
3159
+ ...(prepared ? { extension: { path: prepared.dir, ...(flow.extension?.profile ? { profile: flow.extension.profile } : {}) } } : {}),
3147
3160
  },
3148
3161
  async (page, context, ext) => {
3149
3162
  videoPage = page;
@@ -3273,6 +3286,15 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3273
3286
  // Bounded: a download that never finishes must not hang the run. Whatever is
3274
3287
  // still unfinished is reported as such, never dropped.
3275
3288
  await finishDownloads(pendingDownloads, downloads, DOWNLOAD_FINISH_MS);
3289
+ // F009.131 — after the last step, pass or fail, while the context is open.
3290
+ if (persistPath) {
3291
+ try {
3292
+ await persistStorageState(context, persistPath);
3293
+ authPersisted = { ok: true };
3294
+ } catch (err) {
3295
+ authPersisted = { ok: false, reason: `could not write ${persistPath}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}` };
3296
+ }
3297
+ }
3276
3298
  },
3277
3299
  );
3278
3300
  } catch (err) {
@@ -3280,7 +3302,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3280
3302
  // Keep the recording even on a crash — watching HOW it broke is the whole point.
3281
3303
  const video = await readVideo();
3282
3304
  await prepared?.cleanup();
3283
- return { downloads, ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
3305
+ return { downloads, ...(authPersisted ? { auth_persisted: authPersisted } : {}), ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
3284
3306
  }
3285
3307
 
3286
3308
  const video = await readVideo();
@@ -3333,6 +3355,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3333
3355
  // ASKED for, defaulted the same way the context does, so the report and the
3334
3356
  // context can never disagree about it.
3335
3357
  reduced_motion: opts.reducedMotion ?? 'reduce',
3358
+ ...(authPersisted ? { auth_persisted: authPersisted } : {}),
3336
3359
  ...(extensionInfo ? { extension: extensionInfo } : {}),
3337
3360
  downloads,
3338
3361
  };
package/src/mcp.ts CHANGED
@@ -36,6 +36,7 @@ const authSchema = {
36
36
  adapter: { type: 'string', enum: ['storageState', 'cookieJwt', 'mintEndpoint'] },
37
37
  statePath: { type: 'string', description: 'storageState: path to a Playwright storageState JSON (relative → resolved against local_path/repo root). Preferred over stateEnv (no daemon restart).' },
38
38
  stateEnv: { type: 'string', description: 'storageState: env var holding the storageState path/JSON (needs daemon restart to inject).' },
39
+ persist: { type: 'boolean', description: 'F009.131 — storageState + statePath only (flows/manuscripts): write the renewed cookies + storage BACK to statePath when the run ends (atomic, 0600). Sites that rotate their session while used (Google) otherwise leave a saved login good for ONE run. The report says auth.persisted true|false.' },
39
40
  cookieName: { type: 'string' },
40
41
  domain: { type: 'string' },
41
42
  path: { type: 'string' },
@@ -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