@broberg/lens-daemon 0.1.0 → 0.1.6
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-backfill.ts +6 -20
- package/src/artifact-uploads.ts +34 -7
- package/src/auth/index.ts +7 -0
- package/src/flow.ts +96 -6
- package/src/gate.ts +4 -4
- package/src/mcp.ts +2 -2
- package/src/secret-fill.ts +82 -0
- package/src/service.ts +27 -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.6",
|
|
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.10.2"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@happy-dom/global-registrator": "^20.10.6",
|
package/src/artifact-backfill.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { readdir, stat } from 'node:fs/promises';
|
|
16
16
|
import { join, relative, sep } from 'node:path';
|
|
17
17
|
import type { LensDeps } from './service.ts';
|
|
18
|
-
import { enqueueArtifact, uploadStats } from './artifact-uploads.ts';
|
|
18
|
+
import { SKIP_DIRS, enqueueArtifact, uploadStats } from './artifact-uploads.ts';
|
|
19
19
|
|
|
20
20
|
export interface SkippedDir {
|
|
21
21
|
/** The directory as it exists on disk. */
|
|
@@ -60,28 +60,14 @@ export interface BackfillResult {
|
|
|
60
60
|
*/
|
|
61
61
|
export const DIR_ALIASES: Record<string, string> = {
|
|
62
62
|
xrt81: 'xrt81-com',
|
|
63
|
-
|
|
63
|
+
// Reversed 2026-10-13: cardmem's project is `broberg-ai` again and
|
|
64
|
+
// `broberg-ai-site` no longer exists, so rows written under the old alias
|
|
65
|
+
// are repointed back (4 uploads failed on 2026-09-28 against the dead name).
|
|
66
|
+
'broberg-ai-site': 'broberg-ai',
|
|
64
67
|
'torrent-search-api': 'moovyy',
|
|
65
68
|
};
|
|
66
69
|
|
|
67
|
-
|
|
68
|
-
* Directories that are deliberately NOT uploaded, each with the reason it is held back.
|
|
69
|
-
*
|
|
70
|
-
* These are held back BY DECISION. Today they also happen to fail the project lookup,
|
|
71
|
-
* which means they are currently safe by accident — and an accident is not a policy.
|
|
72
|
-
* Naming them here makes the hold-back survive someone later fixing the lookup.
|
|
73
|
-
*
|
|
74
|
-
* `capture-sessions` is Christian's call, given 2026-08-25 when asked directly whether
|
|
75
|
-
* 329 screenshots of him signing in to App Store Connect and Google Play should go to
|
|
76
|
-
* the cloud: "Nej." No credentials are visible in them (all 329 verified as PNG), and
|
|
77
|
-
* they stay on the machine anyway.
|
|
78
|
-
*/
|
|
79
|
-
export const SKIP_DIRS: Record<string, string> = {
|
|
80
|
-
'capture-sessions': "owner decision 2026-08-25: login recordings stay on the Mac",
|
|
81
|
-
_external: 'screenshots of third-party sites — not this fleet\'s project history',
|
|
82
|
-
'lens-statepath-test': 'one-off engine test run, not project history',
|
|
83
|
-
'lens-mint-test': 'one-off engine test run, not project history',
|
|
84
|
-
};
|
|
70
|
+
export { SKIP_DIRS } from './artifact-uploads.ts';
|
|
85
71
|
|
|
86
72
|
export type Attribution =
|
|
87
73
|
| { kind: 'project'; slug: string }
|
package/src/artifact-uploads.ts
CHANGED
|
@@ -49,11 +49,13 @@ export const UPLOAD_QUEUE_DDL = `
|
|
|
49
49
|
/** Create the queue table if it is not there. Idempotent. */
|
|
50
50
|
export function ensureUploadQueue(db: Database): void {
|
|
51
51
|
db.exec(UPLOAD_QUEUE_DDL);
|
|
52
|
-
// Additive for daemons whose table predates F276.5.
|
|
53
|
-
|
|
52
|
+
// Additive for daemons whose table predates F276.5. Look first rather than
|
|
53
|
+
// swallow the ALTER's error: a catch-all would also hide a locked or
|
|
54
|
+
// read-only database, and the drain would then fail later on a column it
|
|
55
|
+
// believes exists.
|
|
56
|
+
const cols = db.query('PRAGMA table_info(lens_artifact_uploads)').all() as { name: string }[];
|
|
57
|
+
if (!cols.some((c) => c.name === 'claimed_at')) {
|
|
54
58
|
db.exec('ALTER TABLE lens_artifact_uploads ADD COLUMN claimed_at INTEGER');
|
|
55
|
-
} catch {
|
|
56
|
-
// column already exists
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -86,12 +88,37 @@ export const MAX_ATTEMPTS = 5;
|
|
|
86
88
|
* must not create 6588 duplicates on the second pass. Expressing it in the table
|
|
87
89
|
* rather than in a guard means no caller can forget it.
|
|
88
90
|
*/
|
|
91
|
+
/**
|
|
92
|
+
* Directories that are deliberately NOT uploaded, each with the reason it is held back.
|
|
93
|
+
*
|
|
94
|
+
* Applied where a file is QUEUED (enqueueArtifact), not only during a backfill —
|
|
95
|
+
* otherwise a live flow under `_external` would be held back only by the project
|
|
96
|
+
* lookup failing, which is the accident this list exists to replace (F006.4).
|
|
97
|
+
*
|
|
98
|
+
* These are held back BY DECISION. Today they also happen to fail the project lookup,
|
|
99
|
+
* which means they are currently safe by accident — and an accident is not a policy.
|
|
100
|
+
* Naming them here makes the hold-back survive someone later fixing the lookup.
|
|
101
|
+
*
|
|
102
|
+
* `capture-sessions` is Christian's call, given 2026-08-25 when asked directly whether
|
|
103
|
+
* 329 screenshots of him signing in to App Store Connect and Google Play should go to
|
|
104
|
+
* the cloud: "Nej." No credentials are visible in them (all 329 verified as PNG), and
|
|
105
|
+
* they stay on the machine anyway.
|
|
106
|
+
*/
|
|
107
|
+
export const SKIP_DIRS: Record<string, string> = {
|
|
108
|
+
'capture-sessions': "owner decision 2026-08-25: login recordings stay on the Mac",
|
|
109
|
+
_external: 'screenshots of third-party sites — not this fleet\'s project history',
|
|
110
|
+
'lens-statepath-test': 'one-off engine test run, not project history',
|
|
111
|
+
'lens-mint-test': 'one-off engine test run, not project history',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
|
|
89
115
|
export function enqueueArtifact(db: Database, projectSlug: string, key: string | null): void {
|
|
90
116
|
if (!key) return;
|
|
117
|
+
const skipReason = SKIP_DIRS[key.split('/')[0] ?? ''];
|
|
91
118
|
db.prepare(
|
|
92
|
-
`INSERT OR IGNORE INTO lens_artifact_uploads (artifact_key, project_slug, status, enqueued_at)
|
|
93
|
-
VALUES (?, ?,
|
|
94
|
-
).run(key, projectSlug, Date.now());
|
|
119
|
+
`INSERT OR IGNORE INTO lens_artifact_uploads (artifact_key, project_slug, status, enqueued_at, last_error)
|
|
120
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
121
|
+
).run(key, projectSlug, skipReason ? 'skipped' : 'pending', Date.now(), skipReason ?? null);
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
/** Queue several at once — the flow path has one per step. */
|
package/src/auth/index.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// - cookieJwt : a single named cookie holding a JWT. Covers cms.
|
|
20
20
|
|
|
21
21
|
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { isAbsolute } from 'node:path';
|
|
22
23
|
import type { BrowserContext } from 'playwright';
|
|
23
24
|
|
|
24
25
|
export type AuthEnv = Record<string, string | undefined>;
|
|
@@ -199,6 +200,12 @@ async function applyMintEndpoint(
|
|
|
199
200
|
// wins over secretEnv. The secret is NOT in the manifest/call, only its source.
|
|
200
201
|
let secret: string | undefined;
|
|
201
202
|
if (cfg.secretPath) {
|
|
203
|
+
// F009.22 — a relative path here resolves against the DAEMON's cwd, i.e.
|
|
204
|
+
// some other repo's secret, which would then be sent to the caller's url.
|
|
205
|
+
// Callers resolve against their project root first (resolveAuthPaths).
|
|
206
|
+
if (!isAbsolute(cfg.secretPath)) {
|
|
207
|
+
throw new Error(`mintEndpoint auth: secretPath must be absolute by the time it is read (got "${cfg.secretPath}") — register the project or pass an absolute path`);
|
|
208
|
+
}
|
|
202
209
|
try {
|
|
203
210
|
secret = readFileSync(cfg.secretPath, 'utf-8').trim();
|
|
204
211
|
} catch (err) {
|
package/src/flow.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { takeShot, settleForShot } from './capture.ts';
|
|
|
29
29
|
import { runCritic, topFindingMessage, type CriticMode, type CriticReport } from './critic.ts';
|
|
30
30
|
import { waitForBuild, observeBuild } from './build-marker.ts';
|
|
31
31
|
import { fillAndVerify } from './fill.ts';
|
|
32
|
+
import { fillSecret } from './secret-fill.ts';
|
|
32
33
|
import { storagePayload, type StorageSeed } from './seed-storage.ts';
|
|
33
34
|
import { passwordInUrl, type FilledPassword, type PasswordInUrlFinding } from './password-in-url.ts';
|
|
34
35
|
import {
|
|
@@ -44,6 +45,11 @@ import {
|
|
|
44
45
|
/** F255.1 — how a click activates a button. See `clickAndActivate`. */
|
|
45
46
|
export type ActivateMode = 'auto' | 'click' | 'enter';
|
|
46
47
|
|
|
48
|
+
/** F009.121 — where a `fill` reads a SECRET from instead of carrying it inline:
|
|
49
|
+
* a gitignored file (absolute, or relative to the run's local_path) or a daemon
|
|
50
|
+
* env var. The value is read at run time and never echoed. */
|
|
51
|
+
export type ValueFrom = { path: string } | { env: string };
|
|
52
|
+
|
|
47
53
|
export type FlowStep =
|
|
48
54
|
// F074.51 — `timeout` was absent here while every other verb carried it, so a
|
|
49
55
|
// goto's 30s was unoverridable. Found by the typecheck of this card's own
|
|
@@ -62,7 +68,7 @@ export type FlowStep =
|
|
|
62
68
|
// F255.4 — `at` clicks a POINT inside the element ("50%,28%" or "40,120" px)
|
|
63
69
|
// instead of its centre, for canvas/3D surfaces where the centre hits nothing.
|
|
64
70
|
| { type: 'click'; testid?: string; target?: Target; label?: string; timeout?: number; activate?: ActivateMode; at?: string }
|
|
65
|
-
| { type: 'fill'; testid?: string; target?: Target; value
|
|
71
|
+
| { type: 'fill'; testid?: string; target?: Target; value?: string; valueFrom?: ValueFrom; label?: string; timeout?: number }
|
|
66
72
|
| { type: 'select'; testid?: string; target?: Target; value: string; label?: string; timeout?: number }
|
|
67
73
|
| { type: 'check'; testid?: string; target?: Target; label?: string; timeout?: number }
|
|
68
74
|
| { type: 'uncheck'; testid?: string; target?: Target; label?: string; timeout?: number }
|
|
@@ -439,9 +445,48 @@ function isValidTarget(t: unknown): boolean {
|
|
|
439
445
|
return keys.length > 0 && keys.every((k) => TARGET_KEYS.includes(k)) && TARGET_LAYERS.some((k) => k in (t as object));
|
|
440
446
|
}
|
|
441
447
|
|
|
448
|
+
/** F009.119 — the fields each step type uses, beyond the shared ones. Mirrors the
|
|
449
|
+
* FlowStep union above; flow-step-fields.test.ts reads the union from source and
|
|
450
|
+
* fails if the two drift. An unknown field is REFUSED: `inspect` with a guessed
|
|
451
|
+
* `selector` used to run a whole-page dump in silence (cardmem, flow b62ffca3). */
|
|
452
|
+
const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms'];
|
|
453
|
+
const TARGETED = ['testid', 'target'];
|
|
454
|
+
export const STEP_FIELDS: Record<string, string[]> = {
|
|
455
|
+
goto: ['path'],
|
|
456
|
+
click: [...TARGETED, 'activate', 'at'],
|
|
457
|
+
fill: [...TARGETED, 'value', 'valueFrom'],
|
|
458
|
+
select: [...TARGETED, 'value'],
|
|
459
|
+
check: TARGETED,
|
|
460
|
+
uncheck: TARGETED,
|
|
461
|
+
waitFor: TARGETED,
|
|
462
|
+
expectText: [...TARGETED, 'text', 'match'],
|
|
463
|
+
expectVisible: TARGETED,
|
|
464
|
+
expectEditable: TARGETED,
|
|
465
|
+
expectAbsent: ['testid'],
|
|
466
|
+
expectAnimating: [...TARGETED, 'property'],
|
|
467
|
+
expectStill: [...TARGETED, 'property'],
|
|
468
|
+
waitForReady: [...TARGETED, 'events'],
|
|
469
|
+
expectTopmost: TARGETED,
|
|
470
|
+
fillSelector: ['selector', 'value', 'frame'],
|
|
471
|
+
clickSelector: ['selector', 'frame', 'at'],
|
|
472
|
+
waitForBuild: ['sha'],
|
|
473
|
+
waitForUrl: ['url'],
|
|
474
|
+
inspect: ['frame', 'html', 'testid'],
|
|
475
|
+
assert: ['js'],
|
|
476
|
+
screenshot: [],
|
|
477
|
+
clear: TARGETED,
|
|
478
|
+
press: ['key', 'testid'],
|
|
479
|
+
drag: ['testid', 'toTestid'],
|
|
480
|
+
uploadFile: ['testid', 'path'],
|
|
481
|
+
autocomplete: ['testid', 'value', 'option', 'pickText', 'pickIndex'],
|
|
482
|
+
capture: ['testid', 'as', 'from'],
|
|
483
|
+
conditional: ['testid', 'present', 'steps'],
|
|
484
|
+
loop: ['data', 'steps', 'parallel'],
|
|
485
|
+
};
|
|
486
|
+
|
|
442
487
|
function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
|
|
443
488
|
if (depth > 3) throw new Error('flow loops nested too deep (max 3)');
|
|
444
|
-
for (const raw of steps) {
|
|
489
|
+
for (const [index, raw] of steps.entries()) {
|
|
445
490
|
const s = raw as FlowStep & Record<string, unknown>;
|
|
446
491
|
if (!s || typeof s !== 'object' || !STEP_TYPES.has(s.type)) {
|
|
447
492
|
// F074.50 — name the ones that DO exist. The list is rendered from
|
|
@@ -493,6 +538,21 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
|
|
|
493
538
|
if (s.type === 'capture' && typeof (s as { as?: unknown }).as !== 'string') {
|
|
494
539
|
throw new Error('capture step requires a string "as" name');
|
|
495
540
|
}
|
|
541
|
+
// F009.121 — a fill carries its value inline OR names where to read a secret
|
|
542
|
+
// from; never both (which one would win is a guess), never neither.
|
|
543
|
+
if (s.type === 'fill') {
|
|
544
|
+
const hasValue = typeof (s as { value?: unknown }).value === 'string';
|
|
545
|
+
const vf = (s as { valueFrom?: unknown }).valueFrom;
|
|
546
|
+
if (hasValue && vf !== undefined) throw new Error('fill step takes value OR valueFrom, not both');
|
|
547
|
+
if (!hasValue && vf === undefined) throw new Error('fill step requires a string value or valueFrom {path} | {env}');
|
|
548
|
+
if (vf !== undefined) {
|
|
549
|
+
const keys = vf && typeof vf === 'object' && !Array.isArray(vf) ? Object.keys(vf) : [];
|
|
550
|
+
const k = keys[0];
|
|
551
|
+
if (keys.length !== 1 || (k !== 'path' && k !== 'env') || typeof (vf as Record<string, unknown>)[k] !== 'string' || !(vf as Record<string, string>)[k]) {
|
|
552
|
+
throw new Error('fill valueFrom must be exactly one of {path: "<file>"} or {env: "<NAME>"}');
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
496
556
|
// F074.23 — single-anchor steps accept a string testid OR a self-healing
|
|
497
557
|
// `target` (LocateSpec); the rest still require a string testid.
|
|
498
558
|
const targetable = ['click', 'fill', 'select', 'check', 'uncheck', 'waitFor', 'expectText', 'expectVisible', 'expectEditable', 'expectTopmost', 'clear', 'waitForReady', 'expectAnimating', 'expectStill'];
|
|
@@ -564,6 +624,15 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
|
|
|
564
624
|
) {
|
|
565
625
|
throw new Error(`${s.type} step frame must be a string or string[]`);
|
|
566
626
|
}
|
|
627
|
+
// Checked LAST, so a step missing its required field keeps the message that
|
|
628
|
+
// names that field — the more useful one when both are wrong.
|
|
629
|
+
const allowed = [...SHARED_STEP_FIELDS, ...(STEP_FIELDS[s.type] ?? [])];
|
|
630
|
+
const unknown = Object.keys(s).filter((k) => !allowed.includes(k));
|
|
631
|
+
if (unknown.length > 0) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`step ${index} (${s.type}${depth > 0 ? `, nested depth ${depth}` : ''}): unknown field ${unknown.map((k) => JSON.stringify(k)).join(', ')} — ${s.type} accepts: ${allowed.filter((k) => k !== 'type').join(', ')}`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
567
636
|
}
|
|
568
637
|
}
|
|
569
638
|
|
|
@@ -842,6 +911,13 @@ function parseManuscriptStep(s: string): FlowStep {
|
|
|
842
911
|
case 'expectabsent': return { type: 'expectAbsent', testid: testid() };
|
|
843
912
|
case 'expecttopmost': return { type: 'expectTopmost', testid: testid() };
|
|
844
913
|
case 'fill': {
|
|
914
|
+
// F009.121 — "fill [pw] from file <path>" / "from env <NAME>": the secret
|
|
915
|
+
// is read at run time, so the manuscript (and its label) never holds it.
|
|
916
|
+
const from = s.match(/\]\s+from\s+(file|env)\s+(\S.*)$/i);
|
|
917
|
+
if (from) {
|
|
918
|
+
const src = from[2]!.trim();
|
|
919
|
+
return { type: 'fill', testid: testid(), valueFrom: from[1]!.toLowerCase() === 'file' ? { path: src } : { env: src } };
|
|
920
|
+
}
|
|
845
921
|
const v = s.split('=').slice(1).join('=').trim();
|
|
846
922
|
return { type: 'fill', testid: testid(), value: v };
|
|
847
923
|
}
|
|
@@ -1591,7 +1667,7 @@ async function sampleMotion(
|
|
|
1591
1667
|
const MOTION_SAMPLE_MS = 250;
|
|
1592
1668
|
|
|
1593
1669
|
|
|
1594
|
-
async function execStep(page: Page, step: FlowStep, base: string, vars: Record<string, unknown>, critic: CriticMode, localPath?: string, sink?: CriticSink, pwSink?: FilledPassword[], readySink?: NotListeningFinding[]): Promise<Buffer | null | { __inspect: string } | { __via: string; __build?: string; __redirect?: string }> {
|
|
1670
|
+
async function execStep(page: Page, step: FlowStep, base: string, vars: Record<string, unknown>, critic: CriticMode, localPath?: string, sink?: CriticSink, pwSink?: FilledPassword[], readySink?: NotListeningFinding[], secretRoot?: string): Promise<Buffer | null | { __inspect: string } | { __via: string; __build?: string; __redirect?: string }> {
|
|
1595
1671
|
switch (step.type) {
|
|
1596
1672
|
case 'goto': {
|
|
1597
1673
|
const p = subst(step.path, vars);
|
|
@@ -1629,7 +1705,11 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
|
|
|
1629
1705
|
// F213.7 — fill proves its own write; a silent no-op or a prepended value
|
|
1630
1706
|
// used to pass as success, which is the half of the fleet save-rule this
|
|
1631
1707
|
// instrument could not perform.
|
|
1632
|
-
|
|
1708
|
+
if (step.valueFrom) {
|
|
1709
|
+
await fillSecret(loc, step.valueFrom, secretRoot, remaining, pwSink, anchorOf(step));
|
|
1710
|
+
return via ? { __via: via } : null;
|
|
1711
|
+
}
|
|
1712
|
+
const filledValue = subst(step.value ?? '', vars);
|
|
1633
1713
|
await fillAndVerify(loc, filledValue, remaining);
|
|
1634
1714
|
// F074.99 — remember a PASSWORD so the end of the run can notice it coming
|
|
1635
1715
|
// back in the URL. Read from the element rather than guessed from the name:
|
|
@@ -2061,7 +2141,11 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
|
|
|
2061
2141
|
)
|
|
2062
2142
|
.slice(0, 60)
|
|
2063
2143
|
.map(pick);
|
|
2064
|
-
|
|
2144
|
+
// F009.119 — name the full-HTML option where the truncation is seen: two
|
|
2145
|
+
// sessions in two days worked around the 400-char cut without knowing
|
|
2146
|
+
// `html:true` existed.
|
|
2147
|
+
const hint = "each element's html is cut at 400 chars; for one element's FULL outerHTML use {type:'inspect', html:true, testid}";
|
|
2148
|
+
return JSON.stringify({ hint, iframes, inputs, clickables });
|
|
2065
2149
|
}, undefined, { timeout: stepTimeout(step) });
|
|
2066
2150
|
return { __inspect: String(dump) };
|
|
2067
2151
|
}
|
|
@@ -2123,6 +2207,12 @@ export interface RunFlowOptions {
|
|
|
2123
2207
|
/** F213.6 — repo root for resolving an uploadFile step's fixture path. The
|
|
2124
2208
|
* path is sandboxed to this dir (no `../` escape). Absent → only data: URIs. */
|
|
2125
2209
|
localPath?: string;
|
|
2210
|
+
/** F009.121 — the repo root a `fill valueFrom {path}` may read from, ALREADY
|
|
2211
|
+
* checked against the daemon sandbox by the caller. Absent → only {env} works.
|
|
2212
|
+
* Kept apart from localPath on purpose: a secret file is typed into whatever
|
|
2213
|
+
* page the caller named, so an unsandboxed root would let a flow post any file
|
|
2214
|
+
* the daemon can read to any URL. */
|
|
2215
|
+
secretRoot?: string;
|
|
2126
2216
|
/** F255.5 — record the whole driven run to a .webm, returned as FlowResult.video.
|
|
2127
2217
|
* Off by default: recording costs disk + a little time on every run, and most flows
|
|
2128
2218
|
* only need the per-step stills. */
|
|
@@ -2384,7 +2474,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2384
2474
|
// matter most, and a sink scoped to the try is unreachable from the catch.
|
|
2385
2475
|
const sink: CriticSink = { critic: null };
|
|
2386
2476
|
try {
|
|
2387
|
-
const out = await execStep(page, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening);
|
|
2477
|
+
const out = await execStep(page, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
|
|
2388
2478
|
const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
|
|
2389
2479
|
const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
|
|
2390
2480
|
const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
|
package/src/gate.ts
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
// selector instead of a testid is a CONVENTION WARNING (migrate it).
|
|
12
12
|
//
|
|
13
13
|
// 2. decideVerdict() — fold the catalogue result + the anchor report into a
|
|
14
|
-
// single green/red verdict + an action.
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// single green/red verdict + an action. This module only DECIDES; nothing
|
|
15
|
+
// here executes it. Today POST /lens/gate returns it as JSON, and cardmem's
|
|
16
|
+
// F095 Full Auto Review is what moves a green card to Done. A notice on a
|
|
17
|
+
// red verdict is not built — that is F009.120.
|
|
18
18
|
|
|
19
19
|
import type { LensManifest } from './manifest.ts';
|
|
20
20
|
|
package/src/mcp.ts
CHANGED
|
@@ -321,7 +321,7 @@ export const TOOLS = [
|
|
|
321
321
|
// A hand-written list in a tool description is the only thing most callers
|
|
322
322
|
// ever read, and ours had drifted to 9 of 26: three repos reported an
|
|
323
323
|
// existing verb as missing because of it.
|
|
324
|
-
`Run an E2E manuscript flow: drive a site step-by-step in one authed page. Steps (${[...STEP_TYPES].length}): ${[...STEP_TYPES].join(' / ')}. testid-anchored steps use [data-testid]; fillSelector/clickSelector use a raw CSS selector + optional cross-origin frame chain to reach a 3rd-party iframe a testid can\'t (e.g. Stripe PCI card fields), and waitForUrl asserts the redirect back to our origin. Stops + pins a screenshot on the first failing step. Returns a per-step report with screenshot_url per step. This is full End-to-End — it can create users, submit forms, complete a Stripe test payment, etc. (set mutates:true for write flows). Scoped to the project slug.`,
|
|
324
|
+
`Run an E2E manuscript flow: drive a site step-by-step in one authed page. Steps (${[...STEP_TYPES].length}): ${[...STEP_TYPES].join(' / ')}. testid-anchored steps use [data-testid]; a fill can take valueFrom:{path}|{env} instead of value to type a password without it ever appearing in the run (F009.121, password inputs only); fillSelector/clickSelector use a raw CSS selector + optional cross-origin frame chain to reach a 3rd-party iframe a testid can\'t (e.g. Stripe PCI card fields), and waitForUrl asserts the redirect back to our origin. Stops + pins a screenshot on the first failing step. Returns a per-step report with screenshot_url per step. This is full End-to-End — it can create users, submit forms, complete a Stripe test payment, etc. (set mutates:true for write flows). Scoped to the project slug.`,
|
|
325
325
|
inputSchema: {
|
|
326
326
|
type: 'object',
|
|
327
327
|
required: ['project', 'flow'],
|
|
@@ -373,7 +373,7 @@ export const TOOLS = [
|
|
|
373
373
|
{
|
|
374
374
|
name: 'lens_run_manuscript',
|
|
375
375
|
description:
|
|
376
|
-
`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" · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" · "- upload [input-testid] = <fixture>" · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". 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.`,
|
|
376
|
+
`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]" · "- upload [input-testid] = <fixture>" · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". 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.`,
|
|
377
377
|
inputSchema: {
|
|
378
378
|
type: 'object',
|
|
379
379
|
required: ['project', 'manuscript'],
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// F009.121 — fill a password field from a file or env var, without the secret
|
|
2
|
+
// ever appearing in the manuscript, the step label, the run report or the stored
|
|
3
|
+
// row. Asked for by scout (F001.4) and broberg-id (F084.97): both prove a real
|
|
4
|
+
// login form with a test account, and the only way before was the plaintext
|
|
5
|
+
// password in the manuscript.
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { resolve, sep } from 'node:path';
|
|
8
|
+
import type { Locator } from 'playwright';
|
|
9
|
+
import { fillAndVerify } from './fill.ts';
|
|
10
|
+
import type { FilledPassword } from './password-in-url.ts';
|
|
11
|
+
import type { ValueFrom } from './flow.ts';
|
|
12
|
+
|
|
13
|
+
/** Read the secret. Every error names WHERE it looked and never WHAT it found. */
|
|
14
|
+
export async function readSecret(
|
|
15
|
+
vf: ValueFrom,
|
|
16
|
+
secretRoot: string | undefined,
|
|
17
|
+
env: Record<string, string | undefined> = process.env,
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
if ('env' in vf) {
|
|
20
|
+
const v = env[vf.env];
|
|
21
|
+
if (!v) throw new Error(`fill valueFrom: env ${vf.env} is not set in the daemon`);
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
24
|
+
// The file is typed into whatever page the caller named, so it must live
|
|
25
|
+
// inside a repo the daemon sandbox allows (secretRoot is checked by the
|
|
26
|
+
// caller) — never anywhere else the daemon user can read. Same containment
|
|
27
|
+
// rule as an uploadFile fixture.
|
|
28
|
+
if (!secretRoot) {
|
|
29
|
+
throw new Error('fill valueFrom {path} needs local_path inside the daemon sandbox — the secret file must live in that repo (use {env} otherwise)');
|
|
30
|
+
}
|
|
31
|
+
const root = resolve(secretRoot);
|
|
32
|
+
const p = resolve(root, vf.path);
|
|
33
|
+
if (p !== root && !p.startsWith(root + sep)) {
|
|
34
|
+
throw new Error(`fill valueFrom: path escapes the repo root: ${vf.path}`);
|
|
35
|
+
}
|
|
36
|
+
let raw: string;
|
|
37
|
+
try {
|
|
38
|
+
raw = await readFile(p, 'utf-8');
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const code = (err as NodeJS.ErrnoException)?.code ?? 'unreadable';
|
|
41
|
+
throw new Error(`fill valueFrom: could not read ${p} (${code})`);
|
|
42
|
+
}
|
|
43
|
+
// One trailing newline is an editor's, not the password's. vault-get.sh writes
|
|
44
|
+
// byte-exact, so this only ever removes something a human added.
|
|
45
|
+
const v = raw.replace(/\r?\n$/, '');
|
|
46
|
+
if (!v) throw new Error(`fill valueFrom: ${p} is empty`);
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Type the secret into a PASSWORD field and prove it landed — reporting only
|
|
51
|
+
* the shape of a mismatch, never characters. */
|
|
52
|
+
export async function fillSecret(
|
|
53
|
+
loc: Locator,
|
|
54
|
+
vf: ValueFrom,
|
|
55
|
+
secretRoot: string | undefined,
|
|
56
|
+
timeout: number,
|
|
57
|
+
pwSink: FilledPassword[] | undefined,
|
|
58
|
+
anchor: string,
|
|
59
|
+
env: Record<string, string | undefined> = process.env,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
// A password input masks itself in every screenshot. Any other field would
|
|
62
|
+
// print the secret into the next still, so it is refused rather than filled.
|
|
63
|
+
const isPassword = await loc
|
|
64
|
+
.evaluate((el) => (el as HTMLInputElement).type === 'password', undefined, { timeout })
|
|
65
|
+
.catch(() => false);
|
|
66
|
+
if (!isPassword) {
|
|
67
|
+
throw new Error('fill valueFrom only fills a password input (type="password") — any other field would show the secret in every later screenshot');
|
|
68
|
+
}
|
|
69
|
+
const secret = await readSecret(vf, secretRoot, env);
|
|
70
|
+
try {
|
|
71
|
+
await fillAndVerify(loc, secret, timeout);
|
|
72
|
+
} catch {
|
|
73
|
+
// fillAndVerify's message quotes expected and actual values. For a secret
|
|
74
|
+
// that IS the leak, so only the length is reported.
|
|
75
|
+
const got = await loc.inputValue({ timeout: 1000 }).catch(() => null);
|
|
76
|
+
throw new Error(
|
|
77
|
+
`fill did not land: expected ${secret.length} characters, the field holds ${got === null ? 'an unreadable value' : `${got.length}`} (value withheld — it is a secret)`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
// F074.99 — the password-in-URL check still sees it, in memory only.
|
|
81
|
+
pwSink?.push({ anchor, value: secret });
|
|
82
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// dashboard + cc point <img>/links at the daemon, the same way the rest of
|
|
9
9
|
// the SPA already talks to 127.0.0.1:7475.
|
|
10
10
|
|
|
11
|
+
import { resolveSandboxedPath } from './sandbox.ts';
|
|
11
12
|
import { randomUUID } from 'node:crypto';
|
|
12
13
|
import sharp from 'sharp';
|
|
13
14
|
import { durabilityOf } from './durability.ts';
|
|
@@ -299,6 +300,19 @@ function projectLocale(deps: LensDeps, project: string): { locale?: string; time
|
|
|
299
300
|
return row?.local_path ? manifestLocale(row.local_path, deps.sandboxRoots) : null;
|
|
300
301
|
}
|
|
301
302
|
|
|
303
|
+
/** F009.121 — the root a `fill valueFrom {path}` may read under: the caller's
|
|
304
|
+
* local_path, but only once the daemon sandbox has accepted it. Outside the
|
|
305
|
+
* sandbox (or absent) there is no root, so only {env} works — a secret file is
|
|
306
|
+
* typed into a page the caller chose, and must not be any file the daemon can read. */
|
|
307
|
+
export function secretRootOf(localPath: string | undefined, sandboxRoots: string[]): string | undefined {
|
|
308
|
+
if (!localPath) return undefined;
|
|
309
|
+
try {
|
|
310
|
+
return resolveSandboxedPath(localPath, sandboxRoots);
|
|
311
|
+
} catch {
|
|
312
|
+
return undefined; // refused by the sandbox; fill reports "needs local_path inside the daemon sandbox"
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
302
316
|
export function resolveVerifyAuth(
|
|
303
317
|
deps: LensDeps,
|
|
304
318
|
project: string,
|
|
@@ -308,7 +322,17 @@ export function resolveVerifyAuth(
|
|
|
308
322
|
const row = deps.db
|
|
309
323
|
.query('SELECT local_path FROM registered_projects WHERE slug = ?')
|
|
310
324
|
.get(project) as { local_path: string } | undefined;
|
|
311
|
-
if (!row?.local_path)
|
|
325
|
+
if (!row?.local_path) {
|
|
326
|
+
// F009.22 — a RELATIVE credential path with no registered project would be
|
|
327
|
+
// resolved against the daemon's own cwd, i.e. read another repo's secret
|
|
328
|
+
// and send it to whatever url the caller named. Refuse instead.
|
|
329
|
+
if (resolveAuthPaths(auth, '/') !== auth) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`project "${project}" is not registered, so a relative ${auth.adapter === 'storageState' ? 'statePath' : 'secretPath'} cannot be resolved — register the project or pass an absolute path`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return auth;
|
|
335
|
+
}
|
|
312
336
|
return (resolveAuthPaths(auth, row.local_path) as AuthConfig | undefined) ?? auth;
|
|
313
337
|
}
|
|
314
338
|
|
|
@@ -916,6 +940,7 @@ export async function lensRunFlow(deps: LensDeps, input: RunFlowInput) {
|
|
|
916
940
|
viewport: coerceViewport(input.viewport, input.device),
|
|
917
941
|
critic: input.critic,
|
|
918
942
|
localPath: input.local_path, // F213.6 — sandbox root for uploadFile fixture paths
|
|
943
|
+
secretRoot: secretRootOf(input.local_path, deps.sandboxRoots), // F009.121
|
|
919
944
|
record: input.record === true, // F255.5 — opt-in .webm of the whole run
|
|
920
945
|
storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
|
|
921
946
|
});
|
|
@@ -978,6 +1003,7 @@ export async function lensRunManuscript(deps: LensDeps, input: RunManuscriptInpu
|
|
|
978
1003
|
reducedMotion: input.reduced_motion,
|
|
979
1004
|
viewport: coerceViewport(input.viewport, input.device), // F252.2 — mobile-only UI E2E in manuscripts
|
|
980
1005
|
localPath: input.local_path, // F213.6 — uploadFile fixture root
|
|
1006
|
+
secretRoot: secretRootOf(input.local_path, deps.sandboxRoots), // F009.121
|
|
981
1007
|
record: input.record === true, // F255.5 — opt-in .webm of the whole run
|
|
982
1008
|
storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
|
|
983
1009
|
});
|