@broberg/lens-daemon 0.1.18 → 0.1.22
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 +28 -13
- package/src/browser.ts +33 -12
- package/src/flow.ts +56 -5
- package/src/mcp.ts +1 -0
- package/src/service.ts +9 -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.22",
|
|
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/auth/index.ts
CHANGED
|
@@ -196,6 +196,26 @@ async function applyMintEndpoint(
|
|
|
196
196
|
cfg: MintEndpointAuth,
|
|
197
197
|
env: AuthEnv,
|
|
198
198
|
): Promise<void> {
|
|
199
|
+
const json = (await fetchMintJson(cfg, env, 'mintEndpoint auth')) as StorageStateShape;
|
|
200
|
+
if (!json.cookies?.length && !json.origins?.length) {
|
|
201
|
+
throw new Error(`mintEndpoint auth: ${cfg.url} returned a storageState with no cookies/origins`);
|
|
202
|
+
}
|
|
203
|
+
await applyStorageStateJson(context, json);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The narrow mint call shared by auth.mintEndpoint and F009.129's
|
|
207
|
+
* extension.storage_local_mint: resolve the secret (secretPath wins over
|
|
208
|
+
* secretEnv), POST/GET with `Authorization: Bearer`, require 2xx + JSON. Error
|
|
209
|
+
* messages name the url and status only — never the secret, never the body. */
|
|
210
|
+
export interface MintCall {
|
|
211
|
+
url: string;
|
|
212
|
+
method?: 'POST' | 'GET';
|
|
213
|
+
secretEnv?: string;
|
|
214
|
+
secretPath?: string;
|
|
215
|
+
body?: unknown;
|
|
216
|
+
headers?: Record<string, string>;
|
|
217
|
+
}
|
|
218
|
+
export async function fetchMintJson(cfg: MintCall, env: AuthEnv, label: string): Promise<unknown> {
|
|
199
219
|
// Resolve the narrow mint secret — secretPath (gitignored file, no restart)
|
|
200
220
|
// wins over secretEnv. The secret is NOT in the manifest/call, only its source.
|
|
201
221
|
let secret: string | undefined;
|
|
@@ -204,18 +224,18 @@ async function applyMintEndpoint(
|
|
|
204
224
|
// some other repo's secret, which would then be sent to the caller's url.
|
|
205
225
|
// Callers resolve against their project root first (resolveAuthPaths).
|
|
206
226
|
if (!isAbsolute(cfg.secretPath)) {
|
|
207
|
-
throw new Error(
|
|
227
|
+
throw new Error(`${label}: secretPath must be absolute by the time it is read (got "${cfg.secretPath}") — register the project or pass an absolute path`);
|
|
208
228
|
}
|
|
209
229
|
try {
|
|
210
230
|
secret = readFileSync(cfg.secretPath, 'utf-8').trim();
|
|
211
231
|
} catch (err) {
|
|
212
|
-
throw new Error(
|
|
232
|
+
throw new Error(`${label}: could not read secretPath ${cfg.secretPath}: ${err instanceof Error ? err.message : err}`);
|
|
213
233
|
}
|
|
214
234
|
} else if (cfg.secretEnv) {
|
|
215
235
|
secret = env[cfg.secretEnv];
|
|
216
|
-
if (!secret) throw new Error(
|
|
236
|
+
if (!secret) throw new Error(`${label}: env ${cfg.secretEnv} is not set`);
|
|
217
237
|
} else {
|
|
218
|
-
throw new Error(
|
|
238
|
+
throw new Error(`${label}: set secretPath or secretEnv`);
|
|
219
239
|
}
|
|
220
240
|
|
|
221
241
|
// Optional JSON body (POST only) — lets the endpoint mint a session for a
|
|
@@ -235,21 +255,16 @@ async function applyMintEndpoint(
|
|
|
235
255
|
...(sendBody ? { body: JSON.stringify(cfg.body) } : {}),
|
|
236
256
|
});
|
|
237
257
|
} catch (err) {
|
|
238
|
-
throw new Error(
|
|
258
|
+
throw new Error(`${label}: request to ${cfg.url} failed: ${err instanceof Error ? err.message : err}`);
|
|
239
259
|
}
|
|
240
260
|
if (!res.ok) {
|
|
241
|
-
throw new Error(
|
|
261
|
+
throw new Error(`${label}: ${cfg.url} returned ${res.status} ${res.statusText}`);
|
|
242
262
|
}
|
|
243
|
-
let json: StorageStateShape;
|
|
244
263
|
try {
|
|
245
|
-
|
|
264
|
+
return await res.json();
|
|
246
265
|
} catch (err) {
|
|
247
|
-
throw new Error(
|
|
266
|
+
throw new Error(`${label}: ${cfg.url} did not return JSON: ${err instanceof Error ? err.message : err}`);
|
|
248
267
|
}
|
|
249
|
-
if (!json.cookies?.length && !json.origins?.length) {
|
|
250
|
-
throw new Error(`mintEndpoint auth: ${cfg.url} returned a storageState with no cookies/origins`);
|
|
251
|
-
}
|
|
252
|
-
await applyStorageStateJson(context, json);
|
|
253
268
|
}
|
|
254
269
|
|
|
255
270
|
async function applyCookieJwt(
|
package/src/browser.ts
CHANGED
|
@@ -320,23 +320,44 @@ export interface LoadedExtension {
|
|
|
320
320
|
* reason this can run on a headless host (and therefore on Cloud Lens) instead
|
|
321
321
|
* of needing a screen. */
|
|
322
322
|
const EXTENSION_CHANNEL = 'chromium';
|
|
323
|
+
const EXTENSION_LAUNCH_TIMEOUT_MS = 30_000;
|
|
324
|
+
const EXTENSION_LAUNCH_ATTEMPTS = 2;
|
|
323
325
|
|
|
324
326
|
async function launchWithExtension(
|
|
325
327
|
opts: WithPageOptions,
|
|
326
328
|
extPath: string,
|
|
327
329
|
): Promise<{ context: BrowserContext; extension: LoadedExtension; userDataDir: string }> {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
330
|
+
// Bounded and retried once: Playwright's default launch timeout is 180 s, so a
|
|
331
|
+
// launch that stalls (seen on loaded Linux CI) used to hang the whole run past
|
|
332
|
+
// any test or caller deadline with no word on WHERE. Now it names the stage.
|
|
333
|
+
let userDataDir = '';
|
|
334
|
+
let context: BrowserContext | null = null;
|
|
335
|
+
let lastLaunchError = '';
|
|
336
|
+
for (let attempt = 1; attempt <= EXTENSION_LAUNCH_ATTEMPTS && !context; attempt++) {
|
|
337
|
+
userDataDir = await mkdtemp(join(tmpdir(), 'lens-ext-'));
|
|
338
|
+
try {
|
|
339
|
+
context = await chromium.launchPersistentContext(userDataDir, {
|
|
340
|
+
...contextOptions(opts),
|
|
341
|
+
headless: true,
|
|
342
|
+
channel: EXTENSION_CHANNEL,
|
|
343
|
+
timeout: EXTENSION_LAUNCH_TIMEOUT_MS,
|
|
344
|
+
args: [
|
|
345
|
+
`--disable-extensions-except=${extPath}`,
|
|
346
|
+
`--load-extension=${extPath}`,
|
|
347
|
+
'--disable-dev-shm-usage',
|
|
348
|
+
'--no-sandbox',
|
|
349
|
+
],
|
|
350
|
+
});
|
|
351
|
+
} catch (err) {
|
|
352
|
+
lastLaunchError = err instanceof Error ? err.message.split('\n')[0]! : String(err);
|
|
353
|
+
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!context) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`extension browser did not start (${EXTENSION_LAUNCH_ATTEMPTS} attempts × ${EXTENSION_LAUNCH_TIMEOUT_MS} ms): ${lastLaunchError}`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
340
361
|
// MV3 registers a service worker; MV2 a background page. Wait for whichever
|
|
341
362
|
// appears — the id lives in its URL host either way.
|
|
342
363
|
let worker = context.serviceWorkers()[0];
|
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, type AuthConfig, type AuthEnv } from './auth/index.ts';
|
|
28
|
+
import { authPrepare, fetchMintJson, 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';
|
|
@@ -194,6 +194,19 @@ export interface FlowExtension {
|
|
|
194
194
|
/** F009.126 — written to the extension's chrome.storage.local before step 0, so a
|
|
195
195
|
* popup that keeps its login there starts signed in (OAuth cannot run headless). */
|
|
196
196
|
storage_local?: Record<string, unknown>;
|
|
197
|
+
/** F009.129 — the daemon MINTS the value itself (Bearer secret from secretPath /
|
|
198
|
+
* secretEnv) and writes the JSON answer to chrome.storage.local[key], so a real
|
|
199
|
+
* token never passes through the calling agent. Wins over storage_local on the
|
|
200
|
+
* same key. */
|
|
201
|
+
storage_local_mint?: StorageLocalMint;
|
|
202
|
+
}
|
|
203
|
+
export interface StorageLocalMint {
|
|
204
|
+
key: string;
|
|
205
|
+
url: string;
|
|
206
|
+
method?: 'POST' | 'GET';
|
|
207
|
+
secretPath?: string;
|
|
208
|
+
secretEnv?: string;
|
|
209
|
+
body?: Record<string, unknown>;
|
|
197
210
|
}
|
|
198
211
|
|
|
199
212
|
/**
|
|
@@ -391,7 +404,13 @@ export interface FlowResult {
|
|
|
391
404
|
downloads?: FlowDownload[];
|
|
392
405
|
/** F009.125 — the extension this run loaded: its id (read at load time), the
|
|
393
406
|
* build path the caller named, and whether host access was SIMULATED on a copy. */
|
|
394
|
-
extension?: {
|
|
407
|
+
extension?: {
|
|
408
|
+
id: string; path: string; host_access: 'as-built' | 'simulated'; note?: string;
|
|
409
|
+
/** F009.129 — the chrome.storage.local KEYS seeded before step 0 (names only, never
|
|
410
|
+
* values), and which one Lens minted itself. A failed mint never gets here — it
|
|
411
|
+
* ends the run before step 0 naming url + status. */
|
|
412
|
+
storage_local?: { keys: string[]; minted: string | null };
|
|
413
|
+
} | null;
|
|
395
414
|
name: string;
|
|
396
415
|
status: 'pass' | 'fail' | 'error';
|
|
397
416
|
steps: FlowStepResult[];
|
|
@@ -496,8 +515,8 @@ export function parseFlow(input: unknown): Flow {
|
|
|
496
515
|
if (f.extension !== undefined) {
|
|
497
516
|
const e = f.extension as unknown as Record<string, unknown>;
|
|
498
517
|
const keys = e && typeof e === 'object' && !Array.isArray(e) ? Object.keys(e) : null;
|
|
499
|
-
if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) =>
|
|
500
|
-
throw new Error('flow.extension must be {path: "<unpacked build dir>", grant_host_access?: boolean, storage_local?: {…}}');
|
|
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?: {…}}');
|
|
501
520
|
}
|
|
502
521
|
if (e.grant_host_access !== undefined && typeof e.grant_host_access !== 'boolean') {
|
|
503
522
|
throw new Error('flow.extension.grant_host_access must be a boolean');
|
|
@@ -506,10 +525,12 @@ export function parseFlow(input: unknown): Flow {
|
|
|
506
525
|
if (sl !== undefined && (sl === null || typeof sl !== 'object' || Array.isArray(sl))) {
|
|
507
526
|
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');
|
|
508
527
|
}
|
|
528
|
+
const mint = e.storage_local_mint === undefined ? undefined : parseStorageLocalMint(e.storage_local_mint);
|
|
509
529
|
extension = {
|
|
510
530
|
path: e.path,
|
|
511
531
|
...(e.grant_host_access === true ? { grant_host_access: true } : {}),
|
|
512
532
|
...(sl !== undefined ? { storage_local: sl as Record<string, unknown> } : {}),
|
|
533
|
+
...(mint ? { storage_local_mint: mint } : {}),
|
|
513
534
|
};
|
|
514
535
|
}
|
|
515
536
|
validateAndNormalizeSteps(f.steps);
|
|
@@ -562,6 +583,30 @@ function redactQuery(u: string): string {
|
|
|
562
583
|
return q === -1 ? u : `${u.slice(0, q)}?…`;
|
|
563
584
|
}
|
|
564
585
|
|
|
586
|
+
/** F009.129 — refused when malformed, the same way the rest of flow.extension is. */
|
|
587
|
+
function parseStorageLocalMint(raw: unknown): StorageLocalMint {
|
|
588
|
+
const m = raw as Record<string, unknown>;
|
|
589
|
+
const shape = 'flow.extension.storage_local_mint must be {key, url, method?, secretPath | secretEnv, body?}';
|
|
590
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) throw new Error(shape);
|
|
591
|
+
const allowed = ['key', 'url', 'method', 'secretPath', 'secretEnv', 'body'];
|
|
592
|
+
const unknownKey = Object.keys(m).find((k) => !allowed.includes(k));
|
|
593
|
+
if (unknownKey) throw new Error(`${shape} — unknown key "${unknownKey}"`);
|
|
594
|
+
if (typeof m.key !== 'string' || !m.key) throw new Error(`${shape} — key is required`);
|
|
595
|
+
if (typeof m.url !== 'string' || !/^https?:\/\//.test(m.url)) throw new Error(`${shape} — url must be http(s)`);
|
|
596
|
+
if (m.method !== undefined && m.method !== 'POST' && m.method !== 'GET') throw new Error(`${shape} — method must be POST or GET`);
|
|
597
|
+
const hasPath = typeof m.secretPath === 'string' && m.secretPath !== '';
|
|
598
|
+
const hasEnv = typeof m.secretEnv === 'string' && m.secretEnv !== '';
|
|
599
|
+
if (hasPath === hasEnv) throw new Error(`${shape} — give exactly one of secretPath or secretEnv`);
|
|
600
|
+
if (m.body !== undefined && (m.body === null || typeof m.body !== 'object' || Array.isArray(m.body))) throw new Error(`${shape} — body must be an object`);
|
|
601
|
+
return {
|
|
602
|
+
key: m.key,
|
|
603
|
+
url: m.url,
|
|
604
|
+
...(m.method ? { method: m.method as 'POST' | 'GET' } : {}),
|
|
605
|
+
...(hasPath ? { secretPath: m.secretPath as string } : { secretEnv: m.secretEnv as string }),
|
|
606
|
+
...(m.body ? { body: m.body as Record<string, unknown> } : {}),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
565
610
|
function usesPopup(steps: FlowStep[]): boolean {
|
|
566
611
|
return steps.some(
|
|
567
612
|
(st) =>
|
|
@@ -3107,12 +3152,18 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3107
3152
|
watchDownloads(context);
|
|
3108
3153
|
if (ext && flow.extension && prepared) {
|
|
3109
3154
|
extCtx = { context, id: ext.id, main: page, popup: null };
|
|
3110
|
-
|
|
3155
|
+
const mint = flow.extension.storage_local_mint;
|
|
3156
|
+
const seed = {
|
|
3157
|
+
...(flow.extension.storage_local ?? {}),
|
|
3158
|
+
...(mint ? { [mint.key]: await fetchMintJson(mint, env, 'storage_local_mint') } : {}),
|
|
3159
|
+
};
|
|
3160
|
+
if (Object.keys(seed).length) await seedExtensionStorage(context, seed);
|
|
3111
3161
|
extensionInfo = {
|
|
3112
3162
|
id: ext.id,
|
|
3113
3163
|
path: flow.extension.path,
|
|
3114
3164
|
host_access: prepared.simulated ? 'simulated' : 'as-built',
|
|
3115
3165
|
...(prepared.simulated ? { note: SIMULATED_ACCESS_NOTE } : {}),
|
|
3166
|
+
...(Object.keys(seed).length ? { storage_local: { keys: Object.keys(seed), minted: mint?.key ?? null } } : {}),
|
|
3116
3167
|
};
|
|
3117
3168
|
}
|
|
3118
3169
|
// F074.36 — measure the credential BEFORE driving, so a run that cannot
|
package/src/mcp.ts
CHANGED
|
@@ -369,6 +369,7 @@ export const TOOLS = [
|
|
|
369
369
|
path: { type: 'string' },
|
|
370
370
|
grant_host_access: { type: 'boolean' },
|
|
371
371
|
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; 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.' },
|
|
372
373
|
},
|
|
373
374
|
},
|
|
374
375
|
steps: {
|
package/src/service.ts
CHANGED
|
@@ -943,6 +943,15 @@ export async function lensRunFlow(deps: LensDeps, input: RunFlowInput) {
|
|
|
943
943
|
const abs = raw.startsWith('/') ? raw : input.local_path ? `${input.local_path.replace(/\/$/, '')}/${raw}` : null;
|
|
944
944
|
if (!abs) throw new Error('flow.extension.path is relative — pass local_path, or give an absolute path');
|
|
945
945
|
flow.extension = { ...flow.extension, path: resolveSandboxedPath(abs, deps.sandboxRoots) };
|
|
946
|
+
// F009.129 — the mint secret file follows auth.mintEndpoint's rule: relative to
|
|
947
|
+
// the project, and inside the sandbox, so a flow can never point the daemon at
|
|
948
|
+
// another repo's secret and send it to its own url.
|
|
949
|
+
const mint = flow.extension.storage_local_mint;
|
|
950
|
+
if (mint?.secretPath) {
|
|
951
|
+
const sp = mint.secretPath.startsWith('/') ? mint.secretPath : input.local_path ? `${input.local_path.replace(/\/$/, '')}/${mint.secretPath}` : null;
|
|
952
|
+
if (!sp) throw new Error('flow.extension.storage_local_mint.secretPath is relative — pass local_path, or give an absolute path');
|
|
953
|
+
flow.extension = { ...flow.extension, storage_local_mint: { ...mint, secretPath: resolveSandboxedPath(sp, deps.sandboxRoots) } };
|
|
954
|
+
}
|
|
946
955
|
}
|
|
947
956
|
// F082.6 — auth reaches flows: own auth (relative paths resolved) or inherit
|
|
948
957
|
// the manifest's. Fixes the blocker where authed routes redirected to login.
|