@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 +3 -3
- package/src/artifact-uploads.ts +1 -1
- package/src/auth/index.ts +33 -1
- package/src/browser.ts +20 -8
- package/src/capture-session.ts +41 -5
- package/src/daemon-routes.ts +9 -2
- package/src/flow-store.ts +2 -0
- package/src/flow.ts +28 -5
- package/src/mcp.ts +1 -0
- package/src/profiles.ts +31 -0
- package/src/service.ts +1 -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.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-
|
|
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/artifact-uploads.ts
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/capture-session.ts
CHANGED
|
@@ -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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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(() => {});
|
package/src/daemon-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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' },
|
package/src/profiles.ts
ADDED
|
@@ -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 (
|
|
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
|
|