@broberg/lens-daemon 0.1.25 → 0.1.28
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/flow-store.ts +10 -0
- package/src/flow.ts +28 -1
- package/src/frames.ts +5 -0
- package/src/http.ts +3 -0
- package/src/local-guard.ts +41 -0
- package/src/mcp.ts +7 -0
- package/src/network-shapes.ts +344 -0
- package/src/record-run.ts +9 -1
- package/src/service.ts +5 -0
- package/src/storage.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"sharp": "^0.33.5",
|
|
16
16
|
"turndown": "^7.2.4",
|
|
17
17
|
"zod": "^3.23.8",
|
|
18
|
-
"@broberg/lens-
|
|
19
|
-
"@broberg/lens-
|
|
18
|
+
"@broberg/lens-engine": "0.14.0",
|
|
19
|
+
"@broberg/lens-svg": "0.1.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@happy-dom/global-registrator": "^20.10.6",
|
package/src/flow-store.ts
CHANGED
|
@@ -122,6 +122,15 @@ export async function recordFlowRun(
|
|
|
122
122
|
storedDownloads.push({ ...meta, ...(key ? { key } : {}) });
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// F009.133 — shapes can run to hundreds of KB; the row keeps counts + the key.
|
|
126
|
+
let networkShapes: { requests: number; websockets: number; other_hosts: number; caps_hit: string[]; key: string } | null = null;
|
|
127
|
+
if (result.network_shapes) {
|
|
128
|
+
const key = artifactKeys.flowNetworkShapes(projectSlug, id);
|
|
129
|
+
await storage.put(key, Buffer.from(JSON.stringify(result.network_shapes, null, 1)), 'application/json');
|
|
130
|
+
const n = result.network_shapes;
|
|
131
|
+
networkShapes = { requests: n.requests.length, websockets: n.websockets.length, other_hosts: n.other_hosts.length, caps_hit: n.caps_hit, key };
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
const row: LensFlowRunRow = {
|
|
126
135
|
id,
|
|
127
136
|
project_slug: projectSlug,
|
|
@@ -173,6 +182,7 @@ export async function recordFlowRun(
|
|
|
173
182
|
...(result.not_listening ? { not_listening: result.not_listening } : {}),
|
|
174
183
|
// F009.128 — always written when the run reported it, empty included.
|
|
175
184
|
...(result.downloads ? { downloads: storedDownloads } : {}),
|
|
185
|
+
...(networkShapes ? { network_shapes: networkShapes } : {}),
|
|
176
186
|
}),
|
|
177
187
|
};
|
|
178
188
|
|
package/src/flow.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type Target,
|
|
25
25
|
} from '@broberg/lens-engine';
|
|
26
26
|
import { withPage, type LensEngine } from './browser.ts';
|
|
27
|
+
import { collectNetworkShapes, type NetworkShapesOptions, type NetworkShapesReport } from './network-shapes.ts';
|
|
27
28
|
import { deadline, defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
|
|
28
29
|
import { authPrepare, fetchMintJson, persistStorageState, persistTarget, type AuthConfig, type AuthEnv } from './auth/index.ts';
|
|
29
30
|
import { takeShot, settleForShot } from './capture.ts';
|
|
@@ -185,6 +186,9 @@ export interface Flow {
|
|
|
185
186
|
* standing in for the toolbar click (activeTab) a headless run cannot make;
|
|
186
187
|
* the original build is never written, and the report says it was simulated. */
|
|
187
188
|
extension?: FlowExtension;
|
|
189
|
+
/** F009.133 — record the SHAPE (never a value) of every call the page makes:
|
|
190
|
+
* fetch/xhr request + response bodies and WebSocket frames. */
|
|
191
|
+
network_shapes?: true | NetworkShapesOptions;
|
|
188
192
|
steps: FlowStep[];
|
|
189
193
|
}
|
|
190
194
|
|
|
@@ -403,6 +407,8 @@ export async function finishDownloads(pending: Promise<void>[], downloads: FlowD
|
|
|
403
407
|
}
|
|
404
408
|
|
|
405
409
|
export interface FlowResult {
|
|
410
|
+
/** F009.133 — present when flow.network_shapes was asked for. */
|
|
411
|
+
network_shapes?: NetworkShapesReport;
|
|
406
412
|
/** F009.128 — files the run downloaded. Always present on a finished run. */
|
|
407
413
|
downloads?: FlowDownload[];
|
|
408
414
|
/** F009.131 — set when auth.persist was asked for: whether the renewed login was
|
|
@@ -545,6 +551,18 @@ export function parseFlow(input: unknown): Flow {
|
|
|
545
551
|
}
|
|
546
552
|
persistTarget(f.auth as AuthConfig | undefined); // F009.131 — refuse a persist that cannot be honoured
|
|
547
553
|
validateAndNormalizeSteps(f.steps);
|
|
554
|
+
// F009.133 — refused when malformed, for the same reason as extension above.
|
|
555
|
+
let networkShapes: true | NetworkShapesOptions | undefined;
|
|
556
|
+
if (f.network_shapes !== undefined) {
|
|
557
|
+
const n = f.network_shapes as unknown;
|
|
558
|
+
const strings = (v: unknown) => v === undefined || (Array.isArray(v) && v.every((x) => typeof x === 'string' && x.length > 0));
|
|
559
|
+
if (n === true) networkShapes = true;
|
|
560
|
+
else if (n && typeof n === 'object' && !Array.isArray(n) && Object.keys(n).every((k) => k === 'hosts' || k === 'reveal') && strings((n as NetworkShapesOptions).hosts) && strings((n as NetworkShapesOptions).reveal)) {
|
|
561
|
+
networkShapes = n as NetworkShapesOptions;
|
|
562
|
+
} else {
|
|
563
|
+
throw new Error('flow.network_shapes must be true or {hosts?: ["<host>"], reveal?: ["<key>"]}');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
548
566
|
if (!extension && usesPopup(f.steps as FlowStep[])) {
|
|
549
567
|
throw new Error('openPopup / in:"popup" need flow.extension — there is no extension loaded to open a popup from');
|
|
550
568
|
}
|
|
@@ -556,6 +574,7 @@ export function parseFlow(input: unknown): Flow {
|
|
|
556
574
|
...(f.wait_for_build !== undefined ? { wait_for_build: f.wait_for_build } : {}),
|
|
557
575
|
...(f.eject_grace_ms !== undefined ? { eject_grace_ms: f.eject_grace_ms } : {}),
|
|
558
576
|
...(extension ? { extension } : {}),
|
|
577
|
+
...(networkShapes ? { network_shapes: networkShapes } : {}),
|
|
559
578
|
steps: f.steps as FlowStep[],
|
|
560
579
|
};
|
|
561
580
|
}
|
|
@@ -3028,6 +3047,9 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3028
3047
|
// F009.128 — files the run downloads. A download is awaited before the context
|
|
3029
3048
|
// closes; otherwise the bytes die with the browser and the report says nothing.
|
|
3030
3049
|
const downloads: FlowDownload[] = [];
|
|
3050
|
+
// F009.133 — the collector lives on the main context; read out before it closes.
|
|
3051
|
+
const shapesRef: { c: ReturnType<typeof collectNetworkShapes> | null } = { c: null };
|
|
3052
|
+
let networkShapesReport: NetworkShapesReport | undefined;
|
|
3031
3053
|
const persistPath = persistTarget(flow.auth);
|
|
3032
3054
|
let authPersisted: FlowResult['auth_persisted'] = persistPath ? { ok: false, reason: 'the run ended before the login could be saved' } : undefined;
|
|
3033
3055
|
const pendingDownloads: Promise<void>[] = [];
|
|
@@ -3160,6 +3182,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3160
3182
|
},
|
|
3161
3183
|
async (page, context, ext) => {
|
|
3162
3184
|
videoPage = page;
|
|
3185
|
+
if (flow.network_shapes) shapesRef.c = collectNetworkShapes(context, flow.network_shapes === true ? {} : flow.network_shapes);
|
|
3163
3186
|
collectFailures(page);
|
|
3164
3187
|
watchRequests(context);
|
|
3165
3188
|
watchDownloads(context);
|
|
@@ -3286,6 +3309,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3286
3309
|
// Bounded: a download that never finishes must not hang the run. Whatever is
|
|
3287
3310
|
// still unfinished is reported as such, never dropped.
|
|
3288
3311
|
await finishDownloads(pendingDownloads, downloads, DOWNLOAD_FINISH_MS);
|
|
3312
|
+
if (shapesRef.c) networkShapesReport = await shapesRef.c.finish();
|
|
3289
3313
|
// F009.131 — after the last step, pass or fail, while the context is open.
|
|
3290
3314
|
if (persistPath) {
|
|
3291
3315
|
try {
|
|
@@ -3301,8 +3325,10 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3301
3325
|
reason = reason ?? (err instanceof Error ? err.message : String(err));
|
|
3302
3326
|
// Keep the recording even on a crash — watching HOW it broke is the whole point.
|
|
3303
3327
|
const video = await readVideo();
|
|
3328
|
+
// F009.133 — what was collected before the crash is still in memory; say it.
|
|
3329
|
+
if (shapesRef.c && !networkShapesReport) networkShapesReport = await shapesRef.c.finish();
|
|
3304
3330
|
await prepared?.cleanup();
|
|
3305
|
-
return { downloads, ...(authPersisted ? { auth_persisted: authPersisted } : {}), ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
|
|
3331
|
+
return { downloads, ...(networkShapesReport ? { network_shapes: networkShapesReport } : {}), ...(authPersisted ? { auth_persisted: authPersisted } : {}), ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
|
|
3306
3332
|
}
|
|
3307
3333
|
|
|
3308
3334
|
const video = await readVideo();
|
|
@@ -3357,6 +3383,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3357
3383
|
reduced_motion: opts.reducedMotion ?? 'reduce',
|
|
3358
3384
|
...(authPersisted ? { auth_persisted: authPersisted } : {}),
|
|
3359
3385
|
...(extensionInfo ? { extension: extensionInfo } : {}),
|
|
3386
|
+
...(networkShapesReport ? { network_shapes: networkShapesReport } : {}),
|
|
3360
3387
|
downloads,
|
|
3361
3388
|
};
|
|
3362
3389
|
}
|
package/src/frames.ts
CHANGED
|
@@ -49,6 +49,11 @@ export async function cutFrames(
|
|
|
49
49
|
bin = 'ffmpeg',
|
|
50
50
|
): Promise<CutResult> {
|
|
51
51
|
const fps = opts.fps ?? 4;
|
|
52
|
+
// fps is pasted into an ffmpeg filter string: a non-number there is a second
|
|
53
|
+
// filter (H4, 2026-10-21 — drawtext=textfile=… drew a file into a frame).
|
|
54
|
+
if (typeof fps !== 'number' || !Number.isFinite(fps) || fps <= 0 || fps > 120) {
|
|
55
|
+
throw new Error(`cutFrames: fps must be a number above 0 and at most 120 — got ${JSON.stringify(fps)}`);
|
|
56
|
+
}
|
|
52
57
|
if (!(await ffmpegAvailable(bin))) {
|
|
53
58
|
return { frames: [], unavailable: `${bin} is not installed — video kept, no stills` };
|
|
54
59
|
}
|
package/src/http.ts
CHANGED
|
@@ -31,9 +31,12 @@ import {
|
|
|
31
31
|
import { drainUploads, uploadStats } from './artifact-uploads.ts';
|
|
32
32
|
import { backfillFromDisk } from './artifact-backfill.ts';
|
|
33
33
|
import { artifactContentType } from './storage.ts';
|
|
34
|
+
import { localRequestGuard } from './local-guard.ts';
|
|
34
35
|
|
|
35
36
|
export function createLensRouter(deps: LensDeps): Hono {
|
|
36
37
|
const r = new Hono();
|
|
38
|
+
// Security sweep 2026-10-21 (H1–H4): loopback Host + JSON writes only. First.
|
|
39
|
+
r.use('*', localRequestGuard(deps.port));
|
|
37
40
|
// lens F001.5 — the daemon's sandbox is injected, never copied (sandbox.ts).
|
|
38
41
|
if (deps.resolveSandboxedPath) setSandboxResolver(deps.resolveSandboxedPath);
|
|
39
42
|
// lens F001.5 — the routes that used to be registered one by one in the
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Security sweep 2026-10-21, high findings H1–H4: the daemon on 127.0.0.1 took
|
|
2
|
+
// a call from anyone who could make a browser send one — a DNS-rebound page
|
|
3
|
+
// (it never checked Host) or any site's "simple" text/plain POST (it parsed JSON
|
|
4
|
+
// whatever the content type). Through that door a page could read any file
|
|
5
|
+
// (/lens/artifact), drive the owner's logged-in profile, mint a secret to its
|
|
6
|
+
// own URL, or approve a baseline.
|
|
7
|
+
//
|
|
8
|
+
// Two rules close it without a token, because a browser cannot break either:
|
|
9
|
+
// · Host must name this loopback daemon — a rebound page sends its own name.
|
|
10
|
+
// · A write (POST/PUT/PATCH/DELETE) must be application/json — a cross-site
|
|
11
|
+
// page cannot send that without a CORS preflight, and the preflight is only
|
|
12
|
+
// granted to the daemon shell's own allowlisted origins.
|
|
13
|
+
// Lens' own Chromium visiting a hostile page is the same case as any browser.
|
|
14
|
+
//
|
|
15
|
+
// A non-browser caller (curl, an MCP client, the cardmem SPA via CORS) already
|
|
16
|
+
// sends both, so nothing legitimate changes.
|
|
17
|
+
|
|
18
|
+
import type { MiddlewareHandler } from 'hono';
|
|
19
|
+
|
|
20
|
+
const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
21
|
+
|
|
22
|
+
export function localRequestGuard(port: number): MiddlewareHandler {
|
|
23
|
+
const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
24
|
+
return async (c, next) => {
|
|
25
|
+
// A server request carries Host; Bun builds c.req.url from it, so the URL's
|
|
26
|
+
// host is the same value when the header is not on the Request object.
|
|
27
|
+
const host = (c.req.header('host') ?? new URL(c.req.url).host).toLowerCase();
|
|
28
|
+
if (!hosts.has(host)) {
|
|
29
|
+
return c.json({ ok: false, error: `lens daemon: refused Host "${host}" — only this machine's loopback address may call it` }, 421);
|
|
30
|
+
}
|
|
31
|
+
if (WRITE.has(c.req.method)) {
|
|
32
|
+
const type = (c.req.header('content-type') ?? '').split(';')[0]!.trim().toLowerCase();
|
|
33
|
+
// A body-less DELETE (the MCP session close) carries no content type.
|
|
34
|
+
const empty = c.req.method === 'DELETE' && !c.req.header('content-length') && !type;
|
|
35
|
+
if (type !== 'application/json' && !empty) {
|
|
36
|
+
return c.json({ ok: false, error: 'lens daemon: a write must be Content-Type: application/json' }, 415);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
await next();
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { Hono } from 'hono';
|
|
11
11
|
import { MANUSCRIPT_VERBS, STEP_TYPES } from './flow.ts';
|
|
12
|
+
import { localRequestGuard } from './local-guard.ts';
|
|
12
13
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
13
14
|
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
14
15
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -361,6 +362,11 @@ export const TOOLS = [
|
|
|
361
362
|
'F255.7 — do not start until the app is serving the build under test. A commit sha waits for THAT build; true waits for any change from the build live at run start. Polls GET /api/health for a `build` field (the fleet contract), from the runner with no-store, requiring 3 consecutive matching reads so a fleet mid-rollout cannot end the wait early. Fails loudly if the app exposes no marker. Use it right after a deploy — otherwise a green run may be verifying the build you just replaced.',
|
|
362
363
|
},
|
|
363
364
|
auth: authSchema,
|
|
365
|
+
network_shapes: {
|
|
366
|
+
type: ['boolean', 'object'],
|
|
367
|
+
description:
|
|
368
|
+
'F009.133 — true or {hosts?:["<host>"], reveal?:["<key>"]}: record the SHAPE (keys, types, string lengths — never a value) of every fetch/xhr call to the page\'s own host (+ hosts): method, path, query keys, status, request-body and response-body shape (JSON, Google )]}\' chunks, SSE, form, SignalR), plus every WebSocket frame. Other hosts are only counted. reveal keeps the value of those named keys (e.g. "rpcids", "target"). The report lands as an artifact; findings.network_shapes carries counts + url.',
|
|
369
|
+
},
|
|
364
370
|
extension: {
|
|
365
371
|
type: 'object',
|
|
366
372
|
required: ['path'],
|
|
@@ -541,6 +547,7 @@ async function dispatch(deps: LensDeps, name: string, args: any): Promise<unknow
|
|
|
541
547
|
|
|
542
548
|
export function createLensMcpRouter(deps: LensDeps): Hono {
|
|
543
549
|
const router = new Hono();
|
|
550
|
+
router.use('*', localRequestGuard(deps.port));
|
|
544
551
|
router.all('/', async (c) => {
|
|
545
552
|
const server = new Server(
|
|
546
553
|
{ name: 'cardmem-lens', version: '0.1.0' },
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// F009.133 — the SHAPE of every call a page makes, never a value.
|
|
2
|
+
//
|
|
3
|
+
// components builds data readers for AI chat services and needs to know which
|
|
4
|
+
// call carries a conversation and what its request and answer look like. The
|
|
5
|
+
// conversation itself must not leave the run, so bodies are read here, reduced
|
|
6
|
+
// to a shape in memory, and only the shape is kept. The one exception is a key
|
|
7
|
+
// the caller NAMES in `reveal` (Gemini's `rpcids`, SignalR's `target`): those
|
|
8
|
+
// are protocol identifiers, and the identifier is the answer being asked for.
|
|
9
|
+
import type { BrowserContext, Page, Request, Response, WebSocket } from 'playwright';
|
|
10
|
+
|
|
11
|
+
export type Shape = string | Shape[] | { [k: string]: Shape };
|
|
12
|
+
|
|
13
|
+
export interface NetworkShapesOptions {
|
|
14
|
+
/** Hosts shaped besides the page's own (e.g. a chat service's WebSocket host). */
|
|
15
|
+
hosts?: string[];
|
|
16
|
+
/** Keys whose string VALUE is kept (query, form and JSON object keys alike). */
|
|
17
|
+
reveal?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ShapedRequest {
|
|
21
|
+
method: string;
|
|
22
|
+
/** origin + path; the query is in `query` as keys + shapes. */
|
|
23
|
+
url: string;
|
|
24
|
+
query?: Record<string, Shape>;
|
|
25
|
+
status: number;
|
|
26
|
+
content_type: string;
|
|
27
|
+
request_shape?: Shape;
|
|
28
|
+
response_shape?: Shape;
|
|
29
|
+
/** Identical calls (same method, url, shapes, status) are merged. */
|
|
30
|
+
count: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FrameShapes {
|
|
34
|
+
frames: number;
|
|
35
|
+
shapes: { shape: Shape; count: number }[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ShapedSocket {
|
|
39
|
+
url: string;
|
|
40
|
+
sent: FrameShapes;
|
|
41
|
+
received: FrameShapes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface NetworkShapesReport {
|
|
45
|
+
page_hosts: string[];
|
|
46
|
+
requests: ShapedRequest[];
|
|
47
|
+
websockets: ShapedSocket[];
|
|
48
|
+
/** Calls to hosts outside scope: counted, never read. */
|
|
49
|
+
other_hosts: { host: string; count: number }[];
|
|
50
|
+
/** Every cap that was hit, named. Empty means nothing was cut. */
|
|
51
|
+
caps_hit: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MAX_DEPTH = 24;
|
|
55
|
+
const MAX_KEYS = 200;
|
|
56
|
+
const MAX_ITEMS = 50;
|
|
57
|
+
const MAX_TUPLE = 40;
|
|
58
|
+
const MAX_ANY_OF = 6;
|
|
59
|
+
const MAX_REVEAL_CHARS = 120;
|
|
60
|
+
export const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
61
|
+
export const MAX_REQUESTS = 400;
|
|
62
|
+
export const MAX_FRAME_SHAPES = 60;
|
|
63
|
+
const BODY_SETTLE_MS = 5_000;
|
|
64
|
+
|
|
65
|
+
const isPlainObject = (v: Shape): v is { [k: string]: Shape } => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
66
|
+
|
|
67
|
+
function tryJson(s: string): { ok: true; value: unknown } | { ok: false } {
|
|
68
|
+
const t = s.trim();
|
|
69
|
+
if (!(t.startsWith('{') || t.startsWith('['))) return { ok: false };
|
|
70
|
+
try {
|
|
71
|
+
return { ok: true, value: JSON.parse(t) };
|
|
72
|
+
} catch {
|
|
73
|
+
return { ok: false };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Merge two shapes of the same position into one. Pure. */
|
|
78
|
+
export function mergeShapes(a: Shape, b: Shape): Shape {
|
|
79
|
+
const ja = JSON.stringify(a);
|
|
80
|
+
if (ja === JSON.stringify(b)) return a;
|
|
81
|
+
if (typeof a === 'string' && typeof b === 'string' && a.startsWith('string(') && b.startsWith('string(')) return 'string';
|
|
82
|
+
if (isPlainObject(a) && isPlainObject(b) && !('anyOf' in a) && !('anyOf' in b)) {
|
|
83
|
+
const aArr = 'array' in a && 'item' in a;
|
|
84
|
+
const bArr = 'array' in b && 'item' in b;
|
|
85
|
+
if (aArr && bArr) {
|
|
86
|
+
return { array: a.array === b.array ? a.array! : 'varies', item: a.item === 'null' && b.item !== 'null' ? b.item! : mergeShapes(a.item!, b.item!) };
|
|
87
|
+
}
|
|
88
|
+
if (!aArr && !bArr && !('tuple' in a) && !('tuple' in b) && !('json' in a) && !('json' in b)) {
|
|
89
|
+
const out: { [k: string]: Shape } = {};
|
|
90
|
+
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
91
|
+
out[k] = k in a && k in b ? mergeShapes(a[k]!, b[k]!) : k in a ? `${flat(a[k]!)} | absent` : `${flat(b[k]!)} | absent`;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const alts = [...(isPlainObject(a) && Array.isArray(a.anyOf) ? a.anyOf : [a]), ...(isPlainObject(b) && Array.isArray(b.anyOf) ? b.anyOf : [b])];
|
|
97
|
+
const uniq: Shape[] = [];
|
|
98
|
+
for (const s of alts) if (!uniq.some((u) => JSON.stringify(u) === JSON.stringify(s))) uniq.push(s);
|
|
99
|
+
return uniq.length > MAX_ANY_OF ? { anyOf: [...uniq.slice(0, MAX_ANY_OF), `… ${uniq.length - MAX_ANY_OF} more`] } : { anyOf: uniq };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const flat = (s: Shape): string => (typeof s === 'string' ? s : 'object');
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The shape of a parsed JSON value. Strings keep only their length — unless their
|
|
106
|
+
* key is in `reveal` — and a string that is itself JSON gets its own shape (Google
|
|
107
|
+
* nests JSON inside JSON strings). Arrays whose items share a shape become
|
|
108
|
+
* {array, item}; mixed positional arrays (Google's tuples) become {array, tuple}.
|
|
109
|
+
*/
|
|
110
|
+
export function shapeOf(v: unknown, reveal: ReadonlySet<string> = new Set(), depth = 0, key?: string): Shape {
|
|
111
|
+
if (depth > MAX_DEPTH) return '…depth';
|
|
112
|
+
if (v === null) return 'null';
|
|
113
|
+
if (typeof v === 'number') return 'number';
|
|
114
|
+
if (typeof v === 'boolean') return 'boolean';
|
|
115
|
+
if (typeof v === 'string') {
|
|
116
|
+
if (key !== undefined && reveal.has(key) && v.length <= MAX_REVEAL_CHARS) return { value: v };
|
|
117
|
+
const j = tryJson(v);
|
|
118
|
+
if (j.ok) return { string: String(v.length), json: shapeOf(j.value, reveal, depth + 1) };
|
|
119
|
+
return `string(${v.length})`;
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(v)) {
|
|
122
|
+
if (v.length === 0) return { array: '0', item: 'null' };
|
|
123
|
+
const items = v.slice(0, MAX_ITEMS).map((x) => shapeOf(x, reveal, depth + 1));
|
|
124
|
+
const allSame = items.every((s) => JSON.stringify(s) === JSON.stringify(items[0]));
|
|
125
|
+
const allObjects = items.every((s) => isPlainObject(s) && !('array' in s) && !('tuple' in s) && !('json' in s) && !('value' in s));
|
|
126
|
+
if (allSame || allObjects) return { array: String(v.length), item: items.reduce((m, s) => mergeShapes(m, s)) };
|
|
127
|
+
if (v.length <= MAX_TUPLE) return { array: String(v.length), tuple: items };
|
|
128
|
+
return { array: String(v.length), item: items.reduce((m, s) => mergeShapes(m, s)) };
|
|
129
|
+
}
|
|
130
|
+
if (typeof v === 'object') {
|
|
131
|
+
const out: { [k: string]: Shape } = {};
|
|
132
|
+
const keys = Object.keys(v as object);
|
|
133
|
+
for (const k of keys.slice(0, MAX_KEYS)) out[k] = shapeOf((v as Record<string, unknown>)[k], reveal, depth + 1, k);
|
|
134
|
+
if (keys.length > MAX_KEYS) out['…'] = `${keys.length - MAX_KEYS} more keys`;
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
return typeof v;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A single form/query value: JSON when it parses (tRPC `input`, Google `f.req`). */
|
|
141
|
+
function shapeParam(k: string, v: string, reveal: ReadonlySet<string>): Shape {
|
|
142
|
+
if (reveal.has(k) && v.length <= MAX_REVEAL_CHARS) return { value: v };
|
|
143
|
+
const j = tryJson(v);
|
|
144
|
+
return j.ok ? { string: String(v.length), json: shapeOf(j.value, reveal) } : `string(${v.length})`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function shapeParams(params: URLSearchParams, reveal: ReadonlySet<string>): Record<string, Shape> {
|
|
148
|
+
const out: Record<string, Shape> = {};
|
|
149
|
+
for (const [k, v] of params) {
|
|
150
|
+
const s = shapeParam(k, v, reveal);
|
|
151
|
+
out[k] = k in out ? mergeShapes(out[k]!, s) : s;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const shapeList = (values: unknown[], reveal: ReadonlySet<string>): Shape => shapeOf(values, reveal);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The shape of a body as it travels: JSON; Google's `)]}'` guard followed by
|
|
160
|
+
* length-prefixed chunks; text/event-stream `data:` lines; form-urlencoded;
|
|
161
|
+
* SignalR's \x1e-separated JSON messages. Anything else is its length only.
|
|
162
|
+
*/
|
|
163
|
+
export function shapeBody(text: string, contentType = '', reveal: ReadonlySet<string> = new Set()): Shape {
|
|
164
|
+
const ct = contentType.toLowerCase();
|
|
165
|
+
const t = text.trim();
|
|
166
|
+
if (t === '') return 'empty';
|
|
167
|
+
if (t.startsWith(")]}'")) {
|
|
168
|
+
const parsed: unknown[] = [];
|
|
169
|
+
let other = 0;
|
|
170
|
+
for (const line of t.slice(4).split('\n')) {
|
|
171
|
+
const l = line.trim();
|
|
172
|
+
if (!l || /^\d+$/.test(l)) continue;
|
|
173
|
+
const j = tryJson(l);
|
|
174
|
+
if (j.ok) parsed.push(j.value);
|
|
175
|
+
else other++;
|
|
176
|
+
}
|
|
177
|
+
return { format: 'google-chunked', chunks: String(parsed.length), ...(other ? { unparsed_lines: String(other) } : {}), shape: shapeList(parsed, reveal) };
|
|
178
|
+
}
|
|
179
|
+
if (ct.includes('event-stream') || /^(data|event|id|retry):/m.test(t.slice(0, 200))) {
|
|
180
|
+
const data: unknown[] = [];
|
|
181
|
+
const events: Record<string, number> = {};
|
|
182
|
+
let raw = 0;
|
|
183
|
+
for (const line of t.split('\n')) {
|
|
184
|
+
if (line.startsWith('event:')) {
|
|
185
|
+
const name = line.slice(6).trim().slice(0, 40);
|
|
186
|
+
events[name] = (events[name] ?? 0) + 1;
|
|
187
|
+
} else if (line.startsWith('data:')) {
|
|
188
|
+
const j = tryJson(line.slice(5));
|
|
189
|
+
if (j.ok) data.push(j.value);
|
|
190
|
+
else raw++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { format: 'sse', ...(Object.keys(events).length ? { events: Object.fromEntries(Object.entries(events).map(([k, n]) => [k, String(n)])) } : {}), ...(raw ? { non_json_data: String(raw) } : {}), shape: shapeList(data, reveal) };
|
|
194
|
+
}
|
|
195
|
+
if (t.includes('\x1e')) {
|
|
196
|
+
const msgs: unknown[] = [];
|
|
197
|
+
let other = 0;
|
|
198
|
+
for (const part of t.split('\x1e')) {
|
|
199
|
+
if (!part.trim()) continue;
|
|
200
|
+
const j = tryJson(part);
|
|
201
|
+
if (j.ok) msgs.push(j.value);
|
|
202
|
+
else other++;
|
|
203
|
+
}
|
|
204
|
+
return { format: 'signalr', messages: String(msgs.length), ...(other ? { unparsed: String(other) } : {}), shape: shapeList(msgs, reveal) };
|
|
205
|
+
}
|
|
206
|
+
const j = tryJson(t);
|
|
207
|
+
if (j.ok) return shapeOf(j.value, reveal);
|
|
208
|
+
if (ct.includes('x-www-form-urlencoded') || /^[\w.%-]+=[^\s]*(&[\w.%-]+=[^\s]*)*$/.test(t)) {
|
|
209
|
+
return { format: 'form', fields: shapeParams(new URLSearchParams(t), reveal) };
|
|
210
|
+
}
|
|
211
|
+
if (ct.includes('multipart')) return `multipart(${t.length})`;
|
|
212
|
+
return `text(${t.length})`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function hostOf(u: string): string | null {
|
|
216
|
+
try {
|
|
217
|
+
const url = new URL(u);
|
|
218
|
+
return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ws:' || url.protocol === 'wss:' ? url.host : null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const CALL_TYPES = new Set(['fetch', 'xhr', 'eventsource']);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Attach to a flow's browser context. `finish()` waits (bounded) for bodies still
|
|
228
|
+
* being read and returns the report. Never throws into the run: an unreadable
|
|
229
|
+
* body is recorded as such.
|
|
230
|
+
*/
|
|
231
|
+
export function collectNetworkShapes(context: BrowserContext, opts: NetworkShapesOptions = {}) {
|
|
232
|
+
const reveal = new Set(opts.reveal ?? []);
|
|
233
|
+
const extraHosts = new Set(opts.hosts ?? []);
|
|
234
|
+
const pageHosts = new Set<string>();
|
|
235
|
+
const merged = new Map<string, ShapedRequest>();
|
|
236
|
+
const other = new Map<string, number>();
|
|
237
|
+
const sockets: ShapedSocket[] = [];
|
|
238
|
+
const caps = new Set<string>();
|
|
239
|
+
const pending: Promise<void>[] = [];
|
|
240
|
+
|
|
241
|
+
const inScope = (host: string, pageUrl: string | null): boolean => {
|
|
242
|
+
const ph = pageUrl ? hostOf(pageUrl) : null;
|
|
243
|
+
if (ph) pageHosts.add(ph);
|
|
244
|
+
return host === ph || extraHosts.has(host);
|
|
245
|
+
};
|
|
246
|
+
const pageUrlOf = (req: Request): string | null => {
|
|
247
|
+
try {
|
|
248
|
+
return req.frame().page().url();
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const countOther = (host: string): void => {
|
|
254
|
+
other.set(host, (other.get(host) ?? 0) + 1);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const onResponse = async (resp: Response): Promise<void> => {
|
|
258
|
+
const req = resp.request();
|
|
259
|
+
if (!CALL_TYPES.has(req.resourceType())) return;
|
|
260
|
+
const host = hostOf(req.url());
|
|
261
|
+
if (!host) return;
|
|
262
|
+
if (!inScope(host, pageUrlOf(req))) return countOther(host);
|
|
263
|
+
const u = new URL(req.url());
|
|
264
|
+
const headers = resp.headers();
|
|
265
|
+
const contentType = (headers['content-type'] ?? '').split(';')[0]!.trim();
|
|
266
|
+
const entry: ShapedRequest = { method: req.method(), url: `${u.origin}${u.pathname}`, status: resp.status(), content_type: contentType, count: 1 };
|
|
267
|
+
if (u.search) entry.query = shapeParams(u.searchParams, reveal);
|
|
268
|
+
const post = req.postData();
|
|
269
|
+
if (post !== null) entry.request_shape = post.length > MAX_BODY_BYTES ? `too large (${post.length} bytes, cap ${MAX_BODY_BYTES})` : shapeBody(post, req.headers()['content-type'] ?? '', reveal);
|
|
270
|
+
const len = Number(headers['content-length'] ?? NaN);
|
|
271
|
+
if (Number.isFinite(len) && len > MAX_BODY_BYTES) {
|
|
272
|
+
entry.response_shape = `too large (${len} bytes, cap ${MAX_BODY_BYTES})`;
|
|
273
|
+
caps.add(`response body > ${MAX_BODY_BYTES} bytes not read`);
|
|
274
|
+
} else {
|
|
275
|
+
try {
|
|
276
|
+
const body = await resp.text();
|
|
277
|
+
entry.response_shape = body.length > MAX_BODY_BYTES ? `too large (${body.length} chars)` : shapeBody(body, contentType, reveal);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
entry.response_shape = `body not readable (${err instanceof Error ? err.message.split('\n')[0] : String(err)})`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const { count: _c, ...rest } = entry;
|
|
283
|
+
const key = JSON.stringify(rest);
|
|
284
|
+
const seen = merged.get(key);
|
|
285
|
+
if (seen) seen.count++;
|
|
286
|
+
else if (merged.size >= MAX_REQUESTS) {
|
|
287
|
+
caps.add(`more than ${MAX_REQUESTS} distinct calls — the rest are counted under other_hosts as "<host> (over cap)"`);
|
|
288
|
+
countOther(`${host} (over cap)`);
|
|
289
|
+
} else merged.set(key, entry);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const addFrame = (fs: FrameShapes, payload: string | Buffer) => {
|
|
293
|
+
fs.frames++;
|
|
294
|
+
const shape: Shape = typeof payload === 'string' ? shapeBody(payload, '', reveal) : `binary(${payload.length})`;
|
|
295
|
+
const k = JSON.stringify(shape);
|
|
296
|
+
const hit = fs.shapes.find((s) => JSON.stringify(s.shape) === k);
|
|
297
|
+
if (hit) hit.count++;
|
|
298
|
+
else if (fs.shapes.length >= MAX_FRAME_SHAPES) caps.add(`more than ${MAX_FRAME_SHAPES} distinct frame shapes on one socket`);
|
|
299
|
+
else fs.shapes.push({ shape, count: 1 });
|
|
300
|
+
};
|
|
301
|
+
const onSocket = (page: Page) => (ws: WebSocket) => {
|
|
302
|
+
const host = hostOf(ws.url());
|
|
303
|
+
if (!host) return;
|
|
304
|
+
if (!inScope(host, page.url())) return countOther(host);
|
|
305
|
+
const u = new URL(ws.url());
|
|
306
|
+
const s: ShapedSocket = { url: `${u.protocol}//${u.host}${u.pathname}`, sent: { frames: 0, shapes: [] }, received: { frames: 0, shapes: [] } };
|
|
307
|
+
sockets.push(s);
|
|
308
|
+
ws.on('framesent', (f) => addFrame(s.sent, f.payload));
|
|
309
|
+
ws.on('framereceived', (f) => addFrame(s.received, f.payload));
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const hooked = new WeakSet<Page>();
|
|
313
|
+
const hookPage = (p: Page) => {
|
|
314
|
+
if (hooked.has(p)) return;
|
|
315
|
+
hooked.add(p);
|
|
316
|
+
p.on('websocket', onSocket(p));
|
|
317
|
+
};
|
|
318
|
+
for (const p of context.pages()) hookPage(p);
|
|
319
|
+
context.on('page', hookPage);
|
|
320
|
+
context.on('response', (resp) => {
|
|
321
|
+
pending.push(onResponse(resp).catch(() => {}));
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
async finish(): Promise<NetworkShapesReport> {
|
|
326
|
+
let timedOut = false;
|
|
327
|
+
await Promise.race([
|
|
328
|
+
Promise.allSettled(pending),
|
|
329
|
+
new Promise((r) => setTimeout(() => {
|
|
330
|
+
timedOut = true;
|
|
331
|
+
r(undefined);
|
|
332
|
+
}, BODY_SETTLE_MS)),
|
|
333
|
+
]);
|
|
334
|
+
if (timedOut) caps.add(`some bodies were still being read after ${BODY_SETTLE_MS} ms and are missing`);
|
|
335
|
+
return {
|
|
336
|
+
page_hosts: [...pageHosts],
|
|
337
|
+
requests: [...merged.values()],
|
|
338
|
+
websockets: sockets,
|
|
339
|
+
other_hosts: [...other].map(([host, count]) => ({ host, count })).sort((a, b) => b.count - a.count),
|
|
340
|
+
caps_hit: [...caps],
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
package/src/record-run.ts
CHANGED
|
@@ -85,6 +85,13 @@ export async function runRecord(raw: RecordRequest, deps: RecordDeps) {
|
|
|
85
85
|
seed_tabs?: { session: string; channel?: string; label?: string }[];
|
|
86
86
|
};
|
|
87
87
|
if (!body.url) return { ok: false as const, status: 400, error: 'url required' };
|
|
88
|
+
// Both reach an ffmpeg filter string. 0 = no stills (burst), never a string.
|
|
89
|
+
for (const k of ['fps', 'burst_fps'] as const) {
|
|
90
|
+
const v = body[k];
|
|
91
|
+
if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 120)) {
|
|
92
|
+
return { ok: false as const, status: 400, error: `${k} must be a number from 0 to 120` };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
88
95
|
const project = body.project ?? 'lens';
|
|
89
96
|
try {
|
|
90
97
|
let auth = body.auth as AuthConfig | undefined;
|
|
@@ -311,7 +318,8 @@ export async function runRecord(raw: RecordRequest, deps: RecordDeps) {
|
|
|
311
318
|
const vid = nodePath.join(dir, 'run.webm');
|
|
312
319
|
await writeFile(vid, result.video);
|
|
313
320
|
const cuts: { t: number; file: string; near?: number }[] = [];
|
|
314
|
-
|
|
321
|
+
// fps 0 = no timeline stills (the documented off switch), not an ffmpeg call.
|
|
322
|
+
const sparse = body.fps === 0 ? { frames: [] } : await cutFrames(vid, nodePath.join(dir, 'sparse'), { fps: body.fps ?? 2 });
|
|
315
323
|
if (sparse.unavailable) framesUnavailable = sparse.unavailable;
|
|
316
324
|
cuts.push(...sparse.frames.map((f) => ({ t: f.t, file: f.file })));
|
|
317
325
|
const burstFps = body.burst_fps ?? 12;
|
package/src/service.ts
CHANGED
|
@@ -675,6 +675,11 @@ function parseFindingsJson(raw: string | null | undefined, port?: number): Recor
|
|
|
675
675
|
if (port !== undefined && Array.isArray(v.downloads)) {
|
|
676
676
|
v.downloads = v.downloads.map(({ key, ...d }: { key?: string }) => (key ? { ...d, url: artifactUrl(port, key) } : d));
|
|
677
677
|
}
|
|
678
|
+
// F009.133 — same for the network-shapes JSON.
|
|
679
|
+
if (port !== undefined && v.network_shapes && typeof v.network_shapes.key === 'string') {
|
|
680
|
+
const { key, ...rest } = v.network_shapes;
|
|
681
|
+
v.network_shapes = { ...rest, url: artifactUrl(port, key) };
|
|
682
|
+
}
|
|
678
683
|
return v as Record<string, unknown>;
|
|
679
684
|
} catch {
|
|
680
685
|
return null;
|
package/src/storage.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { S3Client } from 'bun';
|
|
20
20
|
import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
21
|
-
import { dirname,
|
|
21
|
+
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
22
22
|
|
|
23
23
|
export interface LensStorage {
|
|
24
24
|
readonly backend: 'local' | 'tigris';
|
|
@@ -31,8 +31,15 @@ class LocalDirStorage implements LensStorage {
|
|
|
31
31
|
readonly backend = 'local' as const;
|
|
32
32
|
constructor(private readonly root: string) {}
|
|
33
33
|
|
|
34
|
+
/** A key is a path INSIDE the store, never a way out of it (H2, 2026-10-21:
|
|
35
|
+
* `../../.ssh/id_rsa` was served by /lens/artifact). */
|
|
34
36
|
private path(key: string): string {
|
|
35
|
-
|
|
37
|
+
const root = resolve(this.root);
|
|
38
|
+
const p = resolve(root, key);
|
|
39
|
+
if (isAbsolute(key) || (p !== root && !p.startsWith(root + sep))) {
|
|
40
|
+
throw new Error(`lens storage: key escapes the artifact store: ${JSON.stringify(key)}`);
|
|
41
|
+
}
|
|
42
|
+
return p;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
async put(key: string, bytes: Buffer): Promise<void> {
|
|
@@ -119,6 +126,8 @@ export const artifactKeys = {
|
|
|
119
126
|
// F255.5 — the whole driven run as a video, alongside its step stills.
|
|
120
127
|
flowVideo: (project: string, flowRunId: string) => `${project}/flows/${flowRunId}/run.webm`,
|
|
121
128
|
// F009.128 — a file the run downloaded. Numbered so two files with one name both survive.
|
|
129
|
+
// F009.133 — the run's network shapes (one JSON per run).
|
|
130
|
+
flowNetworkShapes: (project: string, flowRunId: string) => `${project}/flows/${flowRunId}/network-shapes.json`,
|
|
122
131
|
flowDownload: (project: string, flowRunId: string, n: number, name: string) =>
|
|
123
132
|
`${project}/flows/${flowRunId}/download-${n}-${name.replace(/[^\w.-]+/g, '_').slice(0, 120)}`,
|
|
124
133
|
// F074.63 — a vector export (PDF). Its own prefix, not runs/: an export is not
|