@broberg/lens-daemon 0.1.20 → 0.1.23

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.20",
3
+ "version": "0.1.23",
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",
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/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';
@@ -402,9 +402,18 @@ export async function finishDownloads(pending: Promise<void>[], downloads: FlowD
402
402
  export interface FlowResult {
403
403
  /** F009.128 — files the run downloaded. Always present on a finished run. */
404
404
  downloads?: FlowDownload[];
405
+ /** F009.131 — set when auth.persist was asked for: whether the renewed login was
406
+ * written back to statePath, and why not when it was not. Never a value. */
407
+ auth_persisted?: { ok: boolean; reason?: string };
405
408
  /** F009.125 — the extension this run loaded: its id (read at load time), the
406
409
  * build path the caller named, and whether host access was SIMULATED on a copy. */
407
- extension?: { id: string; path: string; host_access: 'as-built' | 'simulated'; note?: string } | null;
410
+ extension?: {
411
+ id: string; path: string; host_access: 'as-built' | 'simulated'; note?: string;
412
+ /** F009.129 — the chrome.storage.local KEYS seeded before step 0 (names only, never
413
+ * values), and which one Lens minted itself. A failed mint never gets here — it
414
+ * ends the run before step 0 naming url + status. */
415
+ storage_local?: { keys: string[]; minted: string | null };
416
+ } | null;
408
417
  name: string;
409
418
  status: 'pass' | 'fail' | 'error';
410
419
  steps: FlowStepResult[];
@@ -527,6 +536,7 @@ export function parseFlow(input: unknown): Flow {
527
536
  ...(mint ? { storage_local_mint: mint } : {}),
528
537
  };
529
538
  }
539
+ persistTarget(f.auth as AuthConfig | undefined); // F009.131 — refuse a persist that cannot be honoured
530
540
  validateAndNormalizeSteps(f.steps);
531
541
  if (!extension && usesPopup(f.steps as FlowStep[])) {
532
542
  throw new Error('openPopup / in:"popup" need flow.extension — there is no extension loaded to open a popup from');
@@ -3011,6 +3021,8 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3011
3021
  // F009.128 — files the run downloads. A download is awaited before the context
3012
3022
  // closes; otherwise the bytes die with the browser and the report says nothing.
3013
3023
  const downloads: FlowDownload[] = [];
3024
+ const persistPath = persistTarget(flow.auth);
3025
+ let authPersisted: FlowResult['auth_persisted'] = persistPath ? { ok: false, reason: 'the run ended before the login could be saved' } : undefined;
3014
3026
  const pendingDownloads: Promise<void>[] = [];
3015
3027
  let currentStep: number | null = null;
3016
3028
  const watchDownloads = (context: BrowserContext): void => {
@@ -3157,6 +3169,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3157
3169
  path: flow.extension.path,
3158
3170
  host_access: prepared.simulated ? 'simulated' : 'as-built',
3159
3171
  ...(prepared.simulated ? { note: SIMULATED_ACCESS_NOTE } : {}),
3172
+ ...(Object.keys(seed).length ? { storage_local: { keys: Object.keys(seed), minted: mint?.key ?? null } } : {}),
3160
3173
  };
3161
3174
  }
3162
3175
  // F074.36 — measure the credential BEFORE driving, so a run that cannot
@@ -3266,6 +3279,15 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3266
3279
  // Bounded: a download that never finishes must not hang the run. Whatever is
3267
3280
  // still unfinished is reported as such, never dropped.
3268
3281
  await finishDownloads(pendingDownloads, downloads, DOWNLOAD_FINISH_MS);
3282
+ // F009.131 — after the last step, pass or fail, while the context is open.
3283
+ if (persistPath) {
3284
+ try {
3285
+ await persistStorageState(context, persistPath);
3286
+ authPersisted = { ok: true };
3287
+ } catch (err) {
3288
+ authPersisted = { ok: false, reason: `could not write ${persistPath}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}` };
3289
+ }
3290
+ }
3269
3291
  },
3270
3292
  );
3271
3293
  } catch (err) {
@@ -3273,7 +3295,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3273
3295
  // Keep the recording even on a crash — watching HOW it broke is the whole point.
3274
3296
  const video = await readVideo();
3275
3297
  await prepared?.cleanup();
3276
- 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' };
3298
+ 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' };
3277
3299
  }
3278
3300
 
3279
3301
  const video = await readVideo();
@@ -3326,6 +3348,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3326
3348
  // ASKED for, defaulted the same way the context does, so the report and the
3327
3349
  // context can never disagree about it.
3328
3350
  reduced_motion: opts.reducedMotion ?? 'reduce',
3351
+ ...(authPersisted ? { auth_persisted: authPersisted } : {}),
3329
3352
  ...(extensionInfo ? { extension: extensionInfo } : {}),
3330
3353
  downloads,
3331
3354
  };
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' },
@@ -369,7 +370,7 @@ export const TOOLS = [
369
370
  path: { type: 'string' },
370
371
  grant_host_access: { type: 'boolean' },
371
372
  storage_local: { type: 'object', description: 'F009.126 — {key: JSON value} for chrome.storage.local, set before step 0.' },
372
- storage_local_mint: { type: 'object', description: 'F009.129 — {key, url, method?, secretPath | secretEnv, body?}: the DAEMON calls the mint url (Bearer secret; secretPath relative to local_path, sandboxed) and writes the JSON answer to chrome.storage.local[key] before step 0, so a real token never passes through you. Wins over storage_local on the same key. Value and secret never appear in the report.' },
373
+ storage_local_mint: { type: 'object', description: 'F009.129 — {key, url, method?, secretPath | secretEnv, body?}: the DAEMON calls the mint url (Bearer secret; secretPath relative to local_path, sandboxed) and writes the JSON answer to chrome.storage.local[key] before step 0, so a real token never passes through you. Wins over storage_local on the same key. Value and secret never appear in the report; it carries extension.storage_local {keys, minted} — key NAMES only — so you can see the login was seeded. Steps after openPopup still run on the page under test: add in:"popup" to drive the popup.' },
373
374
  },
374
375
  },
375
376
  steps: {