@broberg/lens-daemon 0.1.22 → 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.22",
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,6 +402,9 @@ 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
410
  extension?: {
@@ -533,6 +536,7 @@ export function parseFlow(input: unknown): Flow {
533
536
  ...(mint ? { storage_local_mint: mint } : {}),
534
537
  };
535
538
  }
539
+ persistTarget(f.auth as AuthConfig | undefined); // F009.131 — refuse a persist that cannot be honoured
536
540
  validateAndNormalizeSteps(f.steps);
537
541
  if (!extension && usesPopup(f.steps as FlowStep[])) {
538
542
  throw new Error('openPopup / in:"popup" need flow.extension — there is no extension loaded to open a popup from');
@@ -3017,6 +3021,8 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3017
3021
  // F009.128 — files the run downloads. A download is awaited before the context
3018
3022
  // closes; otherwise the bytes die with the browser and the report says nothing.
3019
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;
3020
3026
  const pendingDownloads: Promise<void>[] = [];
3021
3027
  let currentStep: number | null = null;
3022
3028
  const watchDownloads = (context: BrowserContext): void => {
@@ -3273,6 +3279,15 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3273
3279
  // Bounded: a download that never finishes must not hang the run. Whatever is
3274
3280
  // still unfinished is reported as such, never dropped.
3275
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
+ }
3276
3291
  },
3277
3292
  );
3278
3293
  } catch (err) {
@@ -3280,7 +3295,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3280
3295
  // Keep the recording even on a crash — watching HOW it broke is the whole point.
3281
3296
  const video = await readVideo();
3282
3297
  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' };
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' };
3284
3299
  }
3285
3300
 
3286
3301
  const video = await readVideo();
@@ -3333,6 +3348,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3333
3348
  // ASKED for, defaulted the same way the context does, so the report and the
3334
3349
  // context can never disagree about it.
3335
3350
  reduced_motion: opts.reducedMotion ?? 'reduce',
3351
+ ...(authPersisted ? { auth_persisted: authPersisted } : {}),
3336
3352
  ...(extensionInfo ? { extension: extensionInfo } : {}),
3337
3353
  downloads,
3338
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' },