@trusty-squire/mcp 0.9.19-rc.2 → 0.9.19-rc.21
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 +15 -33
- package/dist/api-client.d.ts +6 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js.map +1 -1
- package/dist/bin.js +4 -10
- package/dist/bin.js.map +1 -1
- package/dist/bot/agent.d.ts +24 -2
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +634 -55
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +7 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +182 -3
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/credential-extraction-flow.d.ts +2 -0
- package/dist/bot/credential-extraction-flow.d.ts.map +1 -1
- package/dist/bot/credential-extraction-flow.js +71 -1
- package/dist/bot/credential-extraction-flow.js.map +1 -1
- package/dist/bot/form-fill.d.ts.map +1 -1
- package/dist/bot/form-fill.js +11 -0
- package/dist/bot/form-fill.js.map +1 -1
- package/dist/bot/google-login.d.ts.map +1 -1
- package/dist/bot/google-login.js +37 -1
- package/dist/bot/google-login.js.map +1 -1
- package/dist/bot/index.d.ts +1 -0
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +1 -0
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/login-state.d.ts +2 -1
- package/dist/bot/login-state.d.ts.map +1 -1
- package/dist/bot/login-state.js +22 -5
- package/dist/bot/login-state.js.map +1 -1
- package/dist/bot/nav-search.d.ts.map +1 -1
- package/dist/bot/nav-search.js +9 -0
- package/dist/bot/nav-search.js.map +1 -1
- package/dist/bot/post-signup-flow.d.ts.map +1 -1
- package/dist/bot/post-signup-flow.js +21 -0
- package/dist/bot/post-signup-flow.js.map +1 -1
- package/dist/bot/post-signup-recovery-state.d.ts +3 -0
- package/dist/bot/post-signup-recovery-state.d.ts.map +1 -1
- package/dist/bot/post-signup-recovery-state.js +3 -0
- package/dist/bot/post-signup-recovery-state.js.map +1 -1
- package/dist/bot/provision-session.d.ts +116 -1
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +885 -41
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/bot/redact.d.ts.map +1 -1
- package/dist/bot/redact.js +25 -2
- package/dist/bot/redact.js.map +1 -1
- package/dist/bot/replay-skill.d.ts +6 -0
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +39 -5
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/bot/skill-hint.d.ts +7 -0
- package/dist/bot/skill-hint.d.ts.map +1 -0
- package/dist/bot/skill-hint.js +105 -0
- package/dist/bot/skill-hint.js.map +1 -0
- package/dist/bot/terminal-gate.d.ts +3 -1
- package/dist/bot/terminal-gate.d.ts.map +1 -1
- package/dist/bot/terminal-gate.js +19 -0
- package/dist/bot/terminal-gate.js.map +1 -1
- package/dist/install/agents.d.ts.map +1 -1
- package/dist/install/agents.js +12 -2
- package/dist/install/agents.js.map +1 -1
- package/dist/install/cli.d.ts +14 -2
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +346 -150
- package/dist/install/cli.js.map +1 -1
- package/dist/install/interactive.d.ts +9 -3
- package/dist/install/interactive.d.ts.map +1 -1
- package/dist/install/interactive.js +80 -140
- package/dist/install/interactive.js.map +1 -1
- package/dist/install/proxy-url.d.ts +2 -0
- package/dist/install/proxy-url.d.ts.map +1 -0
- package/dist/install/proxy-url.js +20 -0
- package/dist/install/proxy-url.js.map +1 -0
- package/dist/install/ui.js +1 -1
- package/dist/install/ui.js.map +1 -1
- package/dist/session.d.ts +3 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js.map +1 -1
- package/dist/skill-registry-client.d.ts +8 -0
- package/dist/skill-registry-client.d.ts.map +1 -1
- package/dist/skill-registry-client.js +70 -53
- package/dist/skill-registry-client.js.map +1 -1
- package/dist/tools/index.d.ts +1 -2
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +10 -19
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/provision-any.d.ts +3 -0
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +162 -32
- package/dist/tools/provision-any.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +121 -5
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +339 -48
- package/dist/tools/provision-drive.js.map +1 -1
- package/dist/tools/store-credential.d.ts +5 -0
- package/dist/tools/store-credential.d.ts.map +1 -1
- package/dist/tools/store-credential.js +5 -0
- package/dist/tools/store-credential.js.map +1 -1
- package/package.json +1 -3
- package/dist/bot/telegram-notify.d.ts +0 -8
- package/dist/bot/telegram-notify.d.ts.map +0 -1
- package/dist/bot/telegram-notify.js +0 -134
- package/dist/bot/telegram-notify.js.map +0 -1
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
// not navigation the agent chose, so they are never blocked.
|
|
16
16
|
// - no credential is ever read back to the agent except via the explicit
|
|
17
17
|
// `finish`/extract path; the vault stays write-only.
|
|
18
|
-
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
19
19
|
import { BrowserController } from "./browser.js";
|
|
20
20
|
import { extractApiKeyFromText, isTruncatedCapture, pickVerificationLink } from "./agent.js";
|
|
21
|
+
import { detectActiveProviderSessions, ensureOAuthSession, } from "./google-login.js";
|
|
22
|
+
import { loginSessionGuidance } from "./skill-hint.js";
|
|
21
23
|
import { initialExtractionState, accumulateCandidate, hasFullHit, resolveExtraction, } from "./extraction.js";
|
|
22
24
|
// Identity-provider + auth-handler hosts a signup legitimately bounces
|
|
23
25
|
// through. Used to widen domain-scope so an OAuth `goto` (rare) isn't blocked.
|
|
@@ -28,6 +30,20 @@ const DEFAULT_AUTH_HOSTS = [
|
|
|
28
30
|
"login.microsoftonline.com",
|
|
29
31
|
"appleid.apple.com",
|
|
30
32
|
];
|
|
33
|
+
// Plain host list for the pieces that only need the names (goto gate, audit,
|
|
34
|
+
// observed-hosts). The source metadata stays on the Session.
|
|
35
|
+
function hostStrings(session) {
|
|
36
|
+
return session.allowedHosts.map((e) => e.host);
|
|
37
|
+
}
|
|
38
|
+
// Hosts that may seed credential EGRESS (where a stored key is later sent by
|
|
39
|
+
// the proxy): start + auto_widen, never mid_session task scope — a wide operate
|
|
40
|
+
// scope must not silently over-grant a key's egress allow-list (Codex). The
|
|
41
|
+
// vault unions these with the service-default + any agent-declared egress_hosts.
|
|
42
|
+
function egressSeedHosts(session) {
|
|
43
|
+
return session.allowedHosts
|
|
44
|
+
.filter((e) => e.source !== "mid_session")
|
|
45
|
+
.map((e) => e.host);
|
|
46
|
+
}
|
|
31
47
|
const sessions = new Map();
|
|
32
48
|
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
33
49
|
// Audit trail (security posture): every session action emits one structured
|
|
@@ -35,10 +51,12 @@ const settle = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
35
51
|
// the trail greppable. No credential VALUES are ever logged — only the action
|
|
36
52
|
// shape + url.
|
|
37
53
|
function audit(sessionId, event, detail = {}) {
|
|
38
|
-
process.stderr.write(`${JSON.stringify({ marker: "provision-audit", session_id: sessionId, event, ...detail })}\n`);
|
|
54
|
+
process.stderr.write(`${JSON.stringify({ marker: "provision-audit", surface: "operate", session_id: sessionId, event, ...detail })}\n`);
|
|
39
55
|
}
|
|
40
56
|
// ── pure helpers (exported for unit tests) ──
|
|
41
57
|
const norm = (s) => (s ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
58
|
+
const PROVISION_REF_RE = /^@?g(\d+):([a-z0-9_-]+)$/i;
|
|
59
|
+
const PROVISION_REF_ID_RE = /^(.+)_(\d+)$/;
|
|
42
60
|
// The label a host sees + targets by. Prefer the most human, stable signal.
|
|
43
61
|
export function elementRef(el) {
|
|
44
62
|
const cand = el.visibleText ??
|
|
@@ -52,31 +70,131 @@ export function elementRef(el) {
|
|
|
52
70
|
const label = (cand ?? "").replace(/\s+/g, " ").trim();
|
|
53
71
|
return label.length > 0 ? label.slice(0, 80) : `${el.tag}#${el.index}`;
|
|
54
72
|
}
|
|
73
|
+
function shortHash(s) {
|
|
74
|
+
return createHash("sha256").update(s).digest("base64url").slice(0, 12);
|
|
75
|
+
}
|
|
76
|
+
export function stableElementId(el) {
|
|
77
|
+
return shortHash([
|
|
78
|
+
el.screenPath ?? "",
|
|
79
|
+
el.testId ?? "",
|
|
80
|
+
el.container ?? "",
|
|
81
|
+
el.role ?? "",
|
|
82
|
+
el.tag,
|
|
83
|
+
elementRef(el),
|
|
84
|
+
el.href ?? "",
|
|
85
|
+
el.type ?? "",
|
|
86
|
+
].join("\u001f"));
|
|
87
|
+
}
|
|
88
|
+
export function provisionElementRef(el, generation, ordinal = 1) {
|
|
89
|
+
return `@g${generation}:${stableElementId(el)}_${ordinal}`;
|
|
90
|
+
}
|
|
91
|
+
function parseProvisionRef(target) {
|
|
92
|
+
const m = target.trim().match(PROVISION_REF_RE);
|
|
93
|
+
if (m === null)
|
|
94
|
+
return null;
|
|
95
|
+
const rawId = m[2];
|
|
96
|
+
const idMatch = rawId.match(PROVISION_REF_ID_RE);
|
|
97
|
+
return {
|
|
98
|
+
generation: Number.parseInt(m[1], 10),
|
|
99
|
+
id: idMatch !== null ? idMatch[1] : rawId,
|
|
100
|
+
ordinal: idMatch !== null ? Number.parseInt(idMatch[2], 10) : null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export function provisionElementRefs(elements, generation) {
|
|
104
|
+
const seen = new Map();
|
|
105
|
+
const refs = new Map();
|
|
106
|
+
for (const el of elements) {
|
|
107
|
+
const id = stableElementId(el);
|
|
108
|
+
const ordinal = (seen.get(id) ?? 0) + 1;
|
|
109
|
+
seen.set(id, ordinal);
|
|
110
|
+
refs.set(el, provisionElementRef(el, generation, ordinal));
|
|
111
|
+
}
|
|
112
|
+
return refs;
|
|
113
|
+
}
|
|
114
|
+
export class StaleProvisionRefError extends Error {
|
|
115
|
+
refGeneration;
|
|
116
|
+
currentGeneration;
|
|
117
|
+
code = "stale_ref";
|
|
118
|
+
constructor(refGeneration, currentGeneration) {
|
|
119
|
+
super(`stale_ref: target is from observation generation ${refGeneration}, ` +
|
|
120
|
+
`but current generation is ${currentGeneration}. Call operate_observe and retry with a fresh ref.`);
|
|
121
|
+
this.refGeneration = refGeneration;
|
|
122
|
+
this.currentGeneration = currentGeneration;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export class AmbiguousProvisionTargetError extends Error {
|
|
126
|
+
target;
|
|
127
|
+
candidates;
|
|
128
|
+
code = "ambiguous_target";
|
|
129
|
+
constructor(target, candidates) {
|
|
130
|
+
super(`ambiguous_target: "${target}" matched ${candidates.length} elements. ` +
|
|
131
|
+
`Retry with one exact ref/path: ${candidates.slice(0, 8).join(", ")}`);
|
|
132
|
+
this.target = target;
|
|
133
|
+
this.candidates = candidates;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function elementTargetKeys(el) {
|
|
137
|
+
return [el.screenPath ?? null, el.testId ?? null, elementRef(el)].flatMap((s) => {
|
|
138
|
+
const v = (s ?? "").replace(/\s+/g, " ").trim();
|
|
139
|
+
return v.length > 0 ? [v] : [];
|
|
140
|
+
});
|
|
141
|
+
}
|
|
55
142
|
// Resolve a host-supplied target string to one live element. Matching is by
|
|
56
|
-
//
|
|
57
|
-
// null when nothing matches — the caller surfaces that rather than
|
|
58
|
-
|
|
143
|
+
// structured path, test id, or label text, scored exact > startsWith > contains.
|
|
144
|
+
// Returns null when nothing matches — the caller surfaces that rather than
|
|
145
|
+
// guessing.
|
|
146
|
+
export function resolveTarget(elements, target, currentGeneration) {
|
|
147
|
+
const parsedRef = parseProvisionRef(target);
|
|
148
|
+
if (parsedRef !== null) {
|
|
149
|
+
if (currentGeneration !== undefined && parsedRef.generation !== currentGeneration) {
|
|
150
|
+
throw new StaleProvisionRefError(parsedRef.generation, currentGeneration);
|
|
151
|
+
}
|
|
152
|
+
const matches = elements.filter((el) => stableElementId(el) === parsedRef.id);
|
|
153
|
+
if (parsedRef.ordinal !== null) {
|
|
154
|
+
const match = matches[parsedRef.ordinal - 1];
|
|
155
|
+
return match ?? null;
|
|
156
|
+
}
|
|
157
|
+
if (matches.length === 1)
|
|
158
|
+
return matches[0];
|
|
159
|
+
if (matches.length > 1) {
|
|
160
|
+
throw new AmbiguousProvisionTargetError(target, matches.map((el) => `${el.screenPath ?? elementRef(el)} (${elementRef(el)})`));
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
59
164
|
const want = norm(target);
|
|
60
165
|
if (want.length === 0)
|
|
61
166
|
return null;
|
|
62
167
|
let best = null;
|
|
168
|
+
let tied = [];
|
|
63
169
|
for (const el of elements) {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
170
|
+
for (const [i, raw] of elementTargetKeys(el).entries()) {
|
|
171
|
+
const label = norm(raw);
|
|
172
|
+
let score = 0;
|
|
173
|
+
const exact = i === 0 ? 120 : i === 1 ? 110 : 100;
|
|
174
|
+
if (label === want)
|
|
175
|
+
score = exact;
|
|
176
|
+
else if (label.startsWith(want))
|
|
177
|
+
score = 70;
|
|
178
|
+
else if (label.includes(want))
|
|
179
|
+
score = 50;
|
|
180
|
+
else if (want.includes(label) && label.length >= 2)
|
|
181
|
+
score = 30;
|
|
182
|
+
if (score === 0)
|
|
183
|
+
continue;
|
|
184
|
+
// Prefer shorter labels at equal score (a more specific match).
|
|
185
|
+
const adjusted = score - label.length * 0.01;
|
|
186
|
+
if (best === null || adjusted > best.score) {
|
|
187
|
+
best = { el, score: adjusted };
|
|
188
|
+
tied = [el];
|
|
189
|
+
}
|
|
190
|
+
else if (Math.abs(adjusted - best.score) < 0.000001) {
|
|
191
|
+
if (!tied.includes(el))
|
|
192
|
+
tied.push(el);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (best !== null && tied.length > 1) {
|
|
197
|
+
throw new AmbiguousProvisionTargetError(target, tied.map((el) => `${el.screenPath ?? elementRef(el)} (${elementRef(el)})`));
|
|
80
198
|
}
|
|
81
199
|
return best?.el ?? null;
|
|
82
200
|
}
|
|
@@ -100,6 +218,290 @@ export function hostAllowed(url, allowedHosts) {
|
|
|
100
218
|
return true;
|
|
101
219
|
return false;
|
|
102
220
|
}
|
|
221
|
+
// A two-label public suffix we must never let a single allow_host widen to —
|
|
222
|
+
// adding "co.uk" would green-light every *.co.uk. Small curated set (the ones
|
|
223
|
+
// the operator surface realistically touches); not a full PSL.
|
|
224
|
+
const TWO_LABEL_PUBLIC_SUFFIXES = new Set([
|
|
225
|
+
"co.uk", "org.uk", "gov.uk", "ac.uk", "com.au", "net.au", "org.au",
|
|
226
|
+
"co.jp", "co.nz", "co.in", "com.br", "co.za", "com.cn",
|
|
227
|
+
"github.io", "web.app", "firebaseapp.com", "pages.dev", "workers.dev",
|
|
228
|
+
"vercel.app", "netlify.app", "herokuapp.com",
|
|
229
|
+
]);
|
|
230
|
+
// Validate an agent-declared allow_host host. Returns the normalized bare
|
|
231
|
+
// hostname or an error string. Hardened (Codex): reject wildcards, ports,
|
|
232
|
+
// schemes/paths, IDNA/punycode + non-ASCII (lookalike-spoof defense), IPv4/IPv6
|
|
233
|
+
// literals, localhost/private hosts, bare TLDs, and two-label public suffixes.
|
|
234
|
+
// This matters more now that type_secret can enter a secret on these hosts.
|
|
235
|
+
export function validateAllowHost(raw) {
|
|
236
|
+
const v = raw.trim().toLowerCase();
|
|
237
|
+
if (v.length === 0 || v.length > 253)
|
|
238
|
+
return { error: "host empty or too long" };
|
|
239
|
+
if (/[/:@?#*\s]/.test(v))
|
|
240
|
+
return { error: "host must be a bare hostname (no scheme, port, path, wildcard, or whitespace)" };
|
|
241
|
+
if (/[^a-z0-9.-]/.test(v))
|
|
242
|
+
return { error: "host has non-ASCII or invalid characters (punycode/unicode spoofing rejected)" };
|
|
243
|
+
if (v.includes("xn--"))
|
|
244
|
+
return { error: "punycode (xn--) hosts rejected — homograph-spoof risk" };
|
|
245
|
+
if (v.startsWith(".") || v.endsWith(".") || v.includes(".."))
|
|
246
|
+
return { error: "malformed host (leading/trailing/double dot)" };
|
|
247
|
+
if (v === "localhost" || v.endsWith(".localhost"))
|
|
248
|
+
return { error: "localhost is not an allowable cross-host" };
|
|
249
|
+
// IPv4 literal / dotted-quad — reject (egress + transfer must be by name).
|
|
250
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(v))
|
|
251
|
+
return { error: "IP-address hosts are not allowed (declare a hostname)" };
|
|
252
|
+
// IPv6 would contain ':' — already rejected by the ':' check above.
|
|
253
|
+
const labels = v.split(".");
|
|
254
|
+
if (labels.length < 2)
|
|
255
|
+
return { error: "bare TLD / single-label host not allowed" };
|
|
256
|
+
if (labels.some((l) => l.length === 0 || l.length > 63))
|
|
257
|
+
return { error: "invalid host label length" };
|
|
258
|
+
if (TWO_LABEL_PUBLIC_SUFFIXES.has(v))
|
|
259
|
+
return { error: `"${v}" is a public suffix — widening to it would allow every subdomain` };
|
|
260
|
+
return { host: v };
|
|
261
|
+
}
|
|
262
|
+
function visibleModeMarkers(pageText) {
|
|
263
|
+
const text = pageText.replace(/\s+/g, " ").trim();
|
|
264
|
+
const markers = [];
|
|
265
|
+
if (/\b(?:test|sandbox)\s+(?:mode|usage|environment|workspace)\b/i.test(text) ||
|
|
266
|
+
/\b(?:mode|environment|workspace)\s*[:=-]?\s*(?:test|sandbox)\b/i.test(text)) {
|
|
267
|
+
markers.push("test/sandbox mode");
|
|
268
|
+
}
|
|
269
|
+
if (/\b(?:live|production)\s+mode\b/i.test(text) ||
|
|
270
|
+
/\b(?:mode|environment|workspace)\s*[:=-]?\s*(?:live|production)\b/i.test(text)) {
|
|
271
|
+
markers.push("live/production mode");
|
|
272
|
+
}
|
|
273
|
+
return markers;
|
|
274
|
+
}
|
|
275
|
+
function appSurfaceMarkers(pageText) {
|
|
276
|
+
const text = pageText.replace(/\s+/g, " ").trim();
|
|
277
|
+
const markers = [];
|
|
278
|
+
const defs = [
|
|
279
|
+
["dashboard", /\bdashboard\b/i],
|
|
280
|
+
["products", /\bproducts?\b/i],
|
|
281
|
+
["customers", /\bcustomers?\b/i],
|
|
282
|
+
["payments", /\bpayments?\b/i],
|
|
283
|
+
["developers", /\bdevelopers?\b/i],
|
|
284
|
+
["api keys", /\bapi\s+keys?\b/i],
|
|
285
|
+
["settings", /\bsettings\b/i],
|
|
286
|
+
["workspace", /\bworkspace\b/i],
|
|
287
|
+
["project", /\bproject\b/i],
|
|
288
|
+
["billing", /\bbilling\b/i],
|
|
289
|
+
["usage", /\busage\b/i],
|
|
290
|
+
["team", /\bteam\b/i],
|
|
291
|
+
];
|
|
292
|
+
for (const [name, re] of defs) {
|
|
293
|
+
if (re.test(text))
|
|
294
|
+
markers.push(name);
|
|
295
|
+
}
|
|
296
|
+
for (const mode of visibleModeMarkers(text))
|
|
297
|
+
markers.push(mode);
|
|
298
|
+
return [...new Set(markers)].slice(0, 8);
|
|
299
|
+
}
|
|
300
|
+
function authenticatedAppSurfaceMarkers(pageText) {
|
|
301
|
+
const markers = appSurfaceMarkers(pageText);
|
|
302
|
+
const modeMarkers = visibleModeMarkers(pageText);
|
|
303
|
+
if (modeMarkers.length > 0)
|
|
304
|
+
return markers;
|
|
305
|
+
return markers.length >= 2 ? markers : [];
|
|
306
|
+
}
|
|
307
|
+
function hasAccountSetupOverlay(pageText) {
|
|
308
|
+
const text = pageText.replace(/\s+/g, " ").trim();
|
|
309
|
+
return (/\b(?:finish|complete|set up|setup)\s+(?:creating\s+|setting\s+up\s+)?(?:your\s+)?(?:account|profile|organization|workspace|business)\b/i.test(text) ||
|
|
310
|
+
/\bcreate\s+(?:your\s+)?account\b/i.test(text) ||
|
|
311
|
+
/\btell us about (?:yourself|your business|your organization|your company)\b/i.test(text));
|
|
312
|
+
}
|
|
313
|
+
// An onboarding / org-or-workspace creation form that GATES the keys page. These
|
|
314
|
+
// are NOT walls — the agent should fill the required fields with sensible
|
|
315
|
+
// inferred values and submit to proceed. Broader than hasAccountSetupOverlay
|
|
316
|
+
// (it also catches "create organization / you aren't part of an org yet").
|
|
317
|
+
export function isOnboardingOrOrgForm(pageText) {
|
|
318
|
+
const text = pageText.replace(/\s+/g, " ").trim();
|
|
319
|
+
if (hasAccountSetupOverlay(text))
|
|
320
|
+
return true;
|
|
321
|
+
return (/\byou\s+(?:aren'?t|are not|do not|don'?t)\s+(?:part of|belong to|have)\b.*\borgani[sz]ation\b/i.test(text) ||
|
|
322
|
+
/\bcreate\s+(?:a\s+|your\s+|an\s+|new\s+)?(?:organi[sz]ation|org|workspace|team|project|company)\b/i.test(text) ||
|
|
323
|
+
/\bname\s+(?:your\s+)?(?:organi[sz]ation|workspace|team|project|company)\b/i.test(text) ||
|
|
324
|
+
/\b(?:what'?s|what is)\s+your\s+name\b/i.test(text) ||
|
|
325
|
+
/\bget\s+started\b.*\b(?:name|organi[sz]ation|workspace|team)\b/i.test(text));
|
|
326
|
+
}
|
|
327
|
+
// A "copy your key NOW — it won't be shown again" one-time reveal (Luma, many
|
|
328
|
+
// console secrets). The value is on screen but vanishes on dismiss/navigate, so
|
|
329
|
+
// the agent must extract it immediately (and name it with secret_label), not
|
|
330
|
+
// click away first.
|
|
331
|
+
export function hasOneTimeSecretModal(pageText) {
|
|
332
|
+
const text = pageText.replace(/\s+/g, " ").trim();
|
|
333
|
+
return (/\b(?:won'?t|will not|can'?t|cannot|never)\b[\s\w]{0,30}?\b(?:shown|displayed|see|view|retriev\w*|access\w*)\b[\s\w]{0,20}?\bagain\b/i.test(text) ||
|
|
334
|
+
/\b(?:only|last)\s+time\b.*\b(?:see|view|copy|shown)\b/i.test(text) ||
|
|
335
|
+
/\b(?:copy|save|store)\s+(?:and\s+save\s+)?(?:your\s+|this\s+|the\s+)?(?:secret|api\s*key|key|token|credential)\b.*\b(?:now|securely|somewhere|before)\b/i.test(text) ||
|
|
336
|
+
/\bmake\s+sure\s+to\s+(?:copy|save|store)\b/i.test(text));
|
|
337
|
+
}
|
|
338
|
+
function isAccountSetupActionTarget(target) {
|
|
339
|
+
return /\b(?:create|finish|complete|set up|setup)\s+(?:your\s+)?(?:account|profile|organization|workspace|business)\b/i.test(target);
|
|
340
|
+
}
|
|
341
|
+
function isBillingObjectActionTarget(target) {
|
|
342
|
+
return (/\b(create|save|add|finish)\b/i.test(target) &&
|
|
343
|
+
/\b(product|price|pricing|subscription|billing|payment|invoice|checkout)\b/i.test(target));
|
|
344
|
+
}
|
|
345
|
+
export function provisionPerceptionGuidance(pageText) {
|
|
346
|
+
const appMarkers = authenticatedAppSurfaceMarkers(pageText);
|
|
347
|
+
const modeMarkers = visibleModeMarkers(pageText);
|
|
348
|
+
const setupOverlay = hasAccountSetupOverlay(pageText);
|
|
349
|
+
const parts = [];
|
|
350
|
+
// One-time secret reveal — extract NOW; it vanishes if you navigate away.
|
|
351
|
+
if (hasOneTimeSecretModal(pageText)) {
|
|
352
|
+
parts.push("One-time secret: the key/secret is shown HERE and will NOT be shown again. " +
|
|
353
|
+
"Extract it immediately with operate_extract (use secret_label to pick the " +
|
|
354
|
+
"right field if several values are shown, and into_slot/store to capture it) " +
|
|
355
|
+
"BEFORE clicking anything that could dismiss this modal or navigate away.");
|
|
356
|
+
}
|
|
357
|
+
// Onboarding / org-creation form — fill it, don't treat it as a wall.
|
|
358
|
+
if (isOnboardingOrOrgForm(pageText)) {
|
|
359
|
+
parts.push("Onboarding/setup form: this is NOT a wall and NOT a failure. It gates the " +
|
|
360
|
+
"keys/dashboard behind a setup step. Fill the required fields with sensible " +
|
|
361
|
+
"inferred values (your name; an organization/workspace/team name such as your " +
|
|
362
|
+
"name or 'Personal'; pick the smallest/free plan) and submit to continue. Do " +
|
|
363
|
+
"not stop or report a wall — drive through it to reach the keys page.");
|
|
364
|
+
}
|
|
365
|
+
if (modeMarkers.length > 0) {
|
|
366
|
+
parts.push(`Mode marker visible: ${modeMarkers.join(", ")}.`);
|
|
367
|
+
}
|
|
368
|
+
else if (appMarkers.length > 0 || setupOverlay) {
|
|
369
|
+
parts.push("No test/sandbox/live mode marker is visible. For mode-sensitive tasks, do not create or save objects until the required mode is visible.");
|
|
370
|
+
}
|
|
371
|
+
if (setupOverlay && appMarkers.length > 0) {
|
|
372
|
+
parts.push(`Screen perception: account/setup overlay text is present while authenticated app markers are also visible (${appMarkers.join(", ")}). This often means a foreground onboarding modal is blocking an already-authenticated app, not that OAuth failed. Do not restart OAuth or navigate to login solely because the overlay says create/finish account; either satisfy the minimal required setup once, or use same-origin app navigation/direct dashboard URLs toward the user's goal.`);
|
|
373
|
+
}
|
|
374
|
+
else if (appMarkers.length > 0) {
|
|
375
|
+
parts.push(`Screen perception: authenticated app markers are visible (${appMarkers.join(", ")}). Prefer app navigation over restarting OAuth unless the current URL is clearly an identity-provider login page.`);
|
|
376
|
+
}
|
|
377
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
378
|
+
}
|
|
379
|
+
export function shouldBlockUnsafeProvisionAction(pageText, action) {
|
|
380
|
+
if (!("target" in action))
|
|
381
|
+
return null;
|
|
382
|
+
const appMarkers = authenticatedAppSurfaceMarkers(pageText);
|
|
383
|
+
if (appMarkers.length > 0 &&
|
|
384
|
+
isAccountSetupActionTarget(action.target) &&
|
|
385
|
+
hasAccountSetupOverlay(pageText)) {
|
|
386
|
+
return (`Perception guard: "${action.target}" looks like an account/setup overlay action, ` +
|
|
387
|
+
`but authenticated app markers are already visible (${appMarkers.join(", ")}). ` +
|
|
388
|
+
`Do not retry OAuth or repeatedly press this overlay; use app navigation/direct ` +
|
|
389
|
+
`same-origin URLs or complete only the minimal required setup.`);
|
|
390
|
+
}
|
|
391
|
+
if (isBillingObjectActionTarget(action.target) &&
|
|
392
|
+
/\b(?:live|production)\s+mode\b/i.test(pageText)) {
|
|
393
|
+
return (`Mode safety guard: "${action.target}" can create or save billing objects, ` +
|
|
394
|
+
`but live/production mode is visible. Switch to the required test/sandbox mode before acting.`);
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
export function buildScreenOutline(elements, pageText) {
|
|
399
|
+
if (elements.length === 0)
|
|
400
|
+
return undefined;
|
|
401
|
+
const byRegion = new Map();
|
|
402
|
+
for (const el of elements) {
|
|
403
|
+
const id = el.container ?? "body:root";
|
|
404
|
+
const role = id.split(":")[0] ?? "region";
|
|
405
|
+
const existing = byRegion.get(id);
|
|
406
|
+
const region = existing ?? {
|
|
407
|
+
id,
|
|
408
|
+
role,
|
|
409
|
+
topmost: false,
|
|
410
|
+
occluded_by: null,
|
|
411
|
+
children: [],
|
|
412
|
+
};
|
|
413
|
+
if (el.topmost === true) {
|
|
414
|
+
region.topmost = true;
|
|
415
|
+
region.occluded_by = null;
|
|
416
|
+
}
|
|
417
|
+
else if (region.occluded_by === null &&
|
|
418
|
+
el.occludedBy !== null &&
|
|
419
|
+
el.occludedBy !== undefined) {
|
|
420
|
+
region.occluded_by = el.occludedBy;
|
|
421
|
+
}
|
|
422
|
+
if (region.children.length < 10) {
|
|
423
|
+
region.children.push({
|
|
424
|
+
ref: el.screenPath ?? elementRef(el),
|
|
425
|
+
role: el.role,
|
|
426
|
+
text: elementRef(el),
|
|
427
|
+
href: el.href ?? null,
|
|
428
|
+
topmost: el.topmost ?? null,
|
|
429
|
+
occluded_by: el.occludedBy ?? null,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
byRegion.set(id, region);
|
|
433
|
+
}
|
|
434
|
+
const regions = [...byRegion.values()].slice(0, 12);
|
|
435
|
+
const foreground = regions.find((r) => r.topmost && r.role === "dialog")?.id ??
|
|
436
|
+
regions.find((r) => r.topmost)?.id ??
|
|
437
|
+
null;
|
|
438
|
+
return {
|
|
439
|
+
foreground,
|
|
440
|
+
mode_markers: visibleModeMarkers(pageText),
|
|
441
|
+
regions,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function roleForAccessibility(el) {
|
|
445
|
+
if (el.role !== null && el.role.length > 0)
|
|
446
|
+
return el.role;
|
|
447
|
+
if (el.tag === "a")
|
|
448
|
+
return "link";
|
|
449
|
+
if (el.tag === "input")
|
|
450
|
+
return el.type ?? "textbox";
|
|
451
|
+
return el.tag;
|
|
452
|
+
}
|
|
453
|
+
export function buildAccessibilitySnapshot(elements, generation, limit = 12000) {
|
|
454
|
+
if (elements.length === 0)
|
|
455
|
+
return undefined;
|
|
456
|
+
const refs = provisionElementRefs(elements, generation);
|
|
457
|
+
const byRegion = new Map();
|
|
458
|
+
for (const el of elements) {
|
|
459
|
+
const region = el.container ?? "body:root";
|
|
460
|
+
const group = byRegion.get(region) ?? [];
|
|
461
|
+
group.push(el);
|
|
462
|
+
byRegion.set(region, group);
|
|
463
|
+
}
|
|
464
|
+
const entries = [...byRegion.entries()];
|
|
465
|
+
const structurallyTruncated = entries.length > 24 || entries.some(([, group]) => group.length > 16);
|
|
466
|
+
const lines = ["RootWebArea"];
|
|
467
|
+
for (const [region, group] of entries.slice(0, 24)) {
|
|
468
|
+
lines.push(` region "${region}"`);
|
|
469
|
+
for (const el of group.slice(0, 16)) {
|
|
470
|
+
const label = elementRef(el).replace(/"/g, '\\"');
|
|
471
|
+
const role = roleForAccessibility(el);
|
|
472
|
+
const flags = [
|
|
473
|
+
el.value !== undefined && el.value !== null ? `value="${el.value.slice(0, 60)}"` : null,
|
|
474
|
+
el.checked !== undefined && el.checked !== null ? `checked=${el.checked}` : null,
|
|
475
|
+
el.href !== undefined && el.href !== null ? `href="${el.href.slice(0, 120)}"` : null,
|
|
476
|
+
el.topmost === false ? `occluded_by="${el.occludedBy ?? "unknown"}"` : null,
|
|
477
|
+
].filter((v) => v !== null);
|
|
478
|
+
lines.push(` ${role} "${label}" ref=${refs.get(el) ?? provisionElementRef(el, generation)}` +
|
|
479
|
+
(flags.length > 0 ? ` ${flags.join(" ")}` : ""));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (structurallyTruncated) {
|
|
483
|
+
lines.push(" ... (truncated, more interactive elements omitted)");
|
|
484
|
+
}
|
|
485
|
+
const tree = lines.join("\n");
|
|
486
|
+
if (tree.length <= limit) {
|
|
487
|
+
return {
|
|
488
|
+
tree,
|
|
489
|
+
refs: elements.length,
|
|
490
|
+
truncated: structurallyTruncated,
|
|
491
|
+
total_chars: tree.length,
|
|
492
|
+
source: "interactive_dom",
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const cut = tree.lastIndexOf("\n", limit);
|
|
496
|
+
const text = tree.slice(0, cut > 0 ? cut : limit);
|
|
497
|
+
return {
|
|
498
|
+
tree: text,
|
|
499
|
+
refs: elements.length,
|
|
500
|
+
truncated: true,
|
|
501
|
+
total_chars: tree.length,
|
|
502
|
+
source: "interactive_dom",
|
|
503
|
+
};
|
|
504
|
+
}
|
|
103
505
|
function registrableHost(url) {
|
|
104
506
|
try {
|
|
105
507
|
return new URL(url).hostname.toLowerCase();
|
|
@@ -108,23 +510,107 @@ function registrableHost(url) {
|
|
|
108
510
|
return null;
|
|
109
511
|
}
|
|
110
512
|
}
|
|
513
|
+
function baseDomain(host) {
|
|
514
|
+
const parts = host.toLowerCase().split(".").filter(Boolean);
|
|
515
|
+
if (parts.length <= 2)
|
|
516
|
+
return parts.join(".");
|
|
517
|
+
return parts.slice(-2).join(".");
|
|
518
|
+
}
|
|
519
|
+
function widenAllowedHostsFromCurrentUrl(session) {
|
|
520
|
+
const host = registrableHost(session.browser.currentUrl());
|
|
521
|
+
if (host === null || session.allowedHosts.some((e) => e.host === host))
|
|
522
|
+
return;
|
|
523
|
+
const currentBase = baseDomain(host);
|
|
524
|
+
// Chain ONLY off START-sourced hosts: an organic redirect that shares a base
|
|
525
|
+
// domain with a host the user declared at start is trusted. We do NOT chain
|
|
526
|
+
// off mid_session or prior auto_widen hosts — that would let a single
|
|
527
|
+
// agent-declared host silently pull in a whole sibling tree (scope creep).
|
|
528
|
+
if (session.allowedHosts.some((e) => e.source === "start" && baseDomain(e.host) === currentBase)) {
|
|
529
|
+
session.allowedHosts.push({ host, source: "auto_widen" });
|
|
530
|
+
audit(session.id, "scope_widen", { host, source: "auto_widen", allowed_hosts: hostStrings(session) });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
export function googleSessionGate(liveProviders) {
|
|
534
|
+
if (liveProviders.includes("google"))
|
|
535
|
+
return { ok: true };
|
|
536
|
+
return {
|
|
537
|
+
ok: false,
|
|
538
|
+
needs_user: {
|
|
539
|
+
wall: "google_session",
|
|
540
|
+
message: "No live Google session in the bot profile, so the operator cannot act " +
|
|
541
|
+
"as you yet. Run `npx @trusty-squire/mcp connect` (or refresh the Google " +
|
|
542
|
+
"login) and retry — the task has NOT started and nothing was changed.",
|
|
543
|
+
resume: "connect",
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
async function ensureProvisionPrimaryProviderSession(profileDir) {
|
|
548
|
+
const initial = await detectActiveProviderSessions(profileDir).catch(() => []);
|
|
549
|
+
if (initial.includes("google"))
|
|
550
|
+
return initial;
|
|
551
|
+
const result = await ensureOAuthSession({
|
|
552
|
+
provider: "google",
|
|
553
|
+
...(profileDir !== undefined ? { profileDir } : {}),
|
|
554
|
+
});
|
|
555
|
+
if (result.status !== "already_valid" && result.status !== "logged_in") {
|
|
556
|
+
return initial;
|
|
557
|
+
}
|
|
558
|
+
const after = await detectActiveProviderSessions(profileDir).catch(() => []);
|
|
559
|
+
return after.includes("google") ? after : ["google", ...after];
|
|
560
|
+
}
|
|
111
561
|
export async function startProvisionSession(opts) {
|
|
112
562
|
const id = randomUUID();
|
|
563
|
+
const liveProviders = await ensureProvisionPrimaryProviderSession(opts.profileDir);
|
|
564
|
+
// Change 5 — fail-closed identity gate BEFORE driving. If an operate task
|
|
565
|
+
// needs to act as the user and there's no live Google session, hand back now;
|
|
566
|
+
// do not start the browser or the task. No autonomous login is attempted.
|
|
567
|
+
if (opts.requireLiveIdentity === true) {
|
|
568
|
+
const gate = googleSessionGate(liveProviders);
|
|
569
|
+
if (!gate.ok) {
|
|
570
|
+
audit(id, "connect_gate", { ok: false, wall: "google_session" });
|
|
571
|
+
return { session_id: id, url: "", text: "", elements: [], needs_user: gate.needs_user };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
113
574
|
const browser = new BrowserController({
|
|
114
575
|
...(opts.profileDir !== undefined ? { profileDir: opts.profileDir } : {}),
|
|
115
576
|
...(opts.proxyUrl !== undefined ? { proxyUrl: opts.proxyUrl } : {}),
|
|
116
577
|
});
|
|
117
578
|
await browser.start();
|
|
118
579
|
const targetHost = registrableHost(opts.serviceUrl);
|
|
119
|
-
const
|
|
580
|
+
const seedHosts = [
|
|
120
581
|
...(targetHost !== null ? [targetHost] : []),
|
|
121
582
|
...(opts.extraAllowedHosts ?? []),
|
|
122
583
|
];
|
|
123
|
-
|
|
584
|
+
// All start-declared hosts are sourced "start" — auto-widen chains off these,
|
|
585
|
+
// and credential egress may seed from these (but never from mid_session).
|
|
586
|
+
const allowedHosts = [...new Set(seedHosts)].map((host) => ({
|
|
587
|
+
host,
|
|
588
|
+
source: "start",
|
|
589
|
+
}));
|
|
590
|
+
const session = {
|
|
591
|
+
id,
|
|
592
|
+
browser,
|
|
593
|
+
allowedHosts,
|
|
594
|
+
generation: 0,
|
|
595
|
+
secretSlots: new Map(),
|
|
596
|
+
lastElements: [],
|
|
597
|
+
};
|
|
124
598
|
sessions.set(id, session);
|
|
125
|
-
audit(id, "start", {
|
|
599
|
+
audit(id, "start", {
|
|
600
|
+
service_url: opts.serviceUrl,
|
|
601
|
+
allowed_hosts: hostStrings(session),
|
|
602
|
+
has_hint: opts.hint !== undefined,
|
|
603
|
+
});
|
|
126
604
|
await browser.goto(opts.serviceUrl);
|
|
127
|
-
|
|
605
|
+
const observation = await observeSession(session);
|
|
606
|
+
// Tell the agent which provider the user actually has a live session for
|
|
607
|
+
// (Google-preferred) — the bot knows from the profile cookies, so the agent
|
|
608
|
+
// doesn't have to guess. Composed with the skill route hint (if any).
|
|
609
|
+
const hintParts = [
|
|
610
|
+
loginSessionGuidance(liveProviders),
|
|
611
|
+
...(opts.hint !== undefined ? [opts.hint] : []),
|
|
612
|
+
];
|
|
613
|
+
return { ...observation, hint: hintParts.join("\n") };
|
|
128
614
|
}
|
|
129
615
|
export async function observe(sessionId) {
|
|
130
616
|
const session = sessions.get(sessionId);
|
|
@@ -132,16 +618,61 @@ export async function observe(sessionId) {
|
|
|
132
618
|
throw new Error(`unknown provision session ${sessionId}`);
|
|
133
619
|
return await observeSession(session);
|
|
134
620
|
}
|
|
621
|
+
// Hosts to seed credential EGRESS from when storing a key extracted in this
|
|
622
|
+
// session: start + auto_widen, NEVER mid_session task scope (a wide multi-app
|
|
623
|
+
// operate scope must not silently over-grant a key's egress allow-list).
|
|
624
|
+
export function observedHostsForSession(sessionId) {
|
|
625
|
+
const session = sessions.get(sessionId);
|
|
626
|
+
if (session === undefined)
|
|
627
|
+
throw new Error(`unknown provision session ${sessionId}`);
|
|
628
|
+
widenAllowedHostsFromCurrentUrl(session);
|
|
629
|
+
return [...new Set(egressSeedHosts(session))];
|
|
630
|
+
}
|
|
631
|
+
// Mask a secret for a host-facing preview: keep a short prefix + last few
|
|
632
|
+
// chars, redact the middle. Never reveals enough to reconstruct the value.
|
|
633
|
+
export function maskSecretValue(value) {
|
|
634
|
+
const v = value.trim();
|
|
635
|
+
if (v.length <= 8)
|
|
636
|
+
return "••••";
|
|
637
|
+
const head = v.slice(0, Math.min(6, v.length - 4));
|
|
638
|
+
const tail = v.slice(-3);
|
|
639
|
+
return `${head}••••${tail}`;
|
|
640
|
+
}
|
|
641
|
+
// Stash a secret into a session-local slot and return ONLY a handle + masked
|
|
642
|
+
// preview. The raw value stays in the Session and is never returned to the
|
|
643
|
+
// host — a later type_secret enters it into another site's form. Extends the
|
|
644
|
+
// write-only-vault moat to in-session credential transfer.
|
|
645
|
+
export function stashSecretSlot(sessionId, slot, value) {
|
|
646
|
+
const session = sessions.get(sessionId);
|
|
647
|
+
if (session === undefined)
|
|
648
|
+
throw new Error(`unknown provision session ${sessionId}`);
|
|
649
|
+
session.secretSlots.set(slot, value);
|
|
650
|
+
audit(sessionId, "secret_slot_set", { slot, length: value.length });
|
|
651
|
+
return { slot, preview: maskSecretValue(value), length: value.length };
|
|
652
|
+
}
|
|
135
653
|
async function observeSession(session) {
|
|
654
|
+
session.browser.recoverActivePage();
|
|
655
|
+
widenAllowedHostsFromCurrentUrl(session);
|
|
656
|
+
session.generation += 1;
|
|
657
|
+
const generation = session.generation;
|
|
136
658
|
const elements = await session.browser.extractInteractiveElements();
|
|
137
659
|
session.lastElements = elements;
|
|
138
660
|
const text = await session.browser.extractVisibleText();
|
|
661
|
+
const normalizedText = text.replace(/\s+/g, " ").trim().slice(0, 4000);
|
|
662
|
+
const guidance = provisionPerceptionGuidance(normalizedText);
|
|
663
|
+
const screen = buildScreenOutline(elements, normalizedText);
|
|
664
|
+
const accessibility = buildAccessibilitySnapshot(elements, generation);
|
|
665
|
+
const refs = provisionElementRefs(elements, generation);
|
|
139
666
|
return {
|
|
140
667
|
session_id: session.id,
|
|
141
668
|
url: session.browser.currentUrl(),
|
|
142
|
-
text:
|
|
669
|
+
text: normalizedText,
|
|
670
|
+
...(guidance !== undefined ? { guidance } : {}),
|
|
671
|
+
...(screen !== undefined ? { screen } : {}),
|
|
672
|
+
...(accessibility !== undefined ? { accessibility } : {}),
|
|
143
673
|
elements: elements.map((el) => ({
|
|
144
|
-
ref:
|
|
674
|
+
ref: refs.get(el) ?? provisionElementRef(el, generation),
|
|
675
|
+
label: elementRef(el),
|
|
145
676
|
tag: el.tag,
|
|
146
677
|
role: el.role,
|
|
147
678
|
type: el.type,
|
|
@@ -149,6 +680,10 @@ async function observeSession(session) {
|
|
|
149
680
|
checked: el.checked ?? null,
|
|
150
681
|
href: el.href ?? null,
|
|
151
682
|
testId: el.testId ?? null,
|
|
683
|
+
path: el.screenPath ?? null,
|
|
684
|
+
container: el.container ?? null,
|
|
685
|
+
topmost: el.topmost ?? null,
|
|
686
|
+
occluded_by: el.occludedBy ?? null,
|
|
152
687
|
})),
|
|
153
688
|
};
|
|
154
689
|
}
|
|
@@ -164,13 +699,25 @@ export async function act(sessionId, action) {
|
|
|
164
699
|
});
|
|
165
700
|
switch (action.kind) {
|
|
166
701
|
case "goto": {
|
|
167
|
-
if (!hostAllowed(action.url, session
|
|
702
|
+
if (!hostAllowed(action.url, hostStrings(session))) {
|
|
168
703
|
throw new Error(`goto blocked by domain-scope: ${action.url} is outside the allowed hosts ` +
|
|
169
|
-
`[${session.
|
|
704
|
+
`[${hostStrings(session).join(", ")}] + auth providers. ` +
|
|
705
|
+
`Declare it first with an allow_host action if this task spans it.`);
|
|
170
706
|
}
|
|
171
707
|
await browser.goto(action.url);
|
|
172
708
|
break;
|
|
173
709
|
}
|
|
710
|
+
case "allow_host": {
|
|
711
|
+
const checked = validateAllowHost(action.host);
|
|
712
|
+
if ("error" in checked) {
|
|
713
|
+
throw new Error(`allow_host rejected "${action.host}": ${checked.error}`);
|
|
714
|
+
}
|
|
715
|
+
if (!session.allowedHosts.some((e) => e.host === checked.host)) {
|
|
716
|
+
session.allowedHosts.push({ host: checked.host, source: "mid_session" });
|
|
717
|
+
audit(sessionId, "allow_host", { host: checked.host, allowed_hosts: hostStrings(session) });
|
|
718
|
+
}
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
174
721
|
case "press": {
|
|
175
722
|
await browser.pressKey(action.key);
|
|
176
723
|
break;
|
|
@@ -179,17 +726,46 @@ export async function act(sessionId, action) {
|
|
|
179
726
|
await browser.settleAfterOAuth();
|
|
180
727
|
break;
|
|
181
728
|
}
|
|
729
|
+
case "scroll": {
|
|
730
|
+
await browser.scrollViewport(action.direction ?? "down");
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
case "type_secret": {
|
|
734
|
+
const value = session.secretSlots.get(action.slot);
|
|
735
|
+
if (value === undefined) {
|
|
736
|
+
throw new Error(`type_secret: no sealed slot named "${action.slot}". Capture it first with ` +
|
|
737
|
+
`operate_extract { into_slot: "${action.slot}" }. Known slots: ` +
|
|
738
|
+
`[${[...session.secretSlots.keys()].join(", ")}]`);
|
|
739
|
+
}
|
|
740
|
+
const fresh = await browser.extractInteractiveElements();
|
|
741
|
+
session.lastElements = fresh;
|
|
742
|
+
const el = resolveTarget(fresh, action.target, session.generation);
|
|
743
|
+
if (el === null) {
|
|
744
|
+
throw new Error(`type_secret: no element matched target "${action.target}".`);
|
|
745
|
+
}
|
|
746
|
+
// Type the REAL value into the page. It crosses only browser↔page; the
|
|
747
|
+
// value is never returned to the host and never logged.
|
|
748
|
+
await browser.type(el.selector, value);
|
|
749
|
+
audit(sessionId, "type_secret", { slot: action.slot, target: action.target, host: registrableHost(browser.currentUrl()) });
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
182
752
|
case "click":
|
|
183
753
|
case "js_click":
|
|
184
754
|
case "type":
|
|
185
755
|
case "oauth_click": {
|
|
756
|
+
const blockReason = shouldBlockUnsafeProvisionAction(await browser.extractVisibleText(), action);
|
|
757
|
+
if (blockReason !== null)
|
|
758
|
+
throw new Error(blockReason);
|
|
186
759
|
// Re-resolve against FRESH elements every act — never trust a stale index.
|
|
187
760
|
const fresh = await browser.extractInteractiveElements();
|
|
188
761
|
session.lastElements = fresh;
|
|
189
|
-
const el = resolveTarget(fresh, action.target);
|
|
762
|
+
const el = resolveTarget(fresh, action.target, session.generation);
|
|
190
763
|
if (el === null) {
|
|
191
764
|
throw new Error(`no element matched target "${action.target}". Visible: ` +
|
|
192
|
-
fresh
|
|
765
|
+
fresh
|
|
766
|
+
.map((e) => `"${e.screenPath ?? elementRef(e)}"`)
|
|
767
|
+
.slice(0, 20)
|
|
768
|
+
.join(", "));
|
|
193
769
|
}
|
|
194
770
|
if (action.kind === "click")
|
|
195
771
|
await browser.click(el.selector);
|
|
@@ -199,21 +775,207 @@ export async function act(sessionId, action) {
|
|
|
199
775
|
await browser.type(el.selector, action.text);
|
|
200
776
|
else
|
|
201
777
|
await browser.startOAuth(el.selector);
|
|
202
|
-
// Brief settle so a React state update (a card selection, a form-state
|
|
203
|
-
// commit) lands before the next observe — the radio-card "needed a
|
|
204
|
-
// re-observe" symptom from the live run.
|
|
205
778
|
if (action.kind !== "type")
|
|
206
|
-
await
|
|
779
|
+
await settleAfterStateChange(browser);
|
|
207
780
|
break;
|
|
208
781
|
}
|
|
209
782
|
}
|
|
210
783
|
return await observeSession(session);
|
|
211
784
|
}
|
|
785
|
+
async function settleAfterStateChange(browser) {
|
|
786
|
+
await settle(450);
|
|
787
|
+
await browser.waitForInteractiveDom(1, 2_000).catch(() => undefined);
|
|
788
|
+
for (let i = 0; i < 4; i += 1) {
|
|
789
|
+
const text = await browser.extractVisibleText().catch(() => "");
|
|
790
|
+
if (text.replace(/\s+/g, " ").trim().length > 0)
|
|
791
|
+
return;
|
|
792
|
+
await settle(300);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
212
795
|
const normLabelKey = (label) => label
|
|
213
796
|
.replace(/\s+/g, "_")
|
|
214
797
|
.replace(/[^a-z0-9_]/gi, "")
|
|
215
798
|
.toLowerCase()
|
|
216
799
|
.slice(0, 40);
|
|
800
|
+
// A real credential never looks like a code identifier. X's anti-bot tombstone
|
|
801
|
+
// ("JavaScript is not available…") leaked `loader.tweetUnavailableTombstoneHandler`
|
|
802
|
+
// (a JS function name) into the extractor, which wrote it to the vault as a key
|
|
803
|
+
// — a false-green. Reject any dotted member-access token (JWTs are the one
|
|
804
|
+
// legitimate dotted credential, guarded by their `eyJ` prefix).
|
|
805
|
+
export function looksLikeCodeIdentifier(s) {
|
|
806
|
+
const t = s.trim();
|
|
807
|
+
if (t.startsWith("eyJ"))
|
|
808
|
+
return false;
|
|
809
|
+
return /[A-Za-z]\.[A-Za-z]/.test(t);
|
|
810
|
+
}
|
|
811
|
+
function looksLikeCredentialValue(value) {
|
|
812
|
+
const v = value.trim();
|
|
813
|
+
if (v.length < 12)
|
|
814
|
+
return false;
|
|
815
|
+
if (looksLikeCodeIdentifier(v))
|
|
816
|
+
return false;
|
|
817
|
+
if (isCredentialNoise(v))
|
|
818
|
+
return false;
|
|
819
|
+
return (findCredentialTokens(v).includes(v) ||
|
|
820
|
+
/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(v) ||
|
|
821
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v));
|
|
822
|
+
}
|
|
823
|
+
function isCredentialNoise(value) {
|
|
824
|
+
const v = value.trim();
|
|
825
|
+
if (v.length === 0)
|
|
826
|
+
return true;
|
|
827
|
+
// Whitespace anywhere → page prose, not a key (a greeting like "Hi X, what do
|
|
828
|
+
// you want to make?", a sentence, "Owner: foo"). Real keys never contain spaces.
|
|
829
|
+
if (/\s/.test(v))
|
|
830
|
+
return true;
|
|
831
|
+
if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(v))
|
|
832
|
+
return true;
|
|
833
|
+
if (/^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(v))
|
|
834
|
+
return true; // ISO date/timestamp (2026-06-23)
|
|
835
|
+
if (/^[^@\s]+@[^@\s]+\.[a-z]{2,}$/i.test(v))
|
|
836
|
+
return true; // email address
|
|
837
|
+
if (v.endsWith(":"))
|
|
838
|
+
return true; // a UI label fragment ("Owner:")
|
|
839
|
+
if (/^v?\d+\.\d+\.\d+(?:[-+.][A-Za-z0-9.-]+)?$/.test(v))
|
|
840
|
+
return true;
|
|
841
|
+
if (/^https?:\/\//i.test(v))
|
|
842
|
+
return true;
|
|
843
|
+
if (/^trusty-squire-dogfood-\d{8}$/i.test(v))
|
|
844
|
+
return true;
|
|
845
|
+
if (/^[A-Z][A-Z0-9_]{2,}=?$/.test(v))
|
|
846
|
+
return true;
|
|
847
|
+
if (/^key_[A-Za-z0-9]{16,}$/i.test(v))
|
|
848
|
+
return true;
|
|
849
|
+
if (v.includes("…") || v.includes("..."))
|
|
850
|
+
return true;
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
// A credentials page that is actually a login wall / anti-bot interstitial has
|
|
854
|
+
// no key to give — every token on it (CSRF cookie, asset hash, guest id) is
|
|
855
|
+
// junk. Grok is the standing case: x.ai routes signup through X (Twitter) OAuth,
|
|
856
|
+
// and X serves headless Chromium its "JavaScript is not available" tombstone, so
|
|
857
|
+
// the extractor would otherwise scrape session tokens and hand one back as a
|
|
858
|
+
// false-green key. Detect that state and fail CLOSED — return no credential plus
|
|
859
|
+
// an explicit reason the host agent can act on (drive an interactive login),
|
|
860
|
+
// rather than surfacing a bogus value. The phrases below are the load-bearing
|
|
861
|
+
// markers of X's tombstone + the four anti-bot vendors waitForFormReady knows.
|
|
862
|
+
const LOGIN_WALL_MARKERS = [
|
|
863
|
+
/javascript is not available/i,
|
|
864
|
+
/enable javascript/i,
|
|
865
|
+
/verifying you are human/i,
|
|
866
|
+
/checking your browser/i,
|
|
867
|
+
/just a moment/i,
|
|
868
|
+
/review the security of your connection/i,
|
|
869
|
+
/unusual (traffic|activity) (from|on)/i,
|
|
870
|
+
];
|
|
871
|
+
export function detectExtractionBlock(pageText) {
|
|
872
|
+
// Require a SHORT page — a real keys page that merely mentions "enable
|
|
873
|
+
// JavaScript" in a footer is not a wall. A tombstone/interstitial is sparse.
|
|
874
|
+
if (pageText.trim().length > 600)
|
|
875
|
+
return null;
|
|
876
|
+
for (const re of LOGIN_WALL_MARKERS) {
|
|
877
|
+
if (re.test(pageText)) {
|
|
878
|
+
return "login_wall: the page is an anti-bot/login interstitial (no credential present) — drive an interactive login or hand back to the user";
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
// Collect every distinct credential-SHAPED token in a blob of page text:
|
|
884
|
+
// a short prefix + separator + a long body that carries at least one digit
|
|
885
|
+
// (vsk_sandbox_write_…, xai-…, sk-lw-…, re_…). Used to surface the SECOND key a
|
|
886
|
+
// multi-credential service shows (e.g. VouchFlow's sandbox read alongside write)
|
|
887
|
+
// that the single-key extraction.ts policy stops short of. The `[_-]` and
|
|
888
|
+
// has-digit requirements exclude the dotted-function-name false positive.
|
|
889
|
+
const CRED_TOKEN_RE = /\b[A-Za-z][A-Za-z0-9]{1,9}[_-][A-Za-z0-9][A-Za-z0-9_-]{12,}\b/g;
|
|
890
|
+
export function findCredentialTokens(text) {
|
|
891
|
+
const out = [];
|
|
892
|
+
const seen = new Set();
|
|
893
|
+
for (const m of text.matchAll(CRED_TOKEN_RE)) {
|
|
894
|
+
const t = m[0];
|
|
895
|
+
if (seen.has(t))
|
|
896
|
+
continue;
|
|
897
|
+
if (t.length < 16)
|
|
898
|
+
continue;
|
|
899
|
+
if (!/[0-9]/.test(t))
|
|
900
|
+
continue; // real keys carry digits; dictionary words don't
|
|
901
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(t))
|
|
902
|
+
continue; // env-var name
|
|
903
|
+
if (!looksLikeCredentialToken(t))
|
|
904
|
+
continue;
|
|
905
|
+
seen.add(t);
|
|
906
|
+
out.push(t);
|
|
907
|
+
}
|
|
908
|
+
return out;
|
|
909
|
+
}
|
|
910
|
+
function looksLikeCredentialToken(token) {
|
|
911
|
+
if (token.includes("_"))
|
|
912
|
+
return true;
|
|
913
|
+
if (/^(?:api|key|pk|re|rk|sk|xai|ghp|pat|vsk|tly)-/i.test(token))
|
|
914
|
+
return true;
|
|
915
|
+
// <short alpha vendor prefix>-<single long alphanumeric run>. We don't
|
|
916
|
+
// enumerate every vendor prefix; any "prefix-<random run>" is a key. The body
|
|
917
|
+
// must be ONE alphanumeric run (no further separators) so a word-word-word-date
|
|
918
|
+
// slug (trusty-squire-dogfood-20260625) is excluded — that has multiple hyphens.
|
|
919
|
+
return /^[A-Za-z][A-Za-z0-9]{0,7}-[A-Za-z0-9]{12,}$/.test(token);
|
|
920
|
+
}
|
|
921
|
+
function firstTokenMatching(haystack, re) {
|
|
922
|
+
const match = haystack.match(re);
|
|
923
|
+
return match?.[0] ?? null;
|
|
924
|
+
}
|
|
925
|
+
export function sanitizeExtractedCredentials(credentials, url, haystack = Object.values(credentials).join("\n")) {
|
|
926
|
+
const host = registrableHost(url) ?? "";
|
|
927
|
+
const normalized = {};
|
|
928
|
+
if (host === "cloud.langfuse.com") {
|
|
929
|
+
const secret = firstTokenMatching(haystack, /\bsk-lf-[0-9a-f-]{20,}\b/i);
|
|
930
|
+
const pub = firstTokenMatching(haystack, /\bpk-lf-[0-9a-f-]{20,}\b/i);
|
|
931
|
+
if (secret !== null) {
|
|
932
|
+
normalized.langfuse_secret_key = secret;
|
|
933
|
+
normalized.api_key = secret;
|
|
934
|
+
}
|
|
935
|
+
if (pub !== null)
|
|
936
|
+
normalized.langfuse_public_key = pub;
|
|
937
|
+
return normalized;
|
|
938
|
+
}
|
|
939
|
+
if (host.endsWith(".neon.tech")) {
|
|
940
|
+
const token = firstTokenMatching(haystack, /\bnapi_[A-Za-z0-9_-]{24,}\b/);
|
|
941
|
+
if (token !== null) {
|
|
942
|
+
normalized.api_token = token;
|
|
943
|
+
normalized.api_key = token;
|
|
944
|
+
}
|
|
945
|
+
return normalized;
|
|
946
|
+
}
|
|
947
|
+
for (const [key, value] of Object.entries(credentials)) {
|
|
948
|
+
const k = normLabelKey(key);
|
|
949
|
+
if (k === "refcode" || k === "referral_code")
|
|
950
|
+
continue;
|
|
951
|
+
if (isCredentialNoise(value))
|
|
952
|
+
continue;
|
|
953
|
+
if ((k === "key" || k === "api_key") && !looksLikeCredentialValue(value))
|
|
954
|
+
continue;
|
|
955
|
+
if (host === "api.together.ai" && /^key_[A-Za-z0-9]{16,}$/i.test(value.trim()))
|
|
956
|
+
continue;
|
|
957
|
+
normalized[key] = value;
|
|
958
|
+
}
|
|
959
|
+
return normalized;
|
|
960
|
+
}
|
|
961
|
+
export function classifyVouchflowCredentials(text) {
|
|
962
|
+
const out = {};
|
|
963
|
+
for (const tok of findCredentialTokens(text)) {
|
|
964
|
+
if (/^vsk_sandbox_read_/i.test(tok) && out.sandbox_read_key === undefined) {
|
|
965
|
+
out.sandbox_read_key = tok;
|
|
966
|
+
}
|
|
967
|
+
else if (/^vsk_sandbox_/i.test(tok) && out.sandbox_write_key === undefined) {
|
|
968
|
+
out.sandbox_write_key = tok;
|
|
969
|
+
}
|
|
970
|
+
else if (/^vsk_live_read_/i.test(tok) && out.live_read_key === undefined) {
|
|
971
|
+
out.live_read_key = tok;
|
|
972
|
+
}
|
|
973
|
+
else if (/^vsk_live_/i.test(tok) && out.live_write_key === undefined) {
|
|
974
|
+
out.live_write_key = tok;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return out;
|
|
978
|
+
}
|
|
217
979
|
// Reveal masked keys, then classify every on-page string source through the
|
|
218
980
|
// SAME exported regex policy the bot uses (extractApiKeyFromText +
|
|
219
981
|
// isTruncatedCapture + extraction.ts accumulation). Reuses the substrate —
|
|
@@ -229,6 +991,20 @@ export async function extractCredentials(sessionId) {
|
|
|
229
991
|
const inputs = await browser.extractAllInputValues();
|
|
230
992
|
const nearCopy = await browser.extractCredentialsNearCopyButtons();
|
|
231
993
|
const text = await browser.extractVisibleText();
|
|
994
|
+
// Fail CLOSED on a login wall / anti-bot interstitial: scraping it yields only
|
|
995
|
+
// session/CSRF/asset tokens, and handing one back is a false-green. Refuse,
|
|
996
|
+
// and tell the host why so it can drive an interactive login instead.
|
|
997
|
+
const blocked = detectExtractionBlock(text);
|
|
998
|
+
if (blocked !== null) {
|
|
999
|
+
audit(sessionId, "extract", { found: false, blocked_reason: blocked });
|
|
1000
|
+
return {
|
|
1001
|
+
session_id: sessionId,
|
|
1002
|
+
url: browser.currentUrl(),
|
|
1003
|
+
credentials: {},
|
|
1004
|
+
candidate_count: 0,
|
|
1005
|
+
blocked_reason: blocked,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
232
1008
|
// Copy-only key surfaces (e.g. LangWatch's /settings/api-keys) never render
|
|
233
1009
|
// the value into the DOM — it goes to the clipboard on a "Copy" click. Read
|
|
234
1010
|
// it (clipboard-read is granted at context creation).
|
|
@@ -236,6 +1012,7 @@ export async function extractCredentials(sessionId) {
|
|
|
236
1012
|
// Primary api_key: first FULL hit wins; a truncated/masked hit is the fallback.
|
|
237
1013
|
let state = initialExtractionState();
|
|
238
1014
|
const sources = [...labeled.map((c) => c.value), ...inputs, ...nearCopy, clip, text];
|
|
1015
|
+
const haystack = sources.join("\n");
|
|
239
1016
|
for (const src of sources) {
|
|
240
1017
|
if (hasFullHit(state))
|
|
241
1018
|
break;
|
|
@@ -248,10 +1025,15 @@ export async function extractCredentials(sessionId) {
|
|
|
248
1025
|
// reaches the actual secret further down the source list.
|
|
249
1026
|
if (/^[A-Z][A-Z0-9_]{2,}=?$/.test(key.trim()))
|
|
250
1027
|
continue;
|
|
1028
|
+
if (isCredentialNoise(key))
|
|
1029
|
+
continue;
|
|
251
1030
|
// Reject too-short non-secrets (UI noise like "Ctrl+K"). Real API keys are
|
|
252
1031
|
// long; a sub-12-char "key" is a false positive, never a credential.
|
|
253
1032
|
if (key.trim().length < 12)
|
|
254
1033
|
continue;
|
|
1034
|
+
// Reject a code identifier scraped off a page (the X-tombstone false-green).
|
|
1035
|
+
if (looksLikeCodeIdentifier(key))
|
|
1036
|
+
continue;
|
|
255
1037
|
const cls = isTruncatedCapture(src, key)
|
|
256
1038
|
? { kind: "truncated", value: key }
|
|
257
1039
|
: { kind: "full", value: key };
|
|
@@ -264,7 +1046,9 @@ export async function extractCredentials(sessionId) {
|
|
|
264
1046
|
for (const c of labeled) {
|
|
265
1047
|
if (c.label === null || c.isMasked)
|
|
266
1048
|
continue;
|
|
267
|
-
if (
|
|
1049
|
+
if (isCredentialNoise(c.value))
|
|
1050
|
+
continue;
|
|
1051
|
+
if (looksLikeCodeIdentifier(c.value))
|
|
268
1052
|
continue;
|
|
269
1053
|
const k = normLabelKey(c.label);
|
|
270
1054
|
if (k.length > 0 && !(k in named))
|
|
@@ -273,21 +1057,55 @@ export async function extractCredentials(sessionId) {
|
|
|
273
1057
|
// resolveExtraction (the regex-found primary key) wins over a same-named
|
|
274
1058
|
// labeled candidate, so a "API Key" label carrying the env-var snippet can
|
|
275
1059
|
// never clobber the real `api_key`.
|
|
276
|
-
const credentials = {
|
|
1060
|
+
const credentials = {
|
|
1061
|
+
...named,
|
|
1062
|
+
...classifyVouchflowCredentials(haystack),
|
|
1063
|
+
...resolveExtraction(state),
|
|
1064
|
+
};
|
|
1065
|
+
// Multi-credential: a service may present several keys of the same shape
|
|
1066
|
+
// (VouchFlow shows sandbox write AND read). The single-key extraction.ts
|
|
1067
|
+
// policy stops at the first; collect every distinct credential-shaped token
|
|
1068
|
+
// and surface the ones the primary missed as api_key_2, api_key_3, …
|
|
1069
|
+
const have = new Set(Object.values(credentials));
|
|
1070
|
+
let n = 1;
|
|
1071
|
+
for (const tok of findCredentialTokens(haystack)) {
|
|
1072
|
+
if (have.has(tok))
|
|
1073
|
+
continue;
|
|
1074
|
+
if (n >= 8)
|
|
1075
|
+
break; // cap extras so page noise can't flood the result
|
|
1076
|
+
have.add(tok);
|
|
1077
|
+
n += 1;
|
|
1078
|
+
credentials[`api_key_${n}`] = tok;
|
|
1079
|
+
}
|
|
1080
|
+
const sanitized = sanitizeExtractedCredentials(credentials, browser.currentUrl(), haystack);
|
|
1081
|
+
const found = Object.keys(sanitized).length > 0;
|
|
1082
|
+
// Report-back so the agent keeps going instead of treating an empty result as
|
|
1083
|
+
// done: if the page HAD labeled candidates but none survived as a real
|
|
1084
|
+
// credential, they were page noise (a date/email/greeting) or a still-masked
|
|
1085
|
+
// display — i.e. this isn't the keys page or the key needs revealing. Tell the
|
|
1086
|
+
// agent that so it navigates/reveals and extracts again, rather than storing junk.
|
|
1087
|
+
const notLegit = !found && labeled.length > 0
|
|
1088
|
+
? "no_legit_credential: the page had candidate values but none looked like a " +
|
|
1089
|
+
"real key (they were page text — a date/email/label — or a still-masked " +
|
|
1090
|
+
"display). You are likely NOT on the API-keys page, or the key is masked. " +
|
|
1091
|
+
"Navigate to the keys/settings page (or click reveal/show/copy), then extract again."
|
|
1092
|
+
: null;
|
|
277
1093
|
audit(sessionId, "extract", {
|
|
278
|
-
found
|
|
1094
|
+
found,
|
|
279
1095
|
candidate_count: labeled.length,
|
|
1096
|
+
not_legit: notLegit !== null,
|
|
280
1097
|
});
|
|
281
1098
|
return {
|
|
282
1099
|
session_id: sessionId,
|
|
283
1100
|
url: browser.currentUrl(),
|
|
284
|
-
credentials,
|
|
1101
|
+
credentials: sanitized,
|
|
285
1102
|
candidate_count: labeled.length,
|
|
1103
|
+
...(notLegit !== null ? { blocked_reason: notLegit } : {}),
|
|
286
1104
|
};
|
|
287
1105
|
}
|
|
288
1106
|
// Detect a captcha and wait for it to clear. Behavior-sim (humanized clicks +
|
|
289
1107
|
// typing) during the drive already scores invisible Turnstile/reCAPTCHA-v3; for
|
|
290
|
-
// a visible checkbox the host clicks it via
|
|
1108
|
+
// a visible checkbox the host clicks it via operate_act, then calls this to
|
|
291
1109
|
// wait for the token. Reuses the substrate's detector + settle poll.
|
|
292
1110
|
export async function captchaGate(sessionId) {
|
|
293
1111
|
const session = sessions.get(sessionId);
|
|
@@ -323,6 +1141,24 @@ export function parseVerification(text, links) {
|
|
|
323
1141
|
}
|
|
324
1142
|
return { code, link };
|
|
325
1143
|
}
|
|
1144
|
+
// Pure: assemble the verification result. When neither a code nor a link was
|
|
1145
|
+
// found, the thick session is still live, so this is a RESUMABLE hand-back
|
|
1146
|
+
// (Flow A) — the host asks the user for the code and types it — not a give-up.
|
|
1147
|
+
// Exported for unit tests.
|
|
1148
|
+
export function buildVerificationResult(sessionId, code, link) {
|
|
1149
|
+
const found = code !== null || link !== null;
|
|
1150
|
+
if (found)
|
|
1151
|
+
return { session_id: sessionId, found, code, link };
|
|
1152
|
+
const needs_user = {
|
|
1153
|
+
wall: "verification_code",
|
|
1154
|
+
message: "No verification code found in the inbox automatically. The service may " +
|
|
1155
|
+
"have sent it by SMS or an authenticator app, or it hasn't arrived yet. " +
|
|
1156
|
+
"Ask the user for the code, then type it into the verification field with " +
|
|
1157
|
+
"operate_act and continue — the session is still live.",
|
|
1158
|
+
resume: "code",
|
|
1159
|
+
};
|
|
1160
|
+
return { session_id: sessionId, found, code, link, needs_user };
|
|
1161
|
+
}
|
|
326
1162
|
export async function awaitVerification(sessionId, opts = {}) {
|
|
327
1163
|
const session = sessions.get(sessionId);
|
|
328
1164
|
if (session === undefined)
|
|
@@ -354,8 +1190,16 @@ export async function awaitVerification(sessionId, opts = {}) {
|
|
|
354
1190
|
sender: opts.sender ?? null,
|
|
355
1191
|
has_code: code !== null,
|
|
356
1192
|
has_link: link !== null,
|
|
1193
|
+
sealed: opts.intoSlot !== undefined && code !== null,
|
|
1194
|
+
needs_user: !found,
|
|
357
1195
|
});
|
|
358
|
-
|
|
1196
|
+
// Seal the OTP into a slot when asked: the host gets a masked handle, not the
|
|
1197
|
+
// code, and enters it with type_secret. The link (not secret) is still returned.
|
|
1198
|
+
if (opts.intoSlot !== undefined && code !== null) {
|
|
1199
|
+
const handle = stashSecretSlot(sessionId, opts.intoSlot, code);
|
|
1200
|
+
return { session_id: sessionId, found: true, code: null, link, sealed: true, slot: handle };
|
|
1201
|
+
}
|
|
1202
|
+
return buildVerificationResult(sessionId, code, link);
|
|
359
1203
|
}
|
|
360
1204
|
export async function finishProvisionSession(sessionId) {
|
|
361
1205
|
const session = sessions.get(sessionId);
|