@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.
Files changed (106) hide show
  1. package/README.md +15 -33
  2. package/dist/api-client.d.ts +6 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/bin.js +4 -10
  6. package/dist/bin.js.map +1 -1
  7. package/dist/bot/agent.d.ts +24 -2
  8. package/dist/bot/agent.d.ts.map +1 -1
  9. package/dist/bot/agent.js +634 -55
  10. package/dist/bot/agent.js.map +1 -1
  11. package/dist/bot/browser.d.ts +7 -0
  12. package/dist/bot/browser.d.ts.map +1 -1
  13. package/dist/bot/browser.js +182 -3
  14. package/dist/bot/browser.js.map +1 -1
  15. package/dist/bot/credential-extraction-flow.d.ts +2 -0
  16. package/dist/bot/credential-extraction-flow.d.ts.map +1 -1
  17. package/dist/bot/credential-extraction-flow.js +71 -1
  18. package/dist/bot/credential-extraction-flow.js.map +1 -1
  19. package/dist/bot/form-fill.d.ts.map +1 -1
  20. package/dist/bot/form-fill.js +11 -0
  21. package/dist/bot/form-fill.js.map +1 -1
  22. package/dist/bot/google-login.d.ts.map +1 -1
  23. package/dist/bot/google-login.js +37 -1
  24. package/dist/bot/google-login.js.map +1 -1
  25. package/dist/bot/index.d.ts +1 -0
  26. package/dist/bot/index.d.ts.map +1 -1
  27. package/dist/bot/index.js +1 -0
  28. package/dist/bot/index.js.map +1 -1
  29. package/dist/bot/login-state.d.ts +2 -1
  30. package/dist/bot/login-state.d.ts.map +1 -1
  31. package/dist/bot/login-state.js +22 -5
  32. package/dist/bot/login-state.js.map +1 -1
  33. package/dist/bot/nav-search.d.ts.map +1 -1
  34. package/dist/bot/nav-search.js +9 -0
  35. package/dist/bot/nav-search.js.map +1 -1
  36. package/dist/bot/post-signup-flow.d.ts.map +1 -1
  37. package/dist/bot/post-signup-flow.js +21 -0
  38. package/dist/bot/post-signup-flow.js.map +1 -1
  39. package/dist/bot/post-signup-recovery-state.d.ts +3 -0
  40. package/dist/bot/post-signup-recovery-state.d.ts.map +1 -1
  41. package/dist/bot/post-signup-recovery-state.js +3 -0
  42. package/dist/bot/post-signup-recovery-state.js.map +1 -1
  43. package/dist/bot/provision-session.d.ts +116 -1
  44. package/dist/bot/provision-session.d.ts.map +1 -1
  45. package/dist/bot/provision-session.js +885 -41
  46. package/dist/bot/provision-session.js.map +1 -1
  47. package/dist/bot/redact.d.ts.map +1 -1
  48. package/dist/bot/redact.js +25 -2
  49. package/dist/bot/redact.js.map +1 -1
  50. package/dist/bot/replay-skill.d.ts +6 -0
  51. package/dist/bot/replay-skill.d.ts.map +1 -1
  52. package/dist/bot/replay-skill.js +39 -5
  53. package/dist/bot/replay-skill.js.map +1 -1
  54. package/dist/bot/skill-hint.d.ts +7 -0
  55. package/dist/bot/skill-hint.d.ts.map +1 -0
  56. package/dist/bot/skill-hint.js +105 -0
  57. package/dist/bot/skill-hint.js.map +1 -0
  58. package/dist/bot/terminal-gate.d.ts +3 -1
  59. package/dist/bot/terminal-gate.d.ts.map +1 -1
  60. package/dist/bot/terminal-gate.js +19 -0
  61. package/dist/bot/terminal-gate.js.map +1 -1
  62. package/dist/install/agents.d.ts.map +1 -1
  63. package/dist/install/agents.js +12 -2
  64. package/dist/install/agents.js.map +1 -1
  65. package/dist/install/cli.d.ts +14 -2
  66. package/dist/install/cli.d.ts.map +1 -1
  67. package/dist/install/cli.js +346 -150
  68. package/dist/install/cli.js.map +1 -1
  69. package/dist/install/interactive.d.ts +9 -3
  70. package/dist/install/interactive.d.ts.map +1 -1
  71. package/dist/install/interactive.js +80 -140
  72. package/dist/install/interactive.js.map +1 -1
  73. package/dist/install/proxy-url.d.ts +2 -0
  74. package/dist/install/proxy-url.d.ts.map +1 -0
  75. package/dist/install/proxy-url.js +20 -0
  76. package/dist/install/proxy-url.js.map +1 -0
  77. package/dist/install/ui.js +1 -1
  78. package/dist/install/ui.js.map +1 -1
  79. package/dist/session.d.ts +3 -0
  80. package/dist/session.d.ts.map +1 -1
  81. package/dist/session.js.map +1 -1
  82. package/dist/skill-registry-client.d.ts +8 -0
  83. package/dist/skill-registry-client.d.ts.map +1 -1
  84. package/dist/skill-registry-client.js +70 -53
  85. package/dist/skill-registry-client.js.map +1 -1
  86. package/dist/tools/index.d.ts +1 -2
  87. package/dist/tools/index.d.ts.map +1 -1
  88. package/dist/tools/index.js +10 -19
  89. package/dist/tools/index.js.map +1 -1
  90. package/dist/tools/provision-any.d.ts +3 -0
  91. package/dist/tools/provision-any.d.ts.map +1 -1
  92. package/dist/tools/provision-any.js +162 -32
  93. package/dist/tools/provision-any.js.map +1 -1
  94. package/dist/tools/provision-drive.d.ts +121 -5
  95. package/dist/tools/provision-drive.d.ts.map +1 -1
  96. package/dist/tools/provision-drive.js +339 -48
  97. package/dist/tools/provision-drive.js.map +1 -1
  98. package/dist/tools/store-credential.d.ts +5 -0
  99. package/dist/tools/store-credential.d.ts.map +1 -1
  100. package/dist/tools/store-credential.js +5 -0
  101. package/dist/tools/store-credential.js.map +1 -1
  102. package/package.json +1 -3
  103. package/dist/bot/telegram-notify.d.ts +0 -8
  104. package/dist/bot/telegram-notify.d.ts.map +0 -1
  105. package/dist/bot/telegram-notify.js +0 -134
  106. 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
- // label text (+ role tiebreak), scored exact > startsWith > contains. Returns
57
- // null when nothing matches — the caller surfaces that rather than guessing.
58
- export function resolveTarget(elements, target) {
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 label = norm(elementRef(el));
65
- let score = 0;
66
- if (label === want)
67
- score = 100;
68
- else if (label.startsWith(want))
69
- score = 70;
70
- else if (label.includes(want))
71
- score = 50;
72
- else if (want.includes(label) && label.length >= 2)
73
- score = 30;
74
- if (score === 0)
75
- continue;
76
- // Prefer shorter labels at equal score (a more specific match).
77
- const adjusted = score - label.length * 0.01;
78
- if (best === null || adjusted > best.score)
79
- best = { el, score: adjusted };
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 allowedHosts = [
580
+ const seedHosts = [
120
581
  ...(targetHost !== null ? [targetHost] : []),
121
582
  ...(opts.extraAllowedHosts ?? []),
122
583
  ];
123
- const session = { id, browser, allowedHosts, lastElements: [] };
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", { service_url: opts.serviceUrl, allowed_hosts: allowedHosts });
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
- return await observeSession(session);
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: text.replace(/\s+/g, " ").trim().slice(0, 4000),
669
+ text: normalizedText,
670
+ ...(guidance !== undefined ? { guidance } : {}),
671
+ ...(screen !== undefined ? { screen } : {}),
672
+ ...(accessibility !== undefined ? { accessibility } : {}),
143
673
  elements: elements.map((el) => ({
144
- ref: elementRef(el),
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.allowedHosts)) {
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.allowedHosts.join(", ")}] + auth providers`);
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.map((e) => `"${elementRef(e)}"`).slice(0, 20).join(", "));
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 settle(450);
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 (/^[A-Z][A-Z0-9_]{2,}=?$/.test(c.value.trim()))
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 = { ...named, ...resolveExtraction(state) };
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: Object.keys(credentials).length > 0,
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 provision_act, then calls this to
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
- return { session_id: sessionId, found, code, link };
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);