@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 +3 -3
- package/src/auth/index.ts +33 -1
- package/src/flow-store.ts +2 -0
- package/src/flow.ts +18 -2
- package/src/mcp.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.1.
|
|
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-
|
|
19
|
-
"@broberg/lens-
|
|
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' },
|