sentinelayer-cli 0.8.12 → 0.9.0

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.
@@ -0,0 +1,707 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { getIdentityById } from "../../ai/identity-store.js";
6
+ import { resolveOutputRoot } from "../../config/service.js";
7
+ import { createAgentEvent } from "../../events/schema.js";
8
+ import { DEVTESTBOT_DEFINITION, DEVTESTBOT_LANES } from "./config/definition.js";
9
+ import { launch } from "./runner.js";
10
+
11
+ const RUNTIME_FILE = "runtime://browser";
12
+ const EXTRA_SECRET_PATTERN =
13
+ /\b(?:otp|one[-_ ]?time[-_ ]?code|reset[-_ ]?link|verification[-_ ]?url|magic[-_ ]?link)\s*[:=]\s*["']?[^"'\s&]+/gi;
14
+ const SENSITIVE_KEY_PATTERN =
15
+ /(?:authorization|cookie|set-cookie|token|secret|password|passwd|api[-_]?key|session|credential|otp|reset|verification)/i;
16
+ const SENSITIVE_FIELD_PATTERN =
17
+ /^(?:authorization|cookie|set-cookie|token|secret|password|passwd|api[-_]?key|session|credential|otp|resetLink|verificationUrl)$/i;
18
+
19
+ export const DEVTESTBOT_RUN_SESSION_TOOL = Object.freeze({
20
+ name: "devtestbot.run_session",
21
+ description:
22
+ "Run a scan-only browser system-test session and return redacted artifact paths plus normalized findings.",
23
+ parameters: {
24
+ type: "object",
25
+ properties: {
26
+ scope: {
27
+ type: "string",
28
+ description: "smoke, auth, full, password-reset, or a named scenario",
29
+ },
30
+ identityId: {
31
+ type: "string",
32
+ description: "AIdenID identity id from the local registry",
33
+ },
34
+ baseUrl: {
35
+ type: "string",
36
+ description: "Approved absolute http/https target URL",
37
+ },
38
+ recordVideo: {
39
+ type: "boolean",
40
+ default: true,
41
+ },
42
+ outputDir: {
43
+ type: "string",
44
+ description: "Optional devTestBot artifact output directory",
45
+ },
46
+ },
47
+ required: ["scope"],
48
+ },
49
+ });
50
+
51
+ export class DevTestBotToolError extends Error {
52
+ constructor(message, options = {}) {
53
+ super(message);
54
+ this.name = "DevTestBotToolError";
55
+ this.code = options.code || "DEVTESTBOT_TOOL_ERROR";
56
+ this.cause = options.cause;
57
+ }
58
+ }
59
+
60
+ export async function executeDevTestBotRunSessionTool(input = {}, ctx = {}) {
61
+ return runDevTestBotSession(input, ctx);
62
+ }
63
+
64
+ export async function runDevTestBotSession(input = {}, ctx = {}) {
65
+ const targetPath = path.resolve(String(input.targetPath || ctx.targetPath || process.cwd()));
66
+ const outputRoot = await resolveOutputRoot({
67
+ cwd: targetPath,
68
+ outputDirOverride: input.outputRoot || ctx.outputRoot || "",
69
+ env: ctx.env || process.env,
70
+ });
71
+ const runId = normalizeString(input.runId || ctx.runId) || createRunId();
72
+ const scope = normalizeScope(input.scope || ctx.scope || "smoke");
73
+ const execute = input.execute !== undefined ? Boolean(input.execute) : ctx.execute !== undefined ? Boolean(ctx.execute) : true;
74
+ const recordVideo = input.recordVideo !== undefined ? Boolean(input.recordVideo) : true;
75
+ const baseUrl = normalizeString(input.baseUrl || ctx.baseUrl);
76
+ const identityId = normalizeString(input.identityId || ctx.identityId);
77
+ const artifactRoot = path.resolve(
78
+ input.outputDir || ctx.outputDir || path.join(outputRoot, "runs", runId, "devtestbot")
79
+ );
80
+ await fsp.mkdir(artifactRoot, { recursive: true });
81
+
82
+ const { registryPath, identity } = await resolveIdentity({
83
+ outputRoot,
84
+ identityId,
85
+ requireIdentity: requiresIdentity(scope) && execute,
86
+ });
87
+ const identityCreds = buildInternalIdentityCreds({
88
+ identity,
89
+ identityId,
90
+ privateIdentityCreds: ctx.identityCreds,
91
+ });
92
+ const sensitiveValues = collectSensitiveValues(ctx.identityCreds);
93
+ if (identity?.emailAddress) sensitiveValues.push(identity.emailAddress);
94
+
95
+ const events = [];
96
+ const emit = (event, payload, usage = {}) => {
97
+ const envelope = createAgentEvent({
98
+ event,
99
+ agent: {
100
+ id: DEVTESTBOT_DEFINITION.id,
101
+ persona: DEVTESTBOT_DEFINITION.persona,
102
+ color: DEVTESTBOT_DEFINITION.color,
103
+ avatar: DEVTESTBOT_DEFINITION.avatar,
104
+ },
105
+ payload: sanitizeJson(payload, sensitiveValues),
106
+ usage,
107
+ runId,
108
+ sessionId: ctx.sessionId,
109
+ });
110
+ events.push(envelope);
111
+ if (typeof ctx.onEvent === "function") {
112
+ ctx.onEvent(envelope);
113
+ }
114
+ return envelope;
115
+ };
116
+
117
+ const startedAt = Date.now();
118
+ emit("agent_start", {
119
+ runId,
120
+ scope,
121
+ execute,
122
+ lanes: DEVTESTBOT_LANES,
123
+ identity: summarizeIdentity(identity, identityId, registryPath),
124
+ });
125
+
126
+ emit("tool_call", {
127
+ tool: DEVTESTBOT_RUN_SESSION_TOOL.name,
128
+ input: {
129
+ scope,
130
+ identityId: identityId || null,
131
+ baseUrl: safeUrlForOutput(baseUrl, sensitiveValues),
132
+ recordVideo,
133
+ execute,
134
+ },
135
+ });
136
+
137
+ let runner = null;
138
+ try {
139
+ if (!execute) {
140
+ const dryRun = await buildDryRunResult({
141
+ runId,
142
+ scope,
143
+ baseUrl,
144
+ artifactRoot,
145
+ sensitiveValues,
146
+ });
147
+ for (const finding of dryRun.findings) {
148
+ emit("finding", { finding });
149
+ }
150
+ emit("tool_result", {
151
+ tool: DEVTESTBOT_RUN_SESSION_TOOL.name,
152
+ success: true,
153
+ dryRun: true,
154
+ artifactBundle: dryRun.artifactBundle,
155
+ findingCount: dryRun.findings.length,
156
+ }, usageSnapshot(startedAt, 1));
157
+ emit("agent_complete", {
158
+ runId,
159
+ scope,
160
+ completed: true,
161
+ dryRun: true,
162
+ artifactBundle: dryRun.artifactBundle,
163
+ findingCount: dryRun.findings.length,
164
+ }, usageSnapshot(startedAt, 1));
165
+ return writeDevTestBotResult({
166
+ runId,
167
+ scope,
168
+ completed: true,
169
+ dryRun: true,
170
+ artifactRoot,
171
+ artifactBundle: dryRun.artifactBundle,
172
+ artifacts: dryRun.artifacts,
173
+ findings: dryRun.findings,
174
+ laneSummaries: dryRun.laneSummaries,
175
+ events,
176
+ sensitiveValues,
177
+ });
178
+ }
179
+
180
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
181
+ const launchImpl = ctx.launchImpl || input.launchImpl || launch;
182
+ runner = await launchImpl({
183
+ baseUrl: normalizedBaseUrl,
184
+ identityCreds,
185
+ outputDir: artifactRoot,
186
+ recordVideo,
187
+ runLighthouse: input.runLighthouse !== undefined ? Boolean(input.runLighthouse) : true,
188
+ lighthouseTimeoutMs: input.lighthouseTimeoutMs,
189
+ headless: input.headless !== undefined ? Boolean(input.headless) : true,
190
+ });
191
+
192
+ await driveScope({ runner, scope });
193
+ const runnerResult = await runner.finalize();
194
+ runner = null;
195
+ const artifacts = sanitizeArtifacts(runnerResult.artifacts || {}, sensitiveValues);
196
+ const laneSummaries = await summarizeArtifactLanes({ artifacts, sensitiveValues, scope });
197
+ const findings = buildFindingsFromLaneSummaries({
198
+ scope,
199
+ artifacts,
200
+ laneSummaries,
201
+ sensitiveValues,
202
+ });
203
+
204
+ for (const finding of findings) {
205
+ emit("finding", { finding });
206
+ }
207
+ emit("tool_result", {
208
+ tool: DEVTESTBOT_RUN_SESSION_TOOL.name,
209
+ success: true,
210
+ dryRun: false,
211
+ artifactBundle: {
212
+ root: artifactRoot,
213
+ artifacts,
214
+ },
215
+ findingCount: findings.length,
216
+ laneSummaries,
217
+ }, usageSnapshot(startedAt, 1));
218
+ emit("agent_complete", {
219
+ runId,
220
+ scope,
221
+ completed: true,
222
+ dryRun: false,
223
+ findingCount: findings.length,
224
+ }, usageSnapshot(startedAt, 1));
225
+
226
+ return writeDevTestBotResult({
227
+ runId,
228
+ scope,
229
+ completed: true,
230
+ dryRun: false,
231
+ artifactRoot,
232
+ artifacts,
233
+ findings,
234
+ laneSummaries,
235
+ events,
236
+ sensitiveValues,
237
+ });
238
+ } catch (error) {
239
+ const safeMessage = redactSessionText(error?.message || String(error), sensitiveValues);
240
+ emit("agent_error", {
241
+ runId,
242
+ scope,
243
+ error: safeMessage,
244
+ code: error?.code || "DEVTESTBOT_SESSION_FAILED",
245
+ }, usageSnapshot(startedAt, 1));
246
+ await writeDevTestBotResult({
247
+ runId,
248
+ scope,
249
+ completed: false,
250
+ dryRun: !execute,
251
+ artifactRoot,
252
+ artifacts: {},
253
+ findings: [],
254
+ laneSummaries: {},
255
+ events,
256
+ sensitiveValues,
257
+ error: safeMessage,
258
+ }).catch(() => null);
259
+ throw new DevTestBotToolError(`devtestbot.run_session failed: ${safeMessage}`, {
260
+ code: error?.code || "DEVTESTBOT_SESSION_FAILED",
261
+ cause: error,
262
+ });
263
+ } finally {
264
+ if (runner && typeof runner.close === "function") {
265
+ await runner.close().catch(() => {});
266
+ }
267
+ }
268
+ }
269
+
270
+ function createRunId() {
271
+ return "devtestbot-session-" + new Date().toISOString().replace(/[:.]/g, "-") + "-" + randomUUID().slice(0, 8);
272
+ }
273
+
274
+ function normalizeString(value) {
275
+ return String(value || "").trim();
276
+ }
277
+
278
+ function normalizeScope(value) {
279
+ const normalized = normalizeString(value).toLowerCase().replace(/\s+/g, "-");
280
+ return normalized || "smoke";
281
+ }
282
+
283
+ function requiresIdentity(scope) {
284
+ return /auth|password|reset|otp|email/.test(scope);
285
+ }
286
+
287
+ function normalizeBaseUrl(value) {
288
+ const normalized = normalizeString(value);
289
+ if (!normalized) {
290
+ throw new DevTestBotToolError("baseUrl is required when devtestbot.run_session executes browser lanes.", {
291
+ code: "DEVTESTBOT_BASE_URL_REQUIRED",
292
+ });
293
+ }
294
+ let parsed;
295
+ try {
296
+ parsed = new URL(normalized);
297
+ } catch (error) {
298
+ throw new DevTestBotToolError("baseUrl must be an absolute URL.", {
299
+ code: "DEVTESTBOT_BASE_URL_INVALID",
300
+ cause: error,
301
+ });
302
+ }
303
+ if (!["http:", "https:"].includes(parsed.protocol)) {
304
+ throw new DevTestBotToolError("baseUrl must use http or https.", {
305
+ code: "DEVTESTBOT_BASE_URL_UNSUPPORTED",
306
+ });
307
+ }
308
+ return parsed.href.replace(/\/+$/, "");
309
+ }
310
+
311
+ async function resolveIdentity({ outputRoot, identityId, requireIdentity }) {
312
+ if (!identityId) {
313
+ if (requireIdentity) {
314
+ throw new DevTestBotToolError("identityId is required for auth/password-reset devTestBot scopes.", {
315
+ code: "DEVTESTBOT_IDENTITY_REQUIRED",
316
+ });
317
+ }
318
+ return { registryPath: "", identity: null };
319
+ }
320
+ const result = await getIdentityById({ outputRoot, identityId });
321
+ if (!result.identity && requireIdentity) {
322
+ throw new DevTestBotToolError(`Identity '${identityId}' is not present in local registry.`, {
323
+ code: "DEVTESTBOT_IDENTITY_NOT_FOUND",
324
+ });
325
+ }
326
+ return result;
327
+ }
328
+
329
+ function buildInternalIdentityCreds({ identity, identityId, privateIdentityCreds }) {
330
+ const privateCreds = privateIdentityCreds && typeof privateIdentityCreds === "object" ? privateIdentityCreds : {};
331
+ return {
332
+ ...privateCreds,
333
+ identityId: identity?.identityId || identityId || privateCreds.identityId || null,
334
+ email: identity?.emailAddress || privateCreds.email || privateCreds.username || null,
335
+ };
336
+ }
337
+
338
+ function summarizeIdentity(identity, identityId, registryPath) {
339
+ return {
340
+ provided: Boolean(identityId),
341
+ identityId: identity?.identityId || identityId || null,
342
+ status: identity?.status || null,
343
+ registryPath: registryPath || "",
344
+ };
345
+ }
346
+
347
+ async function driveScope({ runner, scope }) {
348
+ await runner.goto("/");
349
+ if (/auth|password|reset|otp|email|full/.test(scope)) {
350
+ await runner.page?.waitForTimeout?.(250).catch(() => {});
351
+ }
352
+ }
353
+
354
+ async function buildDryRunResult({ runId, scope, baseUrl, artifactRoot, sensitiveValues }) {
355
+ const artifacts = {
356
+ manifestPath: path.join(artifactRoot, "manifest.json"),
357
+ };
358
+ const laneSummaries = Object.fromEntries(
359
+ DEVTESTBOT_LANES.map((lane) => [
360
+ lane,
361
+ {
362
+ status: "not_executed",
363
+ dryRun: true,
364
+ },
365
+ ])
366
+ );
367
+ const findings = [
368
+ normalizeFinding({
369
+ severity: "P3",
370
+ title: "devTestBot browser lanes were not executed",
371
+ evidence: "swarm run was invoked without --execute, so devTestBot wrote a dry-run artifact bundle only.",
372
+ rootCause: "Runtime execution was disabled.",
373
+ recommendedFix: "Run with --execute --start-url <approved URL> to collect browser evidence.",
374
+ trafficLight: "yellow",
375
+ confidence: 0.82,
376
+ lane: "dry_run",
377
+ artifacts,
378
+ reproduction: {
379
+ type: "runtime_probe",
380
+ steps: ["Run devtestbot.run_session with execute=true and an approved baseUrl."],
381
+ },
382
+ user_impact: "Operators do not receive browser runtime evidence until execution is enabled.",
383
+ }, sensitiveValues),
384
+ ];
385
+ await writeJson(artifacts.manifestPath, {
386
+ runId,
387
+ scope,
388
+ generatedAt: new Date().toISOString(),
389
+ dryRun: true,
390
+ baseUrl: safeUrlForOutput(baseUrl, sensitiveValues),
391
+ lanes: laneSummaries,
392
+ });
393
+ return {
394
+ artifacts,
395
+ laneSummaries,
396
+ findings,
397
+ artifactBundle: {
398
+ root: artifactRoot,
399
+ manifestPath: artifacts.manifestPath,
400
+ artifacts,
401
+ },
402
+ };
403
+ }
404
+
405
+ async function summarizeArtifactLanes({ artifacts, sensitiveValues, scope }) {
406
+ const consolePayload = await readJsonIfPresent(artifacts.consolePath);
407
+ const networkPayload = await readJsonIfPresent(artifacts.networkPath);
408
+ const a11yPayload = await readJsonIfPresent(artifacts.a11yPath);
409
+ const lighthousePayload = await readJsonIfPresent(artifacts.lighthousePath);
410
+ const clickPayload = await readJsonIfPresent(artifacts.clickCoveragePath);
411
+
412
+ const consoleEvents = Array.isArray(consolePayload?.events) ? consolePayload.events : [];
413
+ const networkEvents = Array.isArray(networkPayload?.events) ? networkPayload.events : [];
414
+ const a11yViolations = Array.isArray(a11yPayload?.violations) ? a11yPayload.violations : [];
415
+ const clicks = Array.isArray(clickPayload?.clicks) ? clickPayload.clicks : [];
416
+ const scores = extractLighthouseScores(lighthousePayload);
417
+
418
+ return sanitizeJson({
419
+ console_errors: {
420
+ status: "executed",
421
+ count: consoleEvents.filter((event) => ["error", "pageerror"].includes(String(event.type || ""))).length,
422
+ total: consoleEvents.length,
423
+ artifactPath: artifacts.consolePath || "",
424
+ },
425
+ network_errors: {
426
+ status: "executed",
427
+ failedRequests: networkEvents.filter((event) => event.phase === "requestfailed").length,
428
+ serverErrors: networkEvents.filter((event) => Number(event.status || 0) >= 500).length,
429
+ clientErrors: networkEvents.filter((event) => Number(event.status || 0) >= 400 && Number(event.status || 0) < 500).length,
430
+ total: networkEvents.length,
431
+ artifactPath: artifacts.networkPath || "",
432
+ },
433
+ a11y: {
434
+ status: a11yPayload?.available === false ? "unavailable" : "executed",
435
+ violations: a11yViolations.length,
436
+ critical: a11yViolations.filter((item) => item.impact === "critical" || item.impact === "serious").length,
437
+ artifactPath: artifacts.a11yPath || "",
438
+ },
439
+ lighthouse: {
440
+ status: lighthousePayload?.available === false ? "unavailable" : "executed",
441
+ scores,
442
+ artifactPath: artifacts.lighthousePath || "",
443
+ },
444
+ click_coverage: {
445
+ status: "executed",
446
+ clicks: clicks.length,
447
+ artifactPath: artifacts.clickCoveragePath || "",
448
+ },
449
+ password_reset_e2e: {
450
+ status: /password|reset|otp|email|auth/.test(scope) ? "not_configured" : "not_in_scope",
451
+ artifactPath: "",
452
+ },
453
+ }, sensitiveValues);
454
+ }
455
+
456
+ function buildFindingsFromLaneSummaries({ scope, artifacts, laneSummaries, sensitiveValues }) {
457
+ const findings = [];
458
+ if (laneSummaries.console_errors?.count > 0) {
459
+ findings.push(normalizeFinding({
460
+ severity: "P1",
461
+ title: "Browser console errors detected during devTestBot run",
462
+ evidence: `console.json records ${laneSummaries.console_errors.count} redacted error event(s).`,
463
+ rootCause: "Runtime JavaScript emitted console errors or page errors during the configured scope.",
464
+ recommendedFix: "Inspect the failing runtime path and add browser regression coverage.",
465
+ trafficLight: "yellow",
466
+ confidence: 0.88,
467
+ lane: "console_errors",
468
+ artifacts,
469
+ user_impact: "Users may encounter broken or degraded browser behavior on the tested path.",
470
+ }, sensitiveValues));
471
+ }
472
+
473
+ const network = laneSummaries.network_errors || {};
474
+ const networkFailureCount = Number(network.failedRequests || 0) + Number(network.serverErrors || 0) + Number(network.clientErrors || 0);
475
+ if (networkFailureCount > 0) {
476
+ findings.push(normalizeFinding({
477
+ severity: Number(network.serverErrors || 0) > 0 || Number(network.failedRequests || 0) > 0 ? "P1" : "P2",
478
+ title: "Network failures detected during devTestBot run",
479
+ evidence: `network.json records ${networkFailureCount} redacted failed, 4xx, or 5xx request(s).`,
480
+ rootCause: "The tested browser path encountered unsuccessful network responses.",
481
+ recommendedFix: "Inspect the failing endpoint path and add a regression test for the user flow.",
482
+ trafficLight: Number(network.serverErrors || 0) > 0 ? "yellow" : "green",
483
+ confidence: 0.86,
484
+ lane: "network_errors",
485
+ artifacts,
486
+ user_impact: "Users may see failed actions, missing content, or degraded runtime behavior.",
487
+ }, sensitiveValues));
488
+ }
489
+
490
+ const a11y = laneSummaries.a11y || {};
491
+ if (Number(a11y.violations || 0) > 0) {
492
+ findings.push(normalizeFinding({
493
+ severity: Number(a11y.critical || 0) > 0 ? "P1" : "P2",
494
+ title: "Accessibility violations detected during devTestBot run",
495
+ evidence: `a11y.json records ${a11y.violations} axe violation(s), ${a11y.critical || 0} serious or critical.`,
496
+ rootCause: "The tested page violates automated accessibility rules.",
497
+ recommendedFix: "Fix the axe-reported elements and add accessibility regression coverage.",
498
+ trafficLight: Number(a11y.critical || 0) > 0 ? "yellow" : "green",
499
+ confidence: 0.84,
500
+ lane: "a11y",
501
+ artifacts,
502
+ user_impact: "Keyboard, screen-reader, or assistive-technology users may be blocked or degraded.",
503
+ }, sensitiveValues));
504
+ }
505
+
506
+ const lighthouse = laneSummaries.lighthouse || {};
507
+ const poorScores = Object.entries(lighthouse.scores || {})
508
+ .filter(([, score]) => typeof score === "number" && score < DEVTESTBOT_DEFINITION.thresholds.lighthouseNeedsWorkScore);
509
+ if (poorScores.length > 0) {
510
+ findings.push(normalizeFinding({
511
+ severity: poorScores.some(([, score]) => score < DEVTESTBOT_DEFINITION.thresholds.lighthousePoorScore) ? "P1" : "P2",
512
+ title: "Lighthouse scores need attention",
513
+ evidence: `lighthouse.json records score(s) below ${DEVTESTBOT_DEFINITION.thresholds.lighthouseNeedsWorkScore}: ${poorScores.map(([key, score]) => `${key}=${score}`).join(", ")}.`,
514
+ rootCause: "The tested page misses one or more Lighthouse runtime quality thresholds.",
515
+ recommendedFix: "Inspect the Lighthouse report and prioritize user-visible performance/accessibility regressions.",
516
+ trafficLight: "green",
517
+ confidence: 0.8,
518
+ lane: "lighthouse",
519
+ artifacts,
520
+ user_impact: "Users may experience slower, less accessible, or less robust page behavior.",
521
+ }, sensitiveValues));
522
+ }
523
+
524
+ if (/password|reset|otp|email|auth/.test(scope) && laneSummaries.password_reset_e2e?.status === "not_configured") {
525
+ findings.push(normalizeFinding({
526
+ severity: "P3",
527
+ title: "Password reset E2E scope has no configured playbook",
528
+ evidence: "devTestBot captured baseline browser lanes but no password-reset scenario actions were configured.",
529
+ rootCause: "The runtime scope requested an auth/password-reset lane before a scenario playbook was attached.",
530
+ recommendedFix: "Provide a scoped password-reset playbook or let the DD orchestrator attach one in PR-E3.",
531
+ trafficLight: "yellow",
532
+ confidence: 0.82,
533
+ lane: "password_reset_e2e",
534
+ artifacts,
535
+ user_impact: "Operators do not yet have end-to-end password reset evidence for this run.",
536
+ }, sensitiveValues));
537
+ }
538
+
539
+ return findings;
540
+ }
541
+
542
+ function normalizeFinding(input, sensitiveValues) {
543
+ return sanitizeJson({
544
+ severity: input.severity || "P3",
545
+ file: input.file || RUNTIME_FILE,
546
+ line: Number(input.line || 1),
547
+ title: input.title || "devTestBot runtime finding",
548
+ evidence: input.evidence || "devTestBot artifact bundle contains runtime evidence.",
549
+ rootCause: input.rootCause || "Runtime evidence requires review.",
550
+ recommendedFix: input.recommendedFix || "Inspect the referenced artifact bundle.",
551
+ trafficLight: input.trafficLight || "yellow",
552
+ reproduction: input.reproduction || {
553
+ type: "runtime_probe",
554
+ steps: ["Run devtestbot.run_session with the same scope and identityId."],
555
+ },
556
+ user_impact: input.user_impact || input.userImpact || "Users may experience degraded runtime behavior.",
557
+ confidence: Math.max(0, Math.min(1, Number(input.confidence || DEVTESTBOT_DEFINITION.confidenceFloor))),
558
+ lane: input.lane || "runtime",
559
+ artifacts: sanitizeArtifacts(input.artifacts || {}, sensitiveValues),
560
+ }, sensitiveValues);
561
+ }
562
+
563
+ async function writeDevTestBotResult({
564
+ runId,
565
+ scope,
566
+ completed,
567
+ dryRun,
568
+ artifactRoot,
569
+ artifacts,
570
+ artifactBundle,
571
+ findings,
572
+ laneSummaries,
573
+ events,
574
+ sensitiveValues,
575
+ error = "",
576
+ }) {
577
+ const findingsPath = path.join(artifactRoot, "findings.json");
578
+ const eventsPath = path.join(artifactRoot, "events.ndjson");
579
+ const resultPath = path.join(artifactRoot, "devtestbot-result.json");
580
+ const fullArtifactBundle = {
581
+ root: artifactRoot,
582
+ ...(artifactBundle || {}),
583
+ findingsPath,
584
+ eventsPath,
585
+ resultPath,
586
+ artifacts: sanitizeArtifacts(artifacts || artifactBundle?.artifacts || {}, sensitiveValues),
587
+ };
588
+ await writeJson(findingsPath, findings);
589
+ await fsp.writeFile(eventsPath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf-8");
590
+ const result = sanitizeJson({
591
+ schemaVersion: 1,
592
+ runId,
593
+ generatedAt: new Date().toISOString(),
594
+ agentId: DEVTESTBOT_DEFINITION.id,
595
+ scope,
596
+ completed,
597
+ dryRun,
598
+ findingCount: findings.length,
599
+ findings,
600
+ laneSummaries,
601
+ artifactBundle: fullArtifactBundle,
602
+ artifacts: fullArtifactBundle.artifacts,
603
+ events,
604
+ error: error || undefined,
605
+ }, sensitiveValues);
606
+ await writeJson(resultPath, result);
607
+ return result;
608
+ }
609
+
610
+ function sanitizeArtifacts(artifacts = {}, sensitiveValues = []) {
611
+ const output = {};
612
+ for (const [key, value] of Object.entries(artifacts || {})) {
613
+ if (typeof value === "string") {
614
+ output[key] = redactSessionText(value, sensitiveValues);
615
+ }
616
+ }
617
+ return output;
618
+ }
619
+
620
+ function extractLighthouseScores(report) {
621
+ const categories = report?.categories || {};
622
+ return {
623
+ performance: categories.performance?.score ?? null,
624
+ accessibility: categories.accessibility?.score ?? null,
625
+ bestPractices: categories["best-practices"]?.score ?? null,
626
+ seo: categories.seo?.score ?? null,
627
+ };
628
+ }
629
+
630
+ function safeUrlForOutput(rawUrl, sensitiveValues) {
631
+ if (!rawUrl) return "";
632
+ try {
633
+ const parsed = new URL(rawUrl);
634
+ for (const key of [...parsed.searchParams.keys()]) {
635
+ if (SENSITIVE_KEY_PATTERN.test(key)) {
636
+ parsed.searchParams.set(key, "[REDACTED]");
637
+ }
638
+ }
639
+ return redactSessionText(parsed.href, sensitiveValues);
640
+ } catch {
641
+ return redactSessionText(rawUrl, sensitiveValues);
642
+ }
643
+ }
644
+
645
+ function sanitizeJson(value, sensitiveValues = []) {
646
+ if (Array.isArray(value)) return value.map((item) => sanitizeJson(item, sensitiveValues));
647
+ if (value && typeof value === "object") {
648
+ const output = {};
649
+ for (const [key, item] of Object.entries(value)) {
650
+ output[key] = SENSITIVE_FIELD_PATTERN.test(key)
651
+ ? "[REDACTED]"
652
+ : sanitizeJson(item, sensitiveValues);
653
+ }
654
+ return output;
655
+ }
656
+ if (typeof value === "string") return redactSessionText(value, sensitiveValues);
657
+ return value;
658
+ }
659
+
660
+ function redactSessionText(value, sensitiveValues = []) {
661
+ let text = String(value ?? "");
662
+ for (const sensitiveValue of sensitiveValues) {
663
+ if (!sensitiveValue) continue;
664
+ text = text.split(sensitiveValue).join("[REDACTED]");
665
+ }
666
+ return text
667
+ .replace(/\b(?:bearer|token|password|secret|api[_-]?key|session)\s*[:=]\s*["']?[^"'\s&]+/gi, (match) =>
668
+ match.replace(/[:=]\s*["']?.*$/u, "=[REDACTED]")
669
+ )
670
+ .replace(EXTRA_SECRET_PATTERN, (match) => match.replace(/[:=]\s*["']?.*$/u, "=[REDACTED]"));
671
+ }
672
+
673
+ function collectSensitiveValues(value, out = []) {
674
+ if (value == null) return out;
675
+ if (typeof value === "string") {
676
+ if (value.length >= 4) out.push(value);
677
+ return out;
678
+ }
679
+ if (typeof value !== "object") return out;
680
+ for (const item of Object.values(value)) {
681
+ collectSensitiveValues(item, out);
682
+ }
683
+ return out;
684
+ }
685
+
686
+ function usageSnapshot(startedAt, toolCalls) {
687
+ return {
688
+ costUsd: 0,
689
+ outputTokens: 0,
690
+ toolCalls,
691
+ durationMs: Date.now() - startedAt,
692
+ };
693
+ }
694
+
695
+ async function readJsonIfPresent(filePath) {
696
+ if (!filePath) return null;
697
+ try {
698
+ return JSON.parse(await fsp.readFile(filePath, "utf-8"));
699
+ } catch {
700
+ return null;
701
+ }
702
+ }
703
+
704
+ async function writeJson(filePath, payload) {
705
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
706
+ await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
707
+ }