@broberg/lens-daemon 0.1.23 → 0.1.25
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/browser.ts +20 -8
- package/src/capture-session.ts +43 -5
- package/src/daemon-routes.ts +9 -2
- package/src/flow.ts +10 -3
- 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.25",
|
|
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-svg": "0.1.0",
|
|
19
|
+
"@broberg/lens-engine": "0.13.1"
|
|
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/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,39 @@ 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
|
+
// Measured 16 Oct: the channel build runs as this app, not "Chromium" — activating
|
|
421
|
+
// "Chromium" failed and the window stayed behind the others.
|
|
422
|
+
appName = 'Google Chrome for Testing';
|
|
423
|
+
} else {
|
|
424
|
+
({ browser, appName } = await launchHeaded());
|
|
425
|
+
// Load an existing storageState so the browser opens ALREADY authenticated
|
|
426
|
+
// (skip the whole login; drive only the agreement + step-up 2FA). F240.7.
|
|
427
|
+
context = await browser.newContext(loadStatePath ? { storageState: loadStatePath } : undefined);
|
|
428
|
+
}
|
|
391
429
|
// Mask the most-checked automation signal before any page script runs — pairs
|
|
392
430
|
// with the de-flagged launch so Google's "browser may not be secure" bot-check
|
|
393
431
|
// treats this like a real user's Chrome.
|
|
394
432
|
await context.addInitScript(() => {
|
|
395
433
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
396
434
|
});
|
|
397
|
-
const page = await context.newPage();
|
|
435
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
398
436
|
// Best-effort nav — a slow login portal shouldn't fail start; the human is
|
|
399
437
|
// driving from here and can navigate themselves if the first load stalls.
|
|
400
438
|
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.ts
CHANGED
|
@@ -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;
|
|
@@ -518,8 +521,8 @@ export function parseFlow(input: unknown): Flow {
|
|
|
518
521
|
if (f.extension !== undefined) {
|
|
519
522
|
const e = f.extension as unknown as Record<string, unknown>;
|
|
520
523
|
const keys = e && typeof e === 'object' && !Array.isArray(e) ? Object.keys(e) : null;
|
|
521
|
-
if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) => !['path', 'grant_host_access', 'storage_local', 'storage_local_mint'].includes(k))) {
|
|
522
|
-
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>"}');
|
|
523
526
|
}
|
|
524
527
|
if (e.grant_host_access !== undefined && typeof e.grant_host_access !== 'boolean') {
|
|
525
528
|
throw new Error('flow.extension.grant_host_access must be a boolean');
|
|
@@ -529,11 +532,15 @@ export function parseFlow(input: unknown): Flow {
|
|
|
529
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');
|
|
530
533
|
}
|
|
531
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
|
+
}
|
|
532
538
|
extension = {
|
|
533
539
|
path: e.path,
|
|
534
540
|
...(e.grant_host_access === true ? { grant_host_access: true } : {}),
|
|
535
541
|
...(sl !== undefined ? { storage_local: sl as Record<string, unknown> } : {}),
|
|
536
542
|
...(mint ? { storage_local_mint: mint } : {}),
|
|
543
|
+
...(typeof e.profile === 'string' ? { profile: e.profile } : {}),
|
|
537
544
|
};
|
|
538
545
|
}
|
|
539
546
|
persistTarget(f.auth as AuthConfig | undefined); // F009.131 — refuse a persist that cannot be honoured
|
|
@@ -3149,7 +3156,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3149
3156
|
timezone: opts.timezone,
|
|
3150
3157
|
reducedMotion: opts.reducedMotion,
|
|
3151
3158
|
...(videoDir ? { recordVideoDir: videoDir } : {}),
|
|
3152
|
-
...(prepared ? { extension: { path: prepared.dir } } : {}),
|
|
3159
|
+
...(prepared ? { extension: { path: prepared.dir, ...(flow.extension?.profile ? { profile: flow.extension.profile } : {}) } } : {}),
|
|
3153
3160
|
},
|
|
3154
3161
|
async (page, context, ext) => {
|
|
3155
3162
|
videoPage = page;
|
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
|
|