@broberg/lens-daemon 0.1.43 → 0.1.45
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 +1 -1
- package/src/browser.ts +13 -3
- package/src/flow.ts +24 -2
- package/src/mcp.ts +1 -1
- package/src/owner-fill.ts +95 -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.45",
|
|
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",
|
package/src/browser.ts
CHANGED
|
@@ -360,6 +360,16 @@ export interface LoadedExtension {
|
|
|
360
360
|
export const EXTENSION_CHANNEL = 'chromium';
|
|
361
361
|
const EXTENSION_LAUNCH_TIMEOUT_MS = 30_000;
|
|
362
362
|
const EXTENSION_LAUNCH_ATTEMPTS = 2;
|
|
363
|
+
// Owner 25 Oct (jam, runs 2e72d502 + b64af4b1): a KEPT profile (cookies, cache,
|
|
364
|
+
// history of a real login) took longer than 30 s to start on M1 at load ~175 and
|
|
365
|
+
// the run failed, while a fresh temp profile started in 26 s. A slow start of a
|
|
366
|
+
// kept profile is waited for; the bound stays, so a launch that truly hangs still
|
|
367
|
+
// fails by name.
|
|
368
|
+
const PROFILE_LAUNCH_TIMEOUT_MS = 90_000;
|
|
369
|
+
/** Exported for test: the launch bound for a run, by whether it opens a kept profile. */
|
|
370
|
+
export function launchTimeoutFor(profile: string | undefined): number {
|
|
371
|
+
return profile ? PROFILE_LAUNCH_TIMEOUT_MS : EXTENSION_LAUNCH_TIMEOUT_MS;
|
|
372
|
+
}
|
|
363
373
|
|
|
364
374
|
async function launchWithExtension(
|
|
365
375
|
opts: WithPageOptions,
|
|
@@ -381,7 +391,7 @@ async function launchWithExtension(
|
|
|
381
391
|
...contextOptions(opts),
|
|
382
392
|
headless: true,
|
|
383
393
|
channel: EXTENSION_CHANNEL,
|
|
384
|
-
timeout:
|
|
394
|
+
timeout: launchTimeoutFor(profile),
|
|
385
395
|
args: [
|
|
386
396
|
`--disable-extensions-except=${extPath}`,
|
|
387
397
|
`--load-extension=${extPath}`,
|
|
@@ -396,7 +406,7 @@ async function launchWithExtension(
|
|
|
396
406
|
}
|
|
397
407
|
if (!context) {
|
|
398
408
|
throw new Error(
|
|
399
|
-
`extension browser did not start (${EXTENSION_LAUNCH_ATTEMPTS} attempts × ${
|
|
409
|
+
`extension browser did not start (${EXTENSION_LAUNCH_ATTEMPTS} attempts × ${launchTimeoutFor(profile)} ms): ${lastLaunchError}`,
|
|
400
410
|
);
|
|
401
411
|
}
|
|
402
412
|
// MV3 registers a service worker; MV2 a background page. Wait for whichever
|
|
@@ -468,7 +478,7 @@ async function launchRunBrowser(opts: WithPageOptions, b: RunBrowser): Promise<{
|
|
|
468
478
|
const context = await trackChromeLaunch(b.channel, () => chromium.launchPersistentContext(userDataDir, {
|
|
469
479
|
...contextOptions({ ...opts, ...(userAgent ? { userAgent } : {}) }),
|
|
470
480
|
...runBrowserLaunchOptions(b),
|
|
471
|
-
timeout:
|
|
481
|
+
timeout: launchTimeoutFor(b.profile),
|
|
472
482
|
}));
|
|
473
483
|
return { context, userDataDir };
|
|
474
484
|
} catch (err) {
|
package/src/flow.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { fillSecret } from './secret-fill.ts';
|
|
|
36
36
|
import { storagePayload, type StorageSeed } from './seed-storage.ts';
|
|
37
37
|
import { passwordInUrl, type FilledPassword, type PasswordInUrlFinding } from './password-in-url.ts';
|
|
38
38
|
import { assertTestMailbox, cleanupMail, fillFromMail, httpMailSource, type MailFrom, type MailSource } from './mail-fill.ts';
|
|
39
|
+
import { fillFromOwner, OWNER_KINDS, OWNER_WAIT_MAX_MS, type OwnerFrom, type OwnerKind } from './owner-fill.ts';
|
|
39
40
|
import {
|
|
40
41
|
EDIT_EVENTS,
|
|
41
42
|
waitForListening,
|
|
@@ -54,7 +55,7 @@ export type ActivateMode = 'auto' | 'click' | 'enter';
|
|
|
54
55
|
* env var. The value is read at run time and never echoed. */
|
|
55
56
|
export type SecretFrom = { path: string } | { env: string };
|
|
56
57
|
/** F009.137 — or a code that arrives by mail (the test mailbox only). */
|
|
57
|
-
export type ValueFrom = SecretFrom | { mail: MailFrom };
|
|
58
|
+
export type ValueFrom = SecretFrom | { mail: MailFrom } | { owner: OwnerFrom };
|
|
58
59
|
|
|
59
60
|
/** F009.123 — a drag by an offset, in css pixels from the source's centre. */
|
|
60
61
|
export type DragOffset = { x: number; y: number };
|
|
@@ -879,7 +880,19 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
|
|
|
879
880
|
const hasValue = typeof (s as { value?: unknown }).value === 'string';
|
|
880
881
|
const vf = (s as { valueFrom?: unknown }).valueFrom;
|
|
881
882
|
if (hasValue && vf !== undefined) throw new Error('fill step takes value OR valueFrom, not both');
|
|
882
|
-
if (!hasValue && vf === undefined) throw new Error('fill step requires a string value or valueFrom {path} | {env} | {mail}');
|
|
883
|
+
if (!hasValue && vf === undefined) throw new Error('fill step requires a string value or valueFrom {path} | {env} | {mail} | {owner}');
|
|
884
|
+
// F010.6 — a value the owner types in ARS.
|
|
885
|
+
if (vf && typeof vf === 'object' && 'owner' in (vf as object)) {
|
|
886
|
+
const o = (vf as { owner?: unknown }).owner as Record<string, unknown> | null;
|
|
887
|
+
if (!o || typeof o !== 'object' || typeof o.question !== 'string' || !o.question.trim() || Object.keys(o).some((k) => !['question', 'kind', 'timeout'].includes(k))) {
|
|
888
|
+
throw new Error('fill valueFrom.owner must be {question: "<what the owner is asked>", kind?, timeout?}');
|
|
889
|
+
}
|
|
890
|
+
if (Object.keys(vf as object).length !== 1) throw new Error('fill valueFrom.owner cannot be combined with another source');
|
|
891
|
+
if (o.kind !== undefined && !OWNER_KINDS.includes(o.kind as OwnerKind)) throw new Error(`fill from owner: kind must be one of ${OWNER_KINDS.join(', ')}`);
|
|
892
|
+
if (o.timeout !== undefined && !(typeof o.timeout === 'number' && o.timeout > 0 && o.timeout <= OWNER_WAIT_MAX_MS)) {
|
|
893
|
+
throw new Error(`fill from owner: timeout must be 1..${OWNER_WAIT_MAX_MS} ms`);
|
|
894
|
+
}
|
|
895
|
+
} else
|
|
883
896
|
// F009.137 — a code from mail: only the test mailbox, checked here at parse time.
|
|
884
897
|
if (vf && typeof vf === 'object' && 'mail' in (vf as object)) {
|
|
885
898
|
const m = (vf as { mail?: unknown }).mail as Record<string, unknown> | null;
|
|
@@ -1282,6 +1295,11 @@ function parseManuscriptStep(s: string): FlowStep {
|
|
|
1282
1295
|
// F009.121 — "fill [pw] from file <path>" / "from env <NAME>": the secret
|
|
1283
1296
|
// is read at run time, so the manuscript (and its label) never holds it.
|
|
1284
1297
|
// F009.137 — "fill [code] from mail to <addr> [subject ~ <text>] [match /<re>/]".
|
|
1298
|
+
// F010.6 — "fill [x] from owner "<question>"" (+ optional "as password|text").
|
|
1299
|
+
const owner = s.match(/\]\s+from\s+owner\s+"([^"]+)"(?:\s+as\s+(code|password|text))?\s*$/i);
|
|
1300
|
+
if (owner) {
|
|
1301
|
+
return { type: 'fill', testid: testid(), valueFrom: { owner: { question: owner[1]!, ...(owner[2] ? { kind: owner[2].toLowerCase() as OwnerKind } : {}) } } };
|
|
1302
|
+
}
|
|
1285
1303
|
const mail = s.match(/\]\s+from\s+mail\s+to\s+(\S+)(.*)$/i);
|
|
1286
1304
|
if (mail) {
|
|
1287
1305
|
const rest = mail[2]!;
|
|
@@ -2144,6 +2162,10 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
|
|
|
2144
2162
|
// F213.7 — fill proves its own write; a silent no-op or a prepended value
|
|
2145
2163
|
// used to pass as success, which is the half of the fleet save-rule this
|
|
2146
2164
|
// instrument could not perform.
|
|
2165
|
+
if (step.valueFrom && 'owner' in step.valueFrom) {
|
|
2166
|
+
await fillFromOwner(loc, step.valueFrom.owner, remaining);
|
|
2167
|
+
return via ? { __via: via } : null;
|
|
2168
|
+
}
|
|
2147
2169
|
if (step.valueFrom && 'mail' in step.valueFrom) {
|
|
2148
2170
|
if (!mailCtx) throw new Error('fill from mail: this run has no mail source');
|
|
2149
2171
|
await fillFromMail(loc, step.valueFrom.mail, stepStart, mailCtx.source, mailCtx.consumed, remaining);
|
package/src/mcp.ts
CHANGED
|
@@ -405,7 +405,7 @@ export const TOOLS = [
|
|
|
405
405
|
{
|
|
406
406
|
name: 'lens_run_manuscript',
|
|
407
407
|
description:
|
|
408
|
-
`Compile a human-readable markdown manuscript (fmc-automation style: "# flow: name", "base: <url>", then one "- <verb> …" per line) into a flow and run it E2E. VERBS (${MANUSCRIPT_VERBS.length}, this is the complete list): ${MANUSCRIPT_VERBS.join(' / ')}. Shapes: "- goto /x" · "- fill [testid] = value" · a PASSWORD without plaintext: "- fill [testid] from file <path>" (absolute, or relative to local_path) or "- fill [testid] from env <NAME>" (F009.121 — password inputs only; the value never appears in the run, the report or a screenshot) · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" or "- drag [node] by 120,-40" (a canvas offset; both move in 10 mousemoves so React Flow / d3-drag nodes really move — "steps N" to change, F009.123) · "- upload [input-testid] = <fixture>" · an input with NO testid, also inside an open shadow root: "- uploadSelector <css> = <fixture>[, <fixture>]" (F009.136; fixtures repo-relative to local_path or data:) · a CODE FROM MAIL: "- fill [testid] from mail to jam@jams.party subject ~ Jam match /\\b\\d{6}\\b/" (F009.137 — waits ≤60 s for a mail received after the step started, read through mail-mcp; only the test mailbox webhouse.developer@gmail.com / jam@jams.party; the mail is deleted after the run, report carries mail {consumed, cleaned}; the code never appears in the report) · the BUILT SCREEN'S HTML after the click path: "- inspect html [testid]" (or "- inspect html" for the whole body) — the step detail is that outerHTML, for cardmem_check_mockup_fidelity's built_html (F009.138) · a WINDOW THE PAGE OPENS (window.open / target=_blank — an OAuth dialog like «Continue with Facebook/Google»): "- waitForPopup" takes it over (also one opened by the click just before), "- in opened: <step>" acts in it, "- waitForPopupClose" waits for it to close; steps without the prefix stay in the opener (F009.139) · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". TWO BROWSERS AT ONCE (F009.122): prefix a step with "in <name>:" — "- in b: goto /" · "- in b: expectText [note] == Hej" — and it runs in its own browser (own cookies + storage), open alongside the default one for the rest of the run; steps without the prefix run in the default browser. Click modifiers: "- click [testid] via enter" (F255.1, for Angular/MDC buttons a mouse click does not activate) and "- click [testid] @ 50%,28%" (F255.4, aim at a point inside a canvas/3D surface). A field that already HOLDS a value may no longer render an input — a combobox typically swaps to its selected state, so "fill" times out there while "press ArrowDown" / "press Enter" still drive it (fd-sundhed, 2026-08-21). The authoring shortcut — write prose, not JSON.`,
|
|
408
|
+
`Compile a human-readable markdown manuscript (fmc-automation style: "# flow: name", "base: <url>", then one "- <verb> …" per line) into a flow and run it E2E. VERBS (${MANUSCRIPT_VERBS.length}, this is the complete list): ${MANUSCRIPT_VERBS.join(' / ')}. Shapes: "- goto /x" · "- fill [testid] = value" · a PASSWORD without plaintext: "- fill [testid] from file <path>" (absolute, or relative to local_path) or "- fill [testid] from env <NAME>" (F009.121 — password inputs only; the value never appears in the run, the report or a screenshot) · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" or "- drag [node] by 120,-40" (a canvas offset; both move in 10 mousemoves so React Flow / d3-drag nodes really move — "steps N" to change, F009.123) · "- upload [input-testid] = <fixture>" · an input with NO testid, also inside an open shadow root: "- uploadSelector <css> = <fixture>[, <fixture>]" (F009.136; fixtures repo-relative to local_path or data:) · a CODE FROM MAIL: "- fill [testid] from mail to jam@jams.party subject ~ Jam match /\\b\\d{6}\\b/" (F009.137 — waits ≤60 s for a mail received after the step started, read through mail-mcp; only the test mailbox webhouse.developer@gmail.com / jam@jams.party; the mail is deleted after the run, report carries mail {consumed, cleaned}; the code never appears in the report) · a VALUE ONLY THE OWNER HAS (an npm/authenticator code, a password): "- fill [testid] from owner \"<question>\"" (add "as password" or "as text"; default code) — asked in ARS through buddy's «ars ask», the owner types it there and nowhere else; declined / expired fail the step by that word; the value never appears in the report (F010.6) · the BUILT SCREEN'S HTML after the click path: "- inspect html [testid]" (or "- inspect html" for the whole body) — the step detail is that outerHTML, for cardmem_check_mockup_fidelity's built_html (F009.138) · a WINDOW THE PAGE OPENS (window.open / target=_blank — an OAuth dialog like «Continue with Facebook/Google»): "- waitForPopup" takes it over (also one opened by the click just before), "- in opened: <step>" acts in it, "- waitForPopupClose" waits for it to close; steps without the prefix stay in the opener (F009.139) · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". TWO BROWSERS AT ONCE (F009.122): prefix a step with "in <name>:" — "- in b: goto /" · "- in b: expectText [note] == Hej" — and it runs in its own browser (own cookies + storage), open alongside the default one for the rest of the run; steps without the prefix run in the default browser. Click modifiers: "- click [testid] via enter" (F255.1, for Angular/MDC buttons a mouse click does not activate) and "- click [testid] @ 50%,28%" (F255.4, aim at a point inside a canvas/3D surface). A field that already HOLDS a value may no longer render an input — a combobox typically swaps to its selected state, so "fill" times out there while "press ArrowDown" / "press Enter" still drive it (fd-sundhed, 2026-08-21). The authoring shortcut — write prose, not JSON.`,
|
|
409
409
|
inputSchema: {
|
|
410
410
|
type: 'object',
|
|
411
411
|
required: ['project', 'manuscript'],
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// F010.6 — fill a field with a value the OWNER types in ARS (Auth Relay Service):
|
|
2
|
+
// an npm one-time code, an authenticator-app code, a password.
|
|
3
|
+
//
|
|
4
|
+
// Owner, 25 Oct 2026: «Jeg skal KUN taste noget ind i ars ikke andre steder», and
|
|
5
|
+
// «ars ask skal bygges ind i buddy». So Lens asks through buddy's `ars ask`, which
|
|
6
|
+
// fetches its own BID ticket, finds the owner's active edge and hands the answer
|
|
7
|
+
// out once on stdout. Exit codes: 0 answer · 2 declined · 3 expired · 1 error.
|
|
8
|
+
//
|
|
9
|
+
// The value never reaches a report, a step detail, an error or a log: it goes from
|
|
10
|
+
// the child's stdout into the field and nowhere else.
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import type { Locator } from 'playwright';
|
|
15
|
+
import { fillAndVerify } from './fill.ts';
|
|
16
|
+
|
|
17
|
+
export type OwnerKind = 'code' | 'password' | 'text';
|
|
18
|
+
|
|
19
|
+
export interface OwnerFrom {
|
|
20
|
+
/** What the owner sees in ARS, e.g. «npm-kode til @broberg/local-guard». */
|
|
21
|
+
question: string;
|
|
22
|
+
/** How ARS renders the field. Default 'code'. */
|
|
23
|
+
kind?: OwnerKind;
|
|
24
|
+
/** How long to wait for the owner, ms. Default {@link OWNER_WAIT_MS}. */
|
|
25
|
+
timeout?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const OWNER_KINDS: readonly OwnerKind[] = ['code', 'password', 'text'];
|
|
29
|
+
export const OWNER_WAIT_MS = 300_000;
|
|
30
|
+
export const OWNER_WAIT_MAX_MS = 600_000; // ARS expires an ask after 10 min at most
|
|
31
|
+
|
|
32
|
+
export type OwnerAnswer =
|
|
33
|
+
| { status: 'answered'; answer: string }
|
|
34
|
+
| { status: 'declined' }
|
|
35
|
+
| { status: 'expired' }
|
|
36
|
+
| { status: 'error'; reason: string };
|
|
37
|
+
|
|
38
|
+
export type OwnerAsker = (q: { kind: OwnerKind; question: string; timeout: number }) => Promise<OwnerAnswer>;
|
|
39
|
+
|
|
40
|
+
// One config value: buddy installs `ars` per edge and has been asked to give it
|
|
41
|
+
// one fixed path (auth-relay #44192). launchd's PATH is NOT searched — it is narrow
|
|
42
|
+
// and differs per edge.
|
|
43
|
+
export function arsBinary(): string {
|
|
44
|
+
return process.env.LENS_ARS_BIN || join(homedir(), '.local', 'bin', 'ars');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const spawnAsker: OwnerAsker = ({ kind, question, timeout }) =>
|
|
48
|
+
new Promise((resolve) => {
|
|
49
|
+
const bin = arsBinary();
|
|
50
|
+
const child = spawn(bin, ['ask', '--kind', kind, '--question', question, '--requester', 'lens'], {
|
|
51
|
+
// The BID ticket names the asker from this, so the owner sees «session lens».
|
|
52
|
+
env: { ...process.env, BUDDY_SESSION_NAME: 'lens' },
|
|
53
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
54
|
+
});
|
|
55
|
+
let out = '';
|
|
56
|
+
let err = '';
|
|
57
|
+
child.stdout.on('data', (b: Buffer) => { out += b.toString('utf8'); });
|
|
58
|
+
child.stderr.on('data', (b: Buffer) => { err += b.toString('utf8'); });
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
child.kill('SIGTERM');
|
|
61
|
+
resolve({ status: 'expired' });
|
|
62
|
+
}, timeout);
|
|
63
|
+
child.on('error', (e) => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
resolve({ status: 'error', reason: `could not run ${bin}: ${e.message}` });
|
|
66
|
+
});
|
|
67
|
+
child.on('exit', (code) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
if (code === 0) resolve({ status: 'answered', answer: out.replace(/\r?\n$/, '') });
|
|
70
|
+
else if (code === 2) resolve({ status: 'declined' });
|
|
71
|
+
else if (code === 3) resolve({ status: 'expired' });
|
|
72
|
+
else resolve({ status: 'error', reason: (err.split('\n')[0] || `ars exited ${code}`).slice(0, 300) });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let asker: OwnerAsker = spawnAsker;
|
|
77
|
+
/** Test seam: replace how the owner is asked (null restores `ars ask`). */
|
|
78
|
+
export function setOwnerAsker(fn: OwnerAsker | null): void {
|
|
79
|
+
asker = fn ?? spawnAsker;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Ask the owner through ARS and type the answer into the field. */
|
|
83
|
+
export async function fillFromOwner(loc: Locator, of: OwnerFrom, fillTimeout: number): Promise<void> {
|
|
84
|
+
const kind = of.kind ?? 'code';
|
|
85
|
+
const res = await asker({ kind, question: of.question, timeout: of.timeout ?? OWNER_WAIT_MS });
|
|
86
|
+
if (res.status === 'declined') throw new Error(`fill from owner: declined — the owner said no to «${of.question}»`);
|
|
87
|
+
if (res.status === 'expired') throw new Error(`fill from owner: expired — no answer to «${of.question}» in time`);
|
|
88
|
+
if (res.status === 'error') throw new Error(`fill from owner: ARS failed — ${res.reason}`);
|
|
89
|
+
if (!res.answer) throw new Error('fill from owner: ARS answered with an empty value');
|
|
90
|
+
try {
|
|
91
|
+
await fillAndVerify(loc, res.answer, fillTimeout);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(`fill from owner did not land: expected a ${res.answer.length}-character ${kind} in the field (value withheld)`);
|
|
94
|
+
}
|
|
95
|
+
}
|