@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 +3 -3
- package/src/auth/index.ts +33 -1
- package/src/flow-store.ts +2 -0
- package/src/flow.ts +26 -3
- package/src/mcp.ts +2 -1
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,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?: {
|
|
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: {
|