@loadmill/droid-cua 2.17.0 → 2.18.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.
- package/README.md +18 -0
- package/build/index.js +16 -1
- package/build/src/browser/playwright/actions.js +17 -8
- package/build/src/cli/headless-execution-config.js +6 -0
- package/build/src/cli/headless-report.js +113 -0
- package/build/src/cli/headless-runner.js +40 -6
- package/build/src/commands/help.js +1 -0
- package/build/src/commands/run.js +2 -1
- package/build/src/core/execution-engine.js +57 -2
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/report/generate-html-report.js +88 -0
- package/build/src/core/report/parse-run-report.js +222 -0
- package/build/src/core/report/render-html-report.js +317 -0
- package/build/src/core/report/report-file-name.js +31 -0
- package/build/src/core/report/run-log-model.js +144 -0
- package/build/src/core/report/run-report-model.js +143 -0
- package/build/src/core/secrets.js +116 -0
- package/build/src/core/tool-handlers/close-current-tab.js +3 -3
- package/build/src/core/tool-handlers/navigate.js +3 -3
- package/build/src/core/tool-handlers/preview-click.js +6 -6
- package/build/src/core/tool-handlers/type-sensitive-text.js +62 -0
- package/build/src/core/tool-handlers/utils.js +3 -3
- package/build/src/device/android/actions.js +13 -8
- package/build/src/device/cloud/actions.js +17 -8
- package/build/src/device/ios/actions.js +17 -8
- package/build/src/device/openai.js +17 -0
- package/build/src/device/sensitive-action.js +61 -0
- package/build/src/modes/design-mode-ink.js +3 -1
- package/build/src/modes/design-mode.js +3 -1
- package/build/src/modes/execution-mode.js +1 -0
- package/build/src/prompts/design.js +4 -3
- package/build/src/prompts/execution.js +15 -1
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Pure leaf classifier predicates for execution log events.
|
|
2
|
+
// Shared by the Desktop App (log-utils.ts re-exports these) and the HTML report renderer.
|
|
3
|
+
// No I/O, no React, no side effects.
|
|
4
|
+
import { classifyCaptureSource } from "../capture-sources.js";
|
|
5
|
+
export function captureSourceOf(line) {
|
|
6
|
+
const captureSource = line.payload?.captureSource;
|
|
7
|
+
return typeof captureSource === "string" && captureSource.trim().length > 0 ? captureSource : null;
|
|
8
|
+
}
|
|
9
|
+
export function mediaKindOf(line) {
|
|
10
|
+
const captureSource = captureSourceOf(line);
|
|
11
|
+
const classification = classifyCaptureSource(captureSource);
|
|
12
|
+
const mediaKind = classification.mediaKind;
|
|
13
|
+
if (mediaKind === "hidden-debug" || mediaKind === "click-annotation" || mediaKind === "screenshot") {
|
|
14
|
+
return mediaKind;
|
|
15
|
+
}
|
|
16
|
+
// Backcompat bridge for older logs and reports that predate captureSource normalization.
|
|
17
|
+
const toolName = line.payload?.toolName;
|
|
18
|
+
if (toolName === "preview_click") {
|
|
19
|
+
return "hidden-debug";
|
|
20
|
+
}
|
|
21
|
+
const hasScreenshot = typeof line.payload?.screenshotDataUrl === "string" ||
|
|
22
|
+
typeof line.payload?.screenshotPath === "string";
|
|
23
|
+
if (!hasScreenshot) {
|
|
24
|
+
return "none";
|
|
25
|
+
}
|
|
26
|
+
if (line.eventType === "screenshot_captured" || line.eventType === "assertion_result") {
|
|
27
|
+
return "screenshot";
|
|
28
|
+
}
|
|
29
|
+
if (line.kind === "action") {
|
|
30
|
+
return "click-annotation";
|
|
31
|
+
}
|
|
32
|
+
return "none";
|
|
33
|
+
}
|
|
34
|
+
export function isHiddenExecutionLogEvent(line) {
|
|
35
|
+
return mediaKindOf(line) === "hidden-debug";
|
|
36
|
+
}
|
|
37
|
+
export function isGalleryMediaEvent(line) {
|
|
38
|
+
const mediaKind = mediaKindOf(line);
|
|
39
|
+
return mediaKind === "click-annotation" || mediaKind === "screenshot";
|
|
40
|
+
}
|
|
41
|
+
export function cleanAssertionDetails(rawDetails, assertion, passed) {
|
|
42
|
+
if (!rawDetails)
|
|
43
|
+
return null;
|
|
44
|
+
let details = rawDetails
|
|
45
|
+
.replace(/\[(Assertion|Assistant)\]\s*/gi, "")
|
|
46
|
+
.replace(/\r/g, "")
|
|
47
|
+
.trim();
|
|
48
|
+
const lines = details
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => line.trim())
|
|
51
|
+
.filter((line) => line.length > 0 && !/assertion result:/i.test(line));
|
|
52
|
+
details = lines.join(" ").replace(/\s{2,}/g, " ").trim();
|
|
53
|
+
if (!passed && /assertion result:\s*pass/i.test(rawDetails)) {
|
|
54
|
+
details = details.replace(/assertion result:\s*pass.*$/i, "").trim();
|
|
55
|
+
}
|
|
56
|
+
if (passed && /assertion result:\s*fail/i.test(rawDetails)) {
|
|
57
|
+
details = details.replace(/assertion result:\s*fail.*$/i, "").trim();
|
|
58
|
+
}
|
|
59
|
+
if (details.toLowerCase() === assertion.trim().toLowerCase())
|
|
60
|
+
return null;
|
|
61
|
+
return details.length > 0 ? details : null;
|
|
62
|
+
}
|
|
63
|
+
export function assertionData(event) {
|
|
64
|
+
if (event.eventType !== "assertion_result")
|
|
65
|
+
return null;
|
|
66
|
+
const payload = event.payload ?? {};
|
|
67
|
+
const assertion = typeof payload.assertion === "string" && payload.assertion.trim().length > 0
|
|
68
|
+
? payload.assertion
|
|
69
|
+
: event.text;
|
|
70
|
+
const passed = Boolean(payload.passed);
|
|
71
|
+
const rawDetails = typeof payload.details === "string" && payload.details.trim().length > 0
|
|
72
|
+
? payload.details
|
|
73
|
+
: null;
|
|
74
|
+
return { assertion, details: cleanAssertionDetails(rawDetails, assertion, passed), passed };
|
|
75
|
+
}
|
|
76
|
+
export function runSummaryData(event) {
|
|
77
|
+
if (event.eventType !== "run_finished")
|
|
78
|
+
return null;
|
|
79
|
+
const payload = event.payload ?? {};
|
|
80
|
+
const success = Boolean(payload.success);
|
|
81
|
+
const error = typeof payload.error === "string" && payload.error.trim().length > 0 ? payload.error : null;
|
|
82
|
+
const stopped = !success && Boolean(error && error.toLowerCase().includes("stopped by user"));
|
|
83
|
+
const isAssertionFailure = Boolean(error && error.startsWith("Assertion failed:"));
|
|
84
|
+
return {
|
|
85
|
+
status: success ? "passed" : stopped ? "stopped" : isAssertionFailure ? "failed" : "errored",
|
|
86
|
+
durationMs: typeof payload.durationMs === "number" ? payload.durationMs : null,
|
|
87
|
+
instructionsTotal: typeof payload.instructionsTotal === "number" ? payload.instructionsTotal : null,
|
|
88
|
+
instructionsCompleted: typeof payload.instructionsCompleted === "number" ? payload.instructionsCompleted : null,
|
|
89
|
+
assertionsPassed: typeof payload.assertionsPassed === "number" ? payload.assertionsPassed : null,
|
|
90
|
+
assertionsFailed: typeof payload.assertionsFailed === "number" ? payload.assertionsFailed : null,
|
|
91
|
+
retries: typeof payload.retries === "number" ? payload.retries : null,
|
|
92
|
+
error,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function formatDuration(ms) {
|
|
96
|
+
if (ms === null || ms === undefined)
|
|
97
|
+
return "n/a";
|
|
98
|
+
const totalSeconds = ms / 1000;
|
|
99
|
+
if (totalSeconds < 60)
|
|
100
|
+
return `${totalSeconds.toFixed(1)}s`;
|
|
101
|
+
const wholeSeconds = Math.floor(totalSeconds);
|
|
102
|
+
const minutes = Math.floor(wholeSeconds / 60);
|
|
103
|
+
const seconds = wholeSeconds % 60;
|
|
104
|
+
if (minutes < 60)
|
|
105
|
+
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
|
|
106
|
+
const hours = Math.floor(minutes / 60);
|
|
107
|
+
const remainingMinutes = minutes % 60;
|
|
108
|
+
return `${hours}h ${remainingMinutes.toString().padStart(2, "0")}m`;
|
|
109
|
+
}
|
|
110
|
+
export function isAssistantAssertionSummary(event) {
|
|
111
|
+
if (!(event.eventType === "assistant_message" || event.kind === "assistant"))
|
|
112
|
+
return false;
|
|
113
|
+
const text = event.text.toLowerCase();
|
|
114
|
+
return text.includes("assertion result: pass") || text.includes("assertion result: fail");
|
|
115
|
+
}
|
|
116
|
+
export function hasNearbyAssertionCard(events, index) {
|
|
117
|
+
const current = events[index];
|
|
118
|
+
const windowStart = Math.max(0, index - 3);
|
|
119
|
+
const windowEnd = Math.min(events.length - 1, index + 3);
|
|
120
|
+
for (let i = windowStart; i <= windowEnd; i++) {
|
|
121
|
+
if (i === index)
|
|
122
|
+
continue;
|
|
123
|
+
const candidate = events[i];
|
|
124
|
+
if (candidate.eventType !== "assertion_result")
|
|
125
|
+
continue;
|
|
126
|
+
if (current.stepId && candidate.stepId) {
|
|
127
|
+
if (current.stepId === candidate.stepId)
|
|
128
|
+
return true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
export function isDuplicateAssertionLine(event, previous) {
|
|
136
|
+
if (event.eventType !== "assertion_result" || !previous || previous.eventType !== "assertion_result") {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const current = assertionData(event);
|
|
140
|
+
const prev = assertionData(previous);
|
|
141
|
+
if (!current || !prev)
|
|
142
|
+
return false;
|
|
143
|
+
return current.assertion === prev.assertion && current.passed === prev.passed;
|
|
144
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The single, shared model-assembler for run reports. Both producers — the XML/Desktop
|
|
2
|
+
// path (parse-run-report.js) and the CLI path (headless-report.js) — acquire raw run data
|
|
3
|
+
// their own way (parse stored JUnit XML vs. capture live events), then delegate ALL model
|
|
4
|
+
// assembly to the pure functions here. This guarantees the two paths derive status,
|
|
5
|
+
// success rate, failure shape, and event normalization identically (structural parity,
|
|
6
|
+
// not coincidence), so the shared renderer produces the same HTML from either source.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no I/O, no XML, no React. The canonical normalized event shape is
|
|
9
|
+
// { ts, kind, text, eventType?, actionType?, payload? }
|
|
10
|
+
// and the assembled model is a RunReportModel (see assembleRunReportModel).
|
|
11
|
+
const VALID_KINDS = new Set([
|
|
12
|
+
"user",
|
|
13
|
+
"assistant",
|
|
14
|
+
"reasoning",
|
|
15
|
+
"action",
|
|
16
|
+
"warning",
|
|
17
|
+
"info",
|
|
18
|
+
"error",
|
|
19
|
+
"success",
|
|
20
|
+
"system",
|
|
21
|
+
"muted",
|
|
22
|
+
]);
|
|
23
|
+
const VALID_EVENTS = new Set([
|
|
24
|
+
"run_started",
|
|
25
|
+
"instruction_started",
|
|
26
|
+
"reasoning",
|
|
27
|
+
"assistant_message",
|
|
28
|
+
"tool_call",
|
|
29
|
+
"screenshot_captured",
|
|
30
|
+
"assertion_result",
|
|
31
|
+
"retry",
|
|
32
|
+
"input_request",
|
|
33
|
+
"run_finished",
|
|
34
|
+
"error",
|
|
35
|
+
"system_message",
|
|
36
|
+
"design_started",
|
|
37
|
+
"design_status",
|
|
38
|
+
"design_waiting_for_input",
|
|
39
|
+
"design_user_input",
|
|
40
|
+
"design_generated_script",
|
|
41
|
+
"design_saved",
|
|
42
|
+
"design_error",
|
|
43
|
+
"design_finished",
|
|
44
|
+
]);
|
|
45
|
+
const VALID_ACTIONS = new Set([
|
|
46
|
+
"click",
|
|
47
|
+
"double_click",
|
|
48
|
+
"drag",
|
|
49
|
+
"scroll",
|
|
50
|
+
"type",
|
|
51
|
+
"keypress",
|
|
52
|
+
"wait",
|
|
53
|
+
"screenshot",
|
|
54
|
+
"move",
|
|
55
|
+
]);
|
|
56
|
+
// Normalizes one raw event (already mapped to canonical field names) into the shared
|
|
57
|
+
// shape, validating kind/eventType/actionType against the known sets and dropping events
|
|
58
|
+
// with no usable text. Returns null for a dropped event (and pushes a warning, if a
|
|
59
|
+
// `warnings` array is provided). Callers map their producer-specific field names
|
|
60
|
+
// (NDJSON `event`/`action`, CLI `type`) to `eventType`/`actionType`/`kind` before calling.
|
|
61
|
+
export function normalizeEvent(value, { fallbackTs = null, warnings } = {}) {
|
|
62
|
+
const text = typeof value.text === "string" ? value.text : null;
|
|
63
|
+
if (!text || !text.trim()) {
|
|
64
|
+
warnings?.push("Skipped one event line due to missing text.");
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const ts = typeof value.ts === "string" && value.ts.trim().length > 0 ? value.ts : fallbackTs;
|
|
68
|
+
const kind = typeof value.kind === "string" && VALID_KINDS.has(value.kind) ? value.kind : "info";
|
|
69
|
+
const eventType = typeof value.eventType === "string" && VALID_EVENTS.has(value.eventType) ? value.eventType : undefined;
|
|
70
|
+
const actionType = typeof value.actionType === "string" && VALID_ACTIONS.has(value.actionType) ? value.actionType : undefined;
|
|
71
|
+
const payload = value.payload && typeof value.payload === "object" && !Array.isArray(value.payload)
|
|
72
|
+
? value.payload
|
|
73
|
+
: undefined;
|
|
74
|
+
return { ts, kind, text, eventType, actionType, payload };
|
|
75
|
+
}
|
|
76
|
+
// Maps a list of raw events through normalizeEvent, dropping the ones that fail.
|
|
77
|
+
export function normalizeEvents(values, options = {}) {
|
|
78
|
+
const events = [];
|
|
79
|
+
for (const value of values) {
|
|
80
|
+
const event = normalizeEvent(value, options);
|
|
81
|
+
if (event)
|
|
82
|
+
events.push(event);
|
|
83
|
+
}
|
|
84
|
+
return events;
|
|
85
|
+
}
|
|
86
|
+
// The single status precedence ladder: error > fail > stopped > skipped > pass.
|
|
87
|
+
// `failureKind` is the kind of any parsed <failure>/<error> node (XML) or the CLI's
|
|
88
|
+
// derived failure kind; it lets a run with no count attributes still resolve correctly.
|
|
89
|
+
export function deriveRunStatus({ tests = 0, failures = 0, errors = 0, skipped = 0, stopped = false, hasSkippedNode = false, failureKind, } = {}) {
|
|
90
|
+
if (errors > 0 || failureKind === "error")
|
|
91
|
+
return "error";
|
|
92
|
+
if (failures > 0 || failureKind === "failure")
|
|
93
|
+
return "fail";
|
|
94
|
+
if (stopped)
|
|
95
|
+
return "stopped";
|
|
96
|
+
if (skipped >= tests || (hasSkippedNode && tests <= 1))
|
|
97
|
+
return "skipped";
|
|
98
|
+
return "pass";
|
|
99
|
+
}
|
|
100
|
+
// The single success-rate formula: passed / tests, falling back to status when there are
|
|
101
|
+
// no test counts. `passed` = tests minus failures/errors/skipped (clamped at 0).
|
|
102
|
+
export function computeSuccessRate({ tests = 0, failures = 0, errors = 0, skipped = 0, status } = {}) {
|
|
103
|
+
const passed = Math.max(0, tests - failures - errors - skipped);
|
|
104
|
+
return tests > 0 ? (passed / tests) * 100 : status === "pass" ? 100 : 0;
|
|
105
|
+
}
|
|
106
|
+
// The single failureOrError constructor. Returns undefined when there is no failure kind.
|
|
107
|
+
export function makeFailureOrError({ kind, type, message, body } = {}) {
|
|
108
|
+
if (!kind)
|
|
109
|
+
return undefined;
|
|
110
|
+
return { kind, type, message, body };
|
|
111
|
+
}
|
|
112
|
+
// The single run_finished payload contract — the shape the shared renderer's
|
|
113
|
+
// runSummaryData() consumes. Producers map their own stat names to these canonical fields.
|
|
114
|
+
export function buildRunFinishedPayload({ success, error, durationMs, instructionsTotal, instructionsCompleted, actionsTotal, assertionsPassed, assertionsFailed, retries, } = {}) {
|
|
115
|
+
return {
|
|
116
|
+
success: Boolean(success),
|
|
117
|
+
error: error ?? null,
|
|
118
|
+
durationMs,
|
|
119
|
+
instructionsTotal,
|
|
120
|
+
instructionsCompleted: instructionsCompleted ?? 0,
|
|
121
|
+
actionsTotal: actionsTotal ?? 0,
|
|
122
|
+
assertionsPassed: assertionsPassed ?? 0,
|
|
123
|
+
assertionsFailed: assertionsFailed ?? 0,
|
|
124
|
+
retries: retries ?? 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Assembles the final RunReportModel from already-derived parts. Both producers end here so
|
|
128
|
+
// the top-level shape is identical regardless of source.
|
|
129
|
+
export function assembleRunReportModel({ suiteName = null, testName = null, timestamp = null, durationSeconds = null, status, testCases, successRate, failureOrError, events = [], childTests, warnings = [], } = {}) {
|
|
130
|
+
return {
|
|
131
|
+
suiteName,
|
|
132
|
+
testName,
|
|
133
|
+
timestamp,
|
|
134
|
+
durationSeconds,
|
|
135
|
+
status,
|
|
136
|
+
testCases,
|
|
137
|
+
successRate,
|
|
138
|
+
failureOrError,
|
|
139
|
+
events,
|
|
140
|
+
childTests,
|
|
141
|
+
warnings,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { parse as parseDotenv } from "dotenv";
|
|
4
|
+
export const SECRETS_FILE_NAME = ".secrets";
|
|
5
|
+
export function normalizeSecretKeys(keys) {
|
|
6
|
+
if (!Array.isArray(keys))
|
|
7
|
+
return [];
|
|
8
|
+
return keys
|
|
9
|
+
.map((key) => (typeof key === "string" ? key.trim() : ""))
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.sort((a, b) => a.localeCompare(b));
|
|
12
|
+
}
|
|
13
|
+
function sortedKeys(secretsByKey = {}) {
|
|
14
|
+
return normalizeSecretKeys(Object.keys(secretsByKey));
|
|
15
|
+
}
|
|
16
|
+
function mergeSecretMaps(...maps) {
|
|
17
|
+
const merged = {};
|
|
18
|
+
for (const map of maps) {
|
|
19
|
+
if (!map || typeof map !== "object")
|
|
20
|
+
continue;
|
|
21
|
+
for (const [key, value] of Object.entries(map)) {
|
|
22
|
+
if (!key)
|
|
23
|
+
continue;
|
|
24
|
+
merged[key] = String(value ?? "");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return merged;
|
|
28
|
+
}
|
|
29
|
+
export function getDefaultSecretsPath(basePath = process.cwd()) {
|
|
30
|
+
return path.join(basePath, SECRETS_FILE_NAME);
|
|
31
|
+
}
|
|
32
|
+
export function parseSecretsOverrideString(rawValue) {
|
|
33
|
+
if (rawValue == null || rawValue === "") {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
if (typeof rawValue !== "string") {
|
|
37
|
+
throw new Error("--secrets must be a comma-separated KEY=value string.");
|
|
38
|
+
}
|
|
39
|
+
const parsed = {};
|
|
40
|
+
for (const rawEntry of rawValue.split(",")) {
|
|
41
|
+
const entry = rawEntry.trim();
|
|
42
|
+
if (!entry)
|
|
43
|
+
continue;
|
|
44
|
+
const separatorIndex = entry.indexOf("=");
|
|
45
|
+
if (separatorIndex < 0) {
|
|
46
|
+
throw new Error("--secrets entries must use KEY=value format.");
|
|
47
|
+
}
|
|
48
|
+
const key = entry.slice(0, separatorIndex).trim();
|
|
49
|
+
if (!key) {
|
|
50
|
+
throw new Error("--secrets entries must include a non-empty key.");
|
|
51
|
+
}
|
|
52
|
+
parsed[key] = entry.slice(separatorIndex + 1);
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
export async function loadSecretsFile(filePath) {
|
|
57
|
+
if (!filePath) {
|
|
58
|
+
return {
|
|
59
|
+
status: "missing",
|
|
60
|
+
secretsByKey: {},
|
|
61
|
+
path: null,
|
|
62
|
+
message: null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const content = await readFile(filePath, "utf-8");
|
|
67
|
+
return {
|
|
68
|
+
status: "loaded",
|
|
69
|
+
secretsByKey: parseDotenv(content),
|
|
70
|
+
path: filePath,
|
|
71
|
+
message: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error?.code === "ENOENT") {
|
|
76
|
+
return {
|
|
77
|
+
status: "missing",
|
|
78
|
+
secretsByKey: {},
|
|
79
|
+
path: filePath,
|
|
80
|
+
message: null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const message = error instanceof Error ? error.message : "Unknown file read error.";
|
|
84
|
+
return {
|
|
85
|
+
status: "failed",
|
|
86
|
+
secretsByKey: {},
|
|
87
|
+
path: filePath,
|
|
88
|
+
message,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* @param {Object} [options]
|
|
94
|
+
* @param {string|null} [options.secretsPath] Path to a `.secrets` file. Pass `null`
|
|
95
|
+
* to skip file loading entirely (resolves to no file-based secrets).
|
|
96
|
+
* @param {string|null} [options.cliSecrets] Comma-separated KEY=value overrides.
|
|
97
|
+
*/
|
|
98
|
+
export async function resolveExecutionSecrets({ secretsPath = getDefaultSecretsPath(), cliSecrets = null, } = {}) {
|
|
99
|
+
const fileResult = await loadSecretsFile(secretsPath);
|
|
100
|
+
if (fileResult.status === "failed") {
|
|
101
|
+
throw new Error(`Could not read secrets file ${fileResult.path}: ${fileResult.message}`);
|
|
102
|
+
}
|
|
103
|
+
const cliSecretsByKey = parseSecretsOverrideString(cliSecrets);
|
|
104
|
+
const secretsByKey = mergeSecretMaps(fileResult.secretsByKey, cliSecretsByKey);
|
|
105
|
+
const availableSecretKeys = sortedKeys(secretsByKey);
|
|
106
|
+
return {
|
|
107
|
+
secretsByKey,
|
|
108
|
+
availableSecretKeys,
|
|
109
|
+
sourceMetadata: {
|
|
110
|
+
filePath: fileResult.path,
|
|
111
|
+
fileStatus: fileResult.status,
|
|
112
|
+
fileKeyCount: sortedKeys(fileResult.secretsByKey).length,
|
|
113
|
+
cliKeyCount: sortedKeys(cliSecretsByKey).length,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -2,7 +2,7 @@ import { normalizeBrowserInfoForSession } from "../../browser/normalize.js";
|
|
|
2
2
|
import { CAPTURE_SOURCES } from "../capture-sources.js";
|
|
3
3
|
import { printCliOutput } from "../../utils/console-output.js";
|
|
4
4
|
import { updateBrowserSessionInfo } from "./browser-session.js";
|
|
5
|
-
import { buildFunctionCallOutputItem,
|
|
5
|
+
import { buildFunctionCallOutputItem, buildToolOutputContent, buildTextToolOutputItem, } from "./utils.js";
|
|
6
6
|
export async function handleCloseCurrentTabToolCall({ engine, callId, context = null, stepContext = null }) {
|
|
7
7
|
const session = engine.session;
|
|
8
8
|
if (session?.targetKind !== "browser" && session?.deviceInfo?.platform !== "browser") {
|
|
@@ -44,9 +44,9 @@ export async function handleCloseCurrentTabToolCall({ engine, callId, context =
|
|
|
44
44
|
});
|
|
45
45
|
return {
|
|
46
46
|
kind: "continue",
|
|
47
|
-
outputItem: buildFunctionCallOutputItem(callId,
|
|
47
|
+
outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({
|
|
48
48
|
text: result.message,
|
|
49
|
-
|
|
49
|
+
imageBase64: screenshotBase64,
|
|
50
50
|
})),
|
|
51
51
|
currentScreenshot,
|
|
52
52
|
};
|
|
@@ -3,7 +3,7 @@ import { buildExecutionNavigationRecoveryPrompt } from "../../prompts/execution.
|
|
|
3
3
|
import { emitDesktopDebug } from "../../utils/desktop-debug.js";
|
|
4
4
|
import { printCliOutput } from "../../utils/console-output.js";
|
|
5
5
|
import { updateBrowserSessionInfo } from "./browser-session.js";
|
|
6
|
-
import { buildDebugContext, buildFunctionCallOutputItem,
|
|
6
|
+
import { buildDebugContext, buildFunctionCallOutputItem, buildToolOutputContent, buildTextToolOutputItem, isDesignContext, parseStringToolArgument, } from "./utils.js";
|
|
7
7
|
export async function handleNavigateToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
|
|
8
8
|
const session = engine.session;
|
|
9
9
|
const parsed = parseStringToolArgument(argumentsText, "target", "navigate");
|
|
@@ -69,9 +69,9 @@ export async function handleNavigateToolCall({ engine, callId, argumentsText, co
|
|
|
69
69
|
const screenshotBase64 = await engine.getScreenshotAsBase64(session.deviceId, session.deviceInfo);
|
|
70
70
|
return {
|
|
71
71
|
kind: "continue",
|
|
72
|
-
outputItem: buildFunctionCallOutputItem(callId,
|
|
72
|
+
outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({
|
|
73
73
|
text: `Navigation complete: ${normalizedUrl}`,
|
|
74
|
-
|
|
74
|
+
imageBase64: screenshotBase64,
|
|
75
75
|
})),
|
|
76
76
|
};
|
|
77
77
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createAnnotatedScreenshotFromBase64 } from "../../utils/run-screenshot-recorder.js";
|
|
3
3
|
import { CAPTURE_SOURCES } from "../capture-sources.js";
|
|
4
|
-
import { buildFunctionCallOutputItem,
|
|
4
|
+
import { buildFunctionCallOutputItem, buildToolOutputContent, } from "./utils.js";
|
|
5
5
|
function buildPreviewClickValidationError(argumentsText) {
|
|
6
6
|
if (!argumentsText || typeof argumentsText !== "string") {
|
|
7
7
|
return "Invalid preview_click arguments: expected a JSON object with numeric x and y.";
|
|
@@ -42,7 +42,7 @@ export async function handlePreviewClick({ engine, callId, argumentsText, contex
|
|
|
42
42
|
const session = engine.session;
|
|
43
43
|
const parsedArguments = parsePreviewClickArguments(argumentsText);
|
|
44
44
|
if (!parsedArguments.ok) {
|
|
45
|
-
return continueWith(buildFunctionCallOutputItem(callId,
|
|
45
|
+
return continueWith(buildFunctionCallOutputItem(callId, buildToolOutputContent({ text: parsedArguments.message })));
|
|
46
46
|
}
|
|
47
47
|
try {
|
|
48
48
|
const screenshotBase64 = await engine.getScreenshotAsBase64(session.deviceId, session.deviceInfo);
|
|
@@ -52,7 +52,7 @@ export async function handlePreviewClick({ engine, callId, argumentsText, contex
|
|
|
52
52
|
style: "preview",
|
|
53
53
|
});
|
|
54
54
|
if (!previewResult?.screenshotBase64 || !previewResult?.marker) {
|
|
55
|
-
return continueWith(buildFunctionCallOutputItem(callId,
|
|
55
|
+
return continueWith(buildFunctionCallOutputItem(callId, buildToolOutputContent({
|
|
56
56
|
text: "preview_click failed: could not annotate the current screenshot.",
|
|
57
57
|
})));
|
|
58
58
|
}
|
|
@@ -99,13 +99,13 @@ export async function handlePreviewClick({ engine, callId, argumentsText, contex
|
|
|
99
99
|
},
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
|
-
return continueWith(buildFunctionCallOutputItem(callId,
|
|
102
|
+
return continueWith(buildFunctionCallOutputItem(callId, buildToolOutputContent({
|
|
103
103
|
text: previewText,
|
|
104
|
-
|
|
104
|
+
imageBase64: previewResult.screenshotBase64,
|
|
105
105
|
})));
|
|
106
106
|
}
|
|
107
107
|
catch (error) {
|
|
108
|
-
return continueWith(buildFunctionCallOutputItem(callId,
|
|
108
|
+
return continueWith(buildFunctionCallOutputItem(callId, buildToolOutputContent({
|
|
109
109
|
text: `preview_click failed: ${error instanceof Error ? error.message : "unable to capture or annotate the current screen."}`,
|
|
110
110
|
})));
|
|
111
111
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CAPTURE_SOURCES } from "../capture-sources.js";
|
|
3
|
+
import { buildFunctionCallOutputItem, buildToolOutputContent, buildTextToolOutputItem, parseStringToolArgument, } from "./utils.js";
|
|
4
|
+
export async function handleTypeSensitiveTextToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
|
|
5
|
+
const parsed = parseStringToolArgument(argumentsText, "key", "type_sensitive_text");
|
|
6
|
+
if (!parsed.ok) {
|
|
7
|
+
return {
|
|
8
|
+
kind: "continue",
|
|
9
|
+
outputItem: buildTextToolOutputItem(callId, parsed.message),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const key = parsed.value;
|
|
13
|
+
const secretsByKey = engine.secretsByKey && typeof engine.secretsByKey === "object" ? engine.secretsByKey : {};
|
|
14
|
+
if (!Object.prototype.hasOwnProperty.call(secretsByKey, key)) {
|
|
15
|
+
return {
|
|
16
|
+
kind: "continue",
|
|
17
|
+
outputItem: buildTextToolOutputItem(callId, `Secret key not found: ${key}`),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const action = {
|
|
21
|
+
type: "type",
|
|
22
|
+
text: String(secretsByKey[key] ?? ""),
|
|
23
|
+
sensitive: true,
|
|
24
|
+
sensitiveKey: key,
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
const result = await engine.handleModelAction(engine.session.deviceId, action, engine.session.deviceInfo.scale, {
|
|
28
|
+
...context,
|
|
29
|
+
stepId: stepContext?.stepId,
|
|
30
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
31
|
+
eventId: randomUUID(),
|
|
32
|
+
});
|
|
33
|
+
if (result?.success === false) {
|
|
34
|
+
return {
|
|
35
|
+
kind: "fail",
|
|
36
|
+
error: `Sensitive typing failed for ${key}.`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return {
|
|
42
|
+
kind: "fail",
|
|
43
|
+
error: `Sensitive typing failed for ${key}.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const screenshotBase64 = await engine.getScreenshotAsBase64(engine.session.deviceId, engine.session.deviceInfo);
|
|
47
|
+
const currentScreenshot = await engine.recordScreenshot(screenshotBase64, {
|
|
48
|
+
runId: context?.runId,
|
|
49
|
+
sessionId: context?.sessionId,
|
|
50
|
+
stepId: stepContext?.stepId,
|
|
51
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
52
|
+
captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
kind: "continue",
|
|
56
|
+
outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({
|
|
57
|
+
text: `Typed secret: ${key}`,
|
|
58
|
+
imageBase64: screenshotBase64,
|
|
59
|
+
})),
|
|
60
|
+
currentScreenshot,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -45,12 +45,12 @@ export function buildFunctionCallOutputItem(callId, output) {
|
|
|
45
45
|
export function buildTextToolOutputItem(callId, text) {
|
|
46
46
|
return buildFunctionCallOutputItem(callId, [{ type: "input_text", text }]);
|
|
47
47
|
}
|
|
48
|
-
export function
|
|
48
|
+
export function buildToolOutputContent({ text, imageBase64 = null }) {
|
|
49
49
|
const content = [{ type: "input_text", text }];
|
|
50
|
-
if (
|
|
50
|
+
if (imageBase64) {
|
|
51
51
|
content.push({
|
|
52
52
|
type: "input_image",
|
|
53
|
-
image_url: `data:image/png;base64,${
|
|
53
|
+
image_url: `data:image/png;base64,${imageBase64}`,
|
|
54
54
|
detail: "high",
|
|
55
55
|
});
|
|
56
56
|
}
|
|
@@ -3,6 +3,7 @@ import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js
|
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
4
|
import { buildActionEventMeta } from "../action-event-meta.js";
|
|
5
5
|
import { buildMobileKeypressPayload, getAndroidAdbTransport, normalizeMobileKeypress } from "../mobile-keypress.js";
|
|
6
|
+
import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../sensitive-action.js";
|
|
6
7
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
7
8
|
import { execAdb } from "./tools.js";
|
|
8
9
|
function adbShell(deviceId, command) {
|
|
@@ -34,7 +35,8 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
34
35
|
y1,
|
|
35
36
|
x2,
|
|
36
37
|
y2,
|
|
37
|
-
text:
|
|
38
|
+
text: buildDebugTextField(action, truncateForDebug),
|
|
39
|
+
...buildSensitiveDebugFields(action),
|
|
38
40
|
keyCount: Array.isArray(keys) ? keys.length : 0,
|
|
39
41
|
pathPoints: Array.isArray(path) ? path.length : 0
|
|
40
42
|
});
|
|
@@ -79,7 +81,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
79
81
|
}
|
|
80
82
|
break;
|
|
81
83
|
case "type":
|
|
82
|
-
addOutput({ type: 'action', text:
|
|
84
|
+
addOutput({ type: 'action', text: buildTypeActionText(action, text), ...meta(buildTypeActionPayload(action, text)) });
|
|
83
85
|
const escapedText = text.replace(/(["\\$`])/g, "\\$1").replace(/ /g, "%s");
|
|
84
86
|
await adbShell(deviceId, `input text "${escapedText}"`);
|
|
85
87
|
break;
|
|
@@ -104,13 +106,15 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
104
106
|
default:
|
|
105
107
|
addOutput({ type: 'info', text: `Unknown action: ${JSON.stringify(action)}` });
|
|
106
108
|
}
|
|
109
|
+
return { success: true };
|
|
107
110
|
}
|
|
108
111
|
catch (error) {
|
|
112
|
+
const outputError = buildActionErrorOutput(action, error);
|
|
109
113
|
// Log full error details to file
|
|
110
114
|
logger.error('Action execution error', {
|
|
111
|
-
action,
|
|
112
|
-
message:
|
|
113
|
-
stack: error.stack
|
|
115
|
+
action: actionForErrorLog(action),
|
|
116
|
+
message: outputError.message,
|
|
117
|
+
stack: isSensitiveAction(action) ? undefined : error.stack
|
|
114
118
|
});
|
|
115
119
|
emitDesktopDebug("device.error", "device", {
|
|
116
120
|
runId: context?.runId,
|
|
@@ -121,12 +125,12 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
121
125
|
platform: "android",
|
|
122
126
|
operation: "action.execute",
|
|
123
127
|
actionType: action?.type,
|
|
124
|
-
message:
|
|
128
|
+
message: outputError.message
|
|
125
129
|
});
|
|
126
130
|
// Show user-friendly error message
|
|
127
131
|
addOutput({
|
|
128
132
|
type: 'error',
|
|
129
|
-
text:
|
|
133
|
+
text: outputError.text,
|
|
130
134
|
eventType: 'error',
|
|
131
135
|
actionType: action?.type,
|
|
132
136
|
runId: context?.runId,
|
|
@@ -134,10 +138,11 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
134
138
|
instructionIndex: context?.instructionIndex,
|
|
135
139
|
payload: {
|
|
136
140
|
eventId: context?.eventId,
|
|
137
|
-
message:
|
|
141
|
+
message: outputError.message,
|
|
138
142
|
platform: 'android'
|
|
139
143
|
}
|
|
140
144
|
});
|
|
141
145
|
addOutput({ type: 'info', text: 'Full error details have been logged to the debug log.' });
|
|
146
|
+
return { success: false, error: outputError.message };
|
|
142
147
|
}
|
|
143
148
|
}
|