@foldspace_npm/harness 0.1.34 → 0.1.36
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/CLAUDE.md +11 -1
- package/bin/observe.mjs +53 -12
- package/package.json +1 -1
- package/src/cdp-ownership.mjs +4 -0
- package/src/cli-registry.mjs +1 -0
- package/src/observe-core.mjs +22 -0
package/CLAUDE.md
CHANGED
|
@@ -319,6 +319,16 @@ and asking are not the only options — **observing is, and it comes first**:
|
|
|
319
319
|
not open a path that does something (`/logout`, `/…/delete`); and `read`
|
|
320
320
|
only goes to the app's own origin, a host configured for this project, or
|
|
321
321
|
an origin `screen` saw the page call.
|
|
322
|
+
|
|
323
|
+
**A button you need to look behind** (a report tile, a "View details"):
|
|
324
|
+
ask the human first, naming it - *"May I click 'Staff Overview'? It's a
|
|
325
|
+
button, so it could do something."* Only after their yes in this chat, run
|
|
326
|
+
`npx foldspace observe screen --click "Staff Overview" --human-said-yes`.
|
|
327
|
+
One yes covers that one button, once. Never pass `--human-said-yes`
|
|
328
|
+
without it. Even with a yes, observe refuses a label that reads like an
|
|
329
|
+
action (Export, Send, Delete, Run…), a form submit, a download and signing
|
|
330
|
+
out: hand those to the human. If the result has `buttonWarning`, the button
|
|
331
|
+
sent changes to the server: tell the human what, in one line, first.
|
|
322
332
|
3. **Ask the human to perform the workflow** — only for what you cannot reach:
|
|
323
333
|
an MFA step, a paywall, a screen that needs a record the account does not
|
|
324
334
|
have, or anything that would write.
|
|
@@ -327,7 +337,7 @@ Signing in is the human's step, because `inject` launches its own Chrome
|
|
|
327
337
|
profile (empty the first time; it keeps the sign-in after that).
|
|
328
338
|
Navigating is yours. **While you drive a signed-in account you are read-only:**
|
|
329
339
|
GET only, never submit a form, never click anything that creates, sends or
|
|
330
|
-
deletes.
|
|
340
|
+
deletes, and a plain button only after the human's yes (above).
|
|
331
341
|
|
|
332
342
|
Capture at least one real HTTP 200 for the data the action needs. If there is
|
|
333
343
|
none, pivot; do not create Foldspace resources. Before that address goes into
|
package/bin/observe.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* foldspace observe pages
|
|
8
8
|
* foldspace observe menu
|
|
9
|
-
* foldspace observe screen [--click "<label>"] [--goto /path | https://<one of this project's hosts>/path] [--match "deal stage"] [--wait 4000]
|
|
9
|
+
* foldspace observe screen [--click "<label>" [--human-said-yes]] [--goto /path | https://<one of this project's hosts>/path] [--match "deal stage"] [--wait 4000]
|
|
10
10
|
* foldspace observe read <path-or-url>
|
|
11
11
|
* foldspace observe auth [--wait 5000]
|
|
12
12
|
* foldspace observe styles
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
NAV_SCOPE_SELECTOR,
|
|
31
31
|
analyzeAuthHeaders,
|
|
32
32
|
buildAuthSummary,
|
|
33
|
+
buttonRefusal,
|
|
33
34
|
clickRefusal,
|
|
34
35
|
displayUrl,
|
|
35
36
|
isCandidateRequest,
|
|
@@ -57,7 +58,7 @@ import { resolveProjectDir } from "../src/upgrade.mjs";
|
|
|
57
58
|
const SUBCOMMANDS = {
|
|
58
59
|
pages: { values: [], flags: [] },
|
|
59
60
|
menu: { values: [], flags: [] },
|
|
60
|
-
screen: { values: ["click", "goto", "match", "wait"], flags: [] },
|
|
61
|
+
screen: { values: ["click", "goto", "match", "wait"], flags: ["human-said-yes"] },
|
|
61
62
|
read: { values: [], flags: [] },
|
|
62
63
|
auth: { values: ["wait"], flags: [] },
|
|
63
64
|
styles: { values: [], flags: ["write"] },
|
|
@@ -124,11 +125,16 @@ async function evaluateInFrames(page, expression, { stopWhen } = {}) {
|
|
|
124
125
|
return values;
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Page code shared by `menu` (click = null) and `screen --click`. With
|
|
130
|
+
* humanSaidYes, a plain button anywhere on the page may be clicked too, once
|
|
131
|
+
* buttonRefusal lets its label through.
|
|
132
|
+
*/
|
|
133
|
+
function navigationScript(clickLabel, humanSaidYes = false) {
|
|
129
134
|
return `(() => {
|
|
130
135
|
${pickByLabel}
|
|
131
136
|
${clickRefusal}
|
|
137
|
+
${buttonRefusal}
|
|
132
138
|
${VISIBLE_JS}
|
|
133
139
|
const labelOf = (el) =>
|
|
134
140
|
(el.innerText || el.textContent || "").replace(/\\s+/g, " ").trim() ||
|
|
@@ -181,17 +187,48 @@ function navigationScript(clickLabel) {
|
|
|
181
187
|
}
|
|
182
188
|
const want = ${JSON.stringify(clickLabel ?? null)};
|
|
183
189
|
if (want === null) return { items };
|
|
190
|
+
// A button the human said yes to: screened by buttonRefusal, then clicked.
|
|
191
|
+
const clickButton = (el, item) => {
|
|
192
|
+
const why = buttonRefusal(item);
|
|
193
|
+
if (why) return { clicked: false, refused: why };
|
|
194
|
+
el.click();
|
|
195
|
+
return { clicked: true, item, button: true };
|
|
196
|
+
};
|
|
184
197
|
const index = pickByLabel(items, want);
|
|
185
198
|
if (index < 0) {
|
|
186
199
|
const lower = want.toLowerCase();
|
|
187
|
-
const
|
|
188
|
-
.
|
|
200
|
+
const anywhere = [...document.querySelectorAll("a, button, [role=button], [role=menuitem], [role=tab], [role=link]")]
|
|
201
|
+
.filter((el) => visible(el) && !el.closest("form") && (labelOf(el) || "").length <= 60);
|
|
202
|
+
const elsewhere = anywhere.some((el) => (labelOf(el) || "").toLowerCase().includes(lower));
|
|
203
|
+
if (${JSON.stringify(Boolean(humanSaidYes))} && elsewhere) {
|
|
204
|
+
const found = anywhere.map((el) => ({ el, label: labelOf(el) }));
|
|
205
|
+
const at = pickByLabel(found, want);
|
|
206
|
+
if (at >= 0) {
|
|
207
|
+
const el = found[at].el;
|
|
208
|
+
const href = el.getAttribute("href");
|
|
209
|
+
if (el.tagName === "A" && href && href !== "#" && !/^javascript:/i.test(href)) {
|
|
210
|
+
return { clicked: false, refused: '"' + found[at].label + '" is a link outside the navigation: open it with --goto ' + new URL(href, location.href).pathname + "." };
|
|
211
|
+
}
|
|
212
|
+
return clickButton(el, {
|
|
213
|
+
label: found[at].label,
|
|
214
|
+
kind: kindOf(el),
|
|
215
|
+
path: null,
|
|
216
|
+
submitsForm: false,
|
|
217
|
+
download: el.hasAttribute("download"),
|
|
218
|
+
filePicker: el.matches("input[type=file], label[for]"),
|
|
219
|
+
expands: el.hasAttribute("aria-expanded") || el.hasAttribute("aria-haspopup"),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
189
223
|
return { clicked: false, outsideNavigation: elsewhere, available: items.map((item) => item.label) };
|
|
190
224
|
}
|
|
191
225
|
const refused = clickRefusal(items[index]);
|
|
192
|
-
if (refused)
|
|
193
|
-
|
|
194
|
-
|
|
226
|
+
if (!refused) {
|
|
227
|
+
elements[index].click();
|
|
228
|
+
return { clicked: true, item: items[index] };
|
|
229
|
+
}
|
|
230
|
+
if (!${JSON.stringify(Boolean(humanSaidYes))} || items[index].kind === "link" && items[index].path) return { clicked: false, refused };
|
|
231
|
+
return clickButton(elements[index], items[index]);
|
|
195
232
|
})()`;
|
|
196
233
|
}
|
|
197
234
|
|
|
@@ -388,7 +425,7 @@ async function runScreen(page, flags) {
|
|
|
388
425
|
if (flags.click !== undefined) await network.settle(Math.min(waitMs, 3000));
|
|
389
426
|
}
|
|
390
427
|
if (flags.click !== undefined) {
|
|
391
|
-
const results = await evaluateInFrames(page, navigationScript(String(flags.click)), {
|
|
428
|
+
const results = await evaluateInFrames(page, navigationScript(String(flags.click), Boolean(flags["human-said-yes"])), {
|
|
392
429
|
stopWhen: (value) => value?.clicked === true,
|
|
393
430
|
});
|
|
394
431
|
const hit = results.find((value) => value?.clicked);
|
|
@@ -398,7 +435,7 @@ async function runScreen(page, flags) {
|
|
|
398
435
|
const error = refused
|
|
399
436
|
? refused.refused
|
|
400
437
|
: outside
|
|
401
|
-
? `"${flags.click}" is on the page but not in the app's navigation.
|
|
438
|
+
? `"${flags.click}" is on the page but not in the app's navigation. Ask the human in chat whether you may click it, naming it; only after their yes, run this again with --human-said-yes.`
|
|
402
439
|
: `No navigation item is labelled "${flags.click}".`;
|
|
403
440
|
throw new ObserveExit(
|
|
404
441
|
{
|
|
@@ -409,7 +446,7 @@ async function runScreen(page, flags) {
|
|
|
409
446
|
1,
|
|
410
447
|
);
|
|
411
448
|
}
|
|
412
|
-
reached = { how: "click", ...publicItem(hit.item) };
|
|
449
|
+
reached = { how: "click", ...publicItem(hit.item), ...(hit.button ? { button: true } : {}) };
|
|
413
450
|
}
|
|
414
451
|
if (!reached) {
|
|
415
452
|
// Nothing to reach: reload so this screen's own requests are seen.
|
|
@@ -449,6 +486,7 @@ async function runScreen(page, flags) {
|
|
|
449
486
|
writes.set(`${entry.method} ${entry.path}`, entry);
|
|
450
487
|
}
|
|
451
488
|
const ranked = rankCandidates(candidates, words, 8);
|
|
489
|
+
const buttonWrote = reached.button && writes.size > 0;
|
|
452
490
|
return {
|
|
453
491
|
ok: true,
|
|
454
492
|
reached,
|
|
@@ -457,6 +495,9 @@ async function runScreen(page, flags) {
|
|
|
457
495
|
requestsSeen: all.filter(isApiCall).length,
|
|
458
496
|
candidates: ranked,
|
|
459
497
|
writesSeen: [...writes.values()].slice(0, 20),
|
|
498
|
+
...(buttonWrote
|
|
499
|
+
? { buttonWarning: `The button "${reached.label}" sent ${writes.size} change(s) to the server (writesSeen). Tell the human what it sent, in one line, before anything else.` }
|
|
500
|
+
: {}),
|
|
460
501
|
...customerSubdomainFields(url, all.filter(isApiCall).map((request) => request.url)),
|
|
461
502
|
...(ranked.length
|
|
462
503
|
? {}
|
package/package.json
CHANGED
package/src/cdp-ownership.mjs
CHANGED
|
@@ -162,8 +162,12 @@ async function openCdpSocket(webSocketDebuggerUrl) {
|
|
|
162
162
|
export async function inspectCdpUserDataDir(webSocketDebuggerUrl) {
|
|
163
163
|
const session = await openCdpSocket(webSocketDebuggerUrl);
|
|
164
164
|
try {
|
|
165
|
+
// background: true - a tab opened in front raises Chrome over whatever
|
|
166
|
+
// app the human is using, on every harness command (measured 2026-10-27:
|
|
167
|
+
// 3 of 3 in front, 0 in the background).
|
|
165
168
|
const { targetId } = await session.call("Target.createTarget", {
|
|
166
169
|
url: "chrome://version/",
|
|
170
|
+
background: true,
|
|
167
171
|
});
|
|
168
172
|
const attached = await session.call("Target.attachToTarget", {
|
|
169
173
|
targetId,
|
package/src/cli-registry.mjs
CHANGED
|
@@ -371,6 +371,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
371
371
|
],
|
|
372
372
|
options: [
|
|
373
373
|
value("--click", "label", "screen: reach the screen by a navigation label, as a person would"),
|
|
374
|
+
flag("--human-said-yes", "screen --click: the human said yes in chat to clicking this one plain button; a label that reads like an action is still refused"),
|
|
374
375
|
value("--goto", "path", "screen: a path on this host, or a full https address on a host this project names"),
|
|
375
376
|
value("--match", "words", "screen: rank the requests seen by these words"),
|
|
376
377
|
value("--wait", "ms", "screen/auth: how long to keep listening"),
|
package/src/observe-core.mjs
CHANGED
|
@@ -233,6 +233,28 @@ export function clickRefusal(item) {
|
|
|
233
233
|
return `"${label}" is a ${item?.kind || "button"} with no link behind it, and a button can do anything. observe clicks links, tabs and menu openers only - find the screen's path with \`menu\` and use --goto.`;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* For a plain button the human has said yes to in chat (`screen --click
|
|
238
|
+
* "<label>" --human-said-yes`): why observe still will not click it, or null
|
|
239
|
+
* when it may. The yes covers looking, never acting, so a label that reads like
|
|
240
|
+
* an action, a form submit, a download, a file picker and signing out stay
|
|
241
|
+
* refused. Self-contained (injected into the page).
|
|
242
|
+
*/
|
|
243
|
+
export function buttonRefusal(item) {
|
|
244
|
+
const label = String(item?.label || "");
|
|
245
|
+
const text = label.replace(/^[^\p{L}\p{N}]+/u, "");
|
|
246
|
+
if (/\b(log|sign)[\s-]?(out|off)\b/i.test(text)) return `"${label}" ends the session; observe never clicks it.`;
|
|
247
|
+
if (item?.submitsForm) return `"${label}" submits a form; observe never clicks it, even with a yes.`;
|
|
248
|
+
if (item?.download) return `"${label}" downloads a file; observe never clicks it, even with a yes.`;
|
|
249
|
+
if (item?.filePicker) return `"${label}" opens a file picker; observe never clicks it, even with a yes.`;
|
|
250
|
+
const acts =
|
|
251
|
+
/\b(delete|remove|destroy|erase|purge|empty|clear|reset|revoke|regenerate|rotate|deactivate|disable|enable|activate|suspend|terminate|cancel|confirm|approve|reject|decline|accept|submit|send|resend|email|share|save|create|add(?![- ]?ons)|new|invite|pay|buy|purchase|charge|refund|upgrade|downgrade|subscribe|unsubscribe|publish|unpublish|archive|restore|import|export|download|upload|print|run|execute|start|stop|pause|resume|merge|transfer|assign|mark|bulk|sync|connect|disconnect|install|uninstall|generate|update|edit|apply|book|schedule|place|checkout|sign|join|leave|follow|unfollow|like|vote)\b/i;
|
|
252
|
+
const hit = text.match(acts);
|
|
253
|
+
return hit
|
|
254
|
+
? `"${label}" reads like an action ("${hit[1]}"), and a yes covers looking, never acting. Hand this step to the human.`
|
|
255
|
+
: null;
|
|
256
|
+
}
|
|
257
|
+
|
|
236
258
|
// ------------------------------------------------------------ JSON shapes
|
|
237
259
|
|
|
238
260
|
/**
|