@addai/node 0.29.1 → 0.30.1
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 +96 -96
- package/assets/vault-extension/background.js +45 -45
- package/assets/vault-extension/content.js +62 -62
- package/assets/vault-extension/manifest.json +39 -39
- package/dist/autostart-linux.js +20 -20
- package/dist/autostart-mac.js +30 -30
- package/dist/autostart-win.js +50 -50
- package/dist/cli.js +19 -19
- package/dist/desktop/install-engine.js +12 -12
- package/dist/desktop/learn/browser.d.ts +22 -0
- package/dist/desktop/learn/browser.js +55 -8
- package/dist/desktop/learn/injected.mjs +173 -173
- package/dist/desktop/learn/recorder.mjs +151 -151
- package/dist/desktop/manager.d.ts +15 -2
- package/dist/desktop/manager.js +38 -6
- package/dist/desktop/provider.js +9 -0
- package/dist/desktop/relay-client.js +29 -5
- package/dist/desktop/spec.d.ts +23 -0
- package/dist/desktop/spec.js +24 -1
- package/dist/desktop/vault-seed.mjs +112 -112
- package/package.json +62 -62
- package/scripts/copy-assets.js +18 -18
- package/scripts/fix-pty-helper.js +28 -28
- package/scripts/precompact-capture.js +292 -292
- package/scripts/probe-tui.mjs +122 -122
- package/scripts/smoke-test.sh +74 -74
|
@@ -4,6 +4,28 @@ export declare const DEBUG_PORT = 9222;
|
|
|
4
4
|
* it would point the wrapper at itself and exec forever. */
|
|
5
5
|
export declare const REAL_BROWSERS: string[];
|
|
6
6
|
export declare const WRAPPER_PATH = "/usr/local/bin/browser";
|
|
7
|
+
/**
|
|
8
|
+
* A line of the wrapper distinctive enough to recognise it by.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ This exists because the wrapper used to destroy the browser it wraps.
|
|
11
|
+
*
|
|
12
|
+
* In the image WRAPPER_PATH is a SYMLINK — `ln -sf /usr/bin/chromium
|
|
13
|
+
* /usr/local/bin/browser`. `cp` follows a symlink destination and writes
|
|
14
|
+
* THROUGH it, so `cp wrapper /usr/local/bin/browser` overwrote /usr/bin/
|
|
15
|
+
* chromium — the real browser — with a script whose last line is
|
|
16
|
+
*
|
|
17
|
+
* exec /usr/bin/chromium --remote-debugging-port=9222 ... "$@"
|
|
18
|
+
*
|
|
19
|
+
* which is now itself. Each hop re-appended the flags and passed the lot on
|
|
20
|
+
* as "$@", so a single launch exec'd itself until the argument list blew up.
|
|
21
|
+
* From the outside: the browser simply never opened, and the flags appeared
|
|
22
|
+
* hundreds of times over in ps.
|
|
23
|
+
*
|
|
24
|
+
* The write is fixed below. This marker is the second line of defence: a
|
|
25
|
+
* candidate that is really this wrapper is never treated as a real browser,
|
|
26
|
+
* so the loop cannot be rebuilt even if something else recreates the shape.
|
|
27
|
+
*/
|
|
28
|
+
export declare const WRAPPER_MARKER = "Written by @addai/node";
|
|
7
29
|
/** The in-container script that signs the browser in. Beside the extension,
|
|
8
30
|
* under the same bind mount, so it arrives by the same route. */
|
|
9
31
|
export declare const SEED_SCRIPT = "/conf/vault/vault-seed.mjs";
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.SEED_SCRIPT = exports.WRAPPER_PATH = exports.REAL_BROWSERS = exports.DEBUG_PORT = void 0;
|
|
36
|
+
exports.SEED_SCRIPT = exports.WRAPPER_MARKER = exports.WRAPPER_PATH = exports.REAL_BROWSERS = exports.DEBUG_PORT = void 0;
|
|
37
37
|
exports.wrapperScript = wrapperScript;
|
|
38
38
|
exports.wrapperIsCurrent = wrapperIsCurrent;
|
|
39
39
|
exports.ensureBrowserWrapper = ensureBrowserWrapper;
|
|
@@ -65,6 +65,28 @@ exports.REAL_BROWSERS = [
|
|
|
65
65
|
'/usr/bin/chromium-browser',
|
|
66
66
|
];
|
|
67
67
|
exports.WRAPPER_PATH = '/usr/local/bin/browser';
|
|
68
|
+
/**
|
|
69
|
+
* A line of the wrapper distinctive enough to recognise it by.
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ This exists because the wrapper used to destroy the browser it wraps.
|
|
72
|
+
*
|
|
73
|
+
* In the image WRAPPER_PATH is a SYMLINK — `ln -sf /usr/bin/chromium
|
|
74
|
+
* /usr/local/bin/browser`. `cp` follows a symlink destination and writes
|
|
75
|
+
* THROUGH it, so `cp wrapper /usr/local/bin/browser` overwrote /usr/bin/
|
|
76
|
+
* chromium — the real browser — with a script whose last line is
|
|
77
|
+
*
|
|
78
|
+
* exec /usr/bin/chromium --remote-debugging-port=9222 ... "$@"
|
|
79
|
+
*
|
|
80
|
+
* which is now itself. Each hop re-appended the flags and passed the lot on
|
|
81
|
+
* as "$@", so a single launch exec'd itself until the argument list blew up.
|
|
82
|
+
* From the outside: the browser simply never opened, and the flags appeared
|
|
83
|
+
* hundreds of times over in ps.
|
|
84
|
+
*
|
|
85
|
+
* The write is fixed below. This marker is the second line of defence: a
|
|
86
|
+
* candidate that is really this wrapper is never treated as a real browser,
|
|
87
|
+
* so the loop cannot be rebuilt even if something else recreates the shape.
|
|
88
|
+
*/
|
|
89
|
+
exports.WRAPPER_MARKER = 'Written by @addai/node';
|
|
68
90
|
/** The in-container script that signs the browser in. Beside the extension,
|
|
69
91
|
* under the same bind mount, so it arrives by the same route. */
|
|
70
92
|
exports.SEED_SCRIPT = '/conf/vault/vault-seed.mjs';
|
|
@@ -92,10 +114,10 @@ function wrapperScript(realBrowser, extensionDir) {
|
|
|
92
114
|
const seed = extensionDir
|
|
93
115
|
? `[ -f ${exports.SEED_SCRIPT} ] && (node ${exports.SEED_SCRIPT} >/dev/null 2>&1 &)\n`
|
|
94
116
|
: '';
|
|
95
|
-
return `#!/bin/sh
|
|
96
|
-
# Written by @addai/node so +Ai Learn can watch this browser${extensionDir ? ' and the +Ai Vault can fill logins in it' : ''}.
|
|
97
|
-
# Removing it costs you the page detail in a lesson${extensionDir ? ' and vault autofill' : ''} and nothing else.
|
|
98
|
-
${seed}exec ${realBrowser} --remote-debugging-port=${exports.DEBUG_PORT} --remote-debugging-address=127.0.0.1${ext} "$@"
|
|
117
|
+
return `#!/bin/sh
|
|
118
|
+
# Written by @addai/node so +Ai Learn can watch this browser${extensionDir ? ' and the +Ai Vault can fill logins in it' : ''}.
|
|
119
|
+
# Removing it costs you the page detail in a lesson${extensionDir ? ' and vault autofill' : ''} and nothing else.
|
|
120
|
+
${seed}exec ${realBrowser} --remote-debugging-port=${exports.DEBUG_PORT} --remote-debugging-address=127.0.0.1${ext} "$@"
|
|
99
121
|
`;
|
|
100
122
|
}
|
|
101
123
|
/** Is what is on disk already what we would write? Exact match, so a change to
|
|
@@ -112,10 +134,28 @@ function wrapperIsCurrent(content, realBrowser, extensionDir) {
|
|
|
112
134
|
* cheap execs and no change.
|
|
113
135
|
*/
|
|
114
136
|
async function ensureBrowserWrapper(run, confDir, extensionDir) {
|
|
137
|
+
// Each candidate is classified, not just found: one that is really this
|
|
138
|
+
// wrapper (see WRAPPER_MARKER) is a browser an older build overwrote, and
|
|
139
|
+
// pointing a new wrapper at it would rebuild the exec loop.
|
|
115
140
|
const found = await run(['sh', '-c',
|
|
116
|
-
exports.REAL_BROWSERS.
|
|
117
|
-
|
|
141
|
+
`for b in ${exports.REAL_BROWSERS.join(' ')}; do [ -x "$b" ] || continue; ` +
|
|
142
|
+
`if head -c 512 "$b" 2>/dev/null | grep -qF '${exports.WRAPPER_MARKER}'; ` +
|
|
143
|
+
`then echo "wrapped $b"; else echo "real $b"; fi; done`]);
|
|
144
|
+
const lines = found.out.trim().split('\n').map(l => l.trim()).filter(Boolean);
|
|
145
|
+
const real = lines.find(l => l.startsWith('real '))?.slice(5);
|
|
118
146
|
if (!real) {
|
|
147
|
+
const wrapped = lines.find(l => l.startsWith('wrapped '))?.slice(8);
|
|
148
|
+
if (wrapped) {
|
|
149
|
+
// Deliberately not repaired from here. Putting the browser back means
|
|
150
|
+
// reinstalling its package — a network install, on every start, on a
|
|
151
|
+
// machine that is somebody's desktop — and this path is meant to be two
|
|
152
|
+
// cheap execs. Rebuilding the desktop restores it from the image.
|
|
153
|
+
return {
|
|
154
|
+
changed: false, browser: null,
|
|
155
|
+
note: `${wrapped} was overwritten by an older version of this wrapper `
|
|
156
|
+
+ 'and no longer starts a browser. Rebuild this desktop to restore it.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
119
159
|
return { changed: false, browser: null, note: 'this desktop has no browser installed' };
|
|
120
160
|
}
|
|
121
161
|
const current = await run(['sh', '-c', `cat ${exports.WRAPPER_PATH} 2>/dev/null || true`]);
|
|
@@ -127,8 +167,15 @@ async function ensureBrowserWrapper(run, confDir, extensionDir) {
|
|
|
127
167
|
// sudo, not `docker exec -u root`: the image gives the entity passwordless
|
|
128
168
|
// sudo inside its own container, and going through the same exec path as
|
|
129
169
|
// everything else keeps one way of talking to a desktop rather than two.
|
|
170
|
+
// ⚠️ rm FIRST, and it is the whole fix. In the image WRAPPER_PATH is a
|
|
171
|
+
// symlink to the real browser, and `cp` follows a symlink destination —
|
|
172
|
+
// writing through it, over the browser, rather than replacing the link.
|
|
173
|
+
// Unlinking first makes the wrapper a file of its own and leaves whatever
|
|
174
|
+
// it pointed at alone. (`cp --remove-destination` does the same in one
|
|
175
|
+
// call; this shape says why out loud and needs no GNU-only flag.)
|
|
130
176
|
const put = await run(['sh', '-c',
|
|
131
|
-
`sudo
|
|
177
|
+
`sudo rm -f ${exports.WRAPPER_PATH} && sudo cp /conf/learn/browser ${exports.WRAPPER_PATH} `
|
|
178
|
+
+ `&& sudo chmod +x ${exports.WRAPPER_PATH}`]);
|
|
132
179
|
if (!put.ok) {
|
|
133
180
|
return { changed: false, browser: real, note: `could not install the browser wrapper: ${put.out.trim().slice(0, 200)}` };
|
|
134
181
|
}
|
|
@@ -1,173 +1,173 @@
|
|
|
1
|
-
// The recorder that runs INSIDE every page of a desktop's browser.
|
|
2
|
-
//
|
|
3
|
-
// Injected through CDP (Page.addScriptToEvaluateOnNewDocument), so its source
|
|
4
|
-
// is stringified and shipped as text. That has one hard consequence: nothing
|
|
5
|
-
// here may reference module scope. The helpers are therefore passed into
|
|
6
|
-
// pageRecorder as arguments and stitched together by injectedSource() below,
|
|
7
|
-
// which keeps the functions the tests exercise and the functions the page runs
|
|
8
|
-
// literally the same code.
|
|
9
|
-
//
|
|
10
|
-
// The rule this file exists to enforce: describe the CONTROL, never its VALUE.
|
|
11
|
-
// The viewer already records what was typed, and that half gets redacted
|
|
12
|
-
// server-side against the secret windows this file reports. If a value ever
|
|
13
|
-
// leaked out of here it would bypass that entirely — it would be in the "safe"
|
|
14
|
-
// track, the one stored raw.
|
|
15
|
-
|
|
16
|
-
/** A control, as a person would point at it. No values, ever. */
|
|
17
|
-
export function describeTarget(el) {
|
|
18
|
-
if (!el || !el.tagName) return { tag: 'unknown' };
|
|
19
|
-
const attr = (n) => {
|
|
20
|
-
try { return el.getAttribute ? el.getAttribute(n) : null; } catch { return null; }
|
|
21
|
-
};
|
|
22
|
-
const clean = (s) => {
|
|
23
|
-
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
24
|
-
return t ? t.slice(0, 60) : null;
|
|
25
|
-
};
|
|
26
|
-
let labelText = null;
|
|
27
|
-
try {
|
|
28
|
-
if (el.labels && el.labels.length) labelText = clean(el.labels[0].innerText);
|
|
29
|
-
} catch { labelText = null; }
|
|
30
|
-
|
|
31
|
-
let css = null;
|
|
32
|
-
try {
|
|
33
|
-
const bits = [];
|
|
34
|
-
let node = el;
|
|
35
|
-
for (let depth = 0; node && node.tagName && depth < 4; depth++) {
|
|
36
|
-
let bit = node.tagName.toLowerCase();
|
|
37
|
-
if (node.id) { bits.unshift(bit + '#' + node.id); break; }
|
|
38
|
-
const cls = String(node.className || '').split(/\s+/).filter(Boolean).slice(0, 2);
|
|
39
|
-
if (cls.length) bit += '.' + cls.join('.');
|
|
40
|
-
bits.unshift(bit);
|
|
41
|
-
node = node.parentElement;
|
|
42
|
-
}
|
|
43
|
-
css = bits.join('>') || null;
|
|
44
|
-
} catch { css = null; }
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
tag: el.tagName.toLowerCase(),
|
|
48
|
-
role: attr('role') || null,
|
|
49
|
-
// innerText, not value: a button says what it does, a field does not.
|
|
50
|
-
text: el.tagName.toLowerCase() === 'input' ? null : clean(el.innerText),
|
|
51
|
-
label: labelText || clean(attr('aria-label')),
|
|
52
|
-
name: clean(el.name) || clean(attr('name')),
|
|
53
|
-
id: clean(el.id) || null,
|
|
54
|
-
placeholder: clean(el.placeholder) || clean(attr('placeholder')),
|
|
55
|
-
css,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Is focus in something we must go blind for?
|
|
61
|
-
*
|
|
62
|
-
* Deliberately wider than input[type=password]: plenty of one-time-code and
|
|
63
|
-
* "access code" fields are plain text inputs, and a lesson that teaches a
|
|
64
|
-
* login will walk straight through one. Word-bounded so that "shipping" is not
|
|
65
|
-
* a PIN and "cardigan" is not a card number.
|
|
66
|
-
*/
|
|
67
|
-
export function isSecretField(el) {
|
|
68
|
-
if (!el || !el.tagName) return false;
|
|
69
|
-
const attr = (n) => {
|
|
70
|
-
try { return el.getAttribute ? el.getAttribute(n) : null; } catch { return null; }
|
|
71
|
-
};
|
|
72
|
-
const type = String(el.type || attr('type') || '').toLowerCase();
|
|
73
|
-
if (type === 'password') return true;
|
|
74
|
-
|
|
75
|
-
const auto = String(attr('autocomplete') || '').toLowerCase();
|
|
76
|
-
if (auto.includes('password') || auto.includes('one-time-code') || auto.includes('cc-number')) {
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
let labelText = '';
|
|
81
|
-
try {
|
|
82
|
-
if (el.labels && el.labels.length) labelText = String(el.labels[0].innerText || '');
|
|
83
|
-
} catch { labelText = ''; }
|
|
84
|
-
|
|
85
|
-
const hay = [el.name, attr('name'), el.id, el.placeholder, attr('placeholder'),
|
|
86
|
-
attr('aria-label'), labelText]
|
|
87
|
-
.map((v) => String(v == null ? '' : v))
|
|
88
|
-
.join(' ')
|
|
89
|
-
.toLowerCase()
|
|
90
|
-
.replace(/[^a-z0-9]+/g, ' ');
|
|
91
|
-
|
|
92
|
-
const SECRET = new RegExp('(^| )(password|passwd|pass|passcode|secret|token|otp|' +
|
|
93
|
-
'cvv|cvc|pin|card number|cardnumber|security code|access code|auth code)( |$)');
|
|
94
|
-
return SECRET.test(' ' + hay + ' ');
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Watch one document. Returns nothing; reports through window.__addaiLearn,
|
|
99
|
-
* the CDP binding the recorder installs.
|
|
100
|
-
*
|
|
101
|
-
* Capture phase throughout, so a page that stops propagation on its own
|
|
102
|
-
* handlers (most single-page apps do) cannot make a lesson go blank.
|
|
103
|
-
*/
|
|
104
|
-
export function pageRecorder(describe, isSecret) {
|
|
105
|
-
if (window.__addaiLearnInstalled) return;
|
|
106
|
-
window.__addaiLearnInstalled = true;
|
|
107
|
-
|
|
108
|
-
const send = (ev) => {
|
|
109
|
-
try {
|
|
110
|
-
if (typeof window.__addaiLearn === 'function') window.__addaiLearn(JSON.stringify(ev));
|
|
111
|
-
} catch { /* a lesson must never break the page it is watching */ }
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
// Secret tracking has TWO independent triggers on purpose.
|
|
115
|
-
//
|
|
116
|
-
// focusin is the obvious one and it is not reliable: a browser window that
|
|
117
|
-
// does not itself have focus changes activeElement without firing any focus
|
|
118
|
-
// event at all (headless Chrome does exactly this, and so does a desktop
|
|
119
|
-
// whose window manager has put another window on top). Relying on it alone
|
|
120
|
-
// gives a silent failure whose consequence is a password in the timeline.
|
|
121
|
-
//
|
|
122
|
-
// So `input` opens a window too. Whatever else is true, a keystroke reaching
|
|
123
|
-
// a secret field is proof we should already be blind.
|
|
124
|
-
let secretOpen = false;
|
|
125
|
-
const openSecret = () => {
|
|
126
|
-
if (!secretOpen) { secretOpen = true; send({ kind: 'secret_focus' }); }
|
|
127
|
-
};
|
|
128
|
-
const closeSecret = () => {
|
|
129
|
-
if (secretOpen) { secretOpen = false; send({ kind: 'secret_blur' }); }
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const editable = (t) => {
|
|
133
|
-
const tag = t && t.tagName ? t.tagName.toLowerCase() : '';
|
|
134
|
-
return tag === 'input' || tag === 'textarea' || tag === 'select' ||
|
|
135
|
-
!!(t && t.isContentEditable);
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
document.addEventListener('click', (e) => {
|
|
139
|
-
send({ kind: 'page_click', target: describe(e.target) });
|
|
140
|
-
// Deliberately does NOT close a secret window. Pressing "show password" or
|
|
141
|
-
// "Sign in" mid-entry is a click on something harmless, and closing there
|
|
142
|
-
// would leave a hole between it and the next keystroke — one that a
|
|
143
|
-
// coalesced `type` event could land in unredacted. Over-inclusive is the
|
|
144
|
-
// right direction for redaction; a window closes when focus really moves.
|
|
145
|
-
}, true);
|
|
146
|
-
|
|
147
|
-
const arrivedAt = (t) => {
|
|
148
|
-
if (isSecret(t)) { openSecret(); return; }
|
|
149
|
-
if (editable(t)) { closeSecret(); send({ kind: 'page_focus', target: describe(t) }); }
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
document.addEventListener('focusin', (e) => arrivedAt(e.target), true);
|
|
153
|
-
// The backstop. Never carries what was typed — only that typing happened.
|
|
154
|
-
document.addEventListener('input', (e) => arrivedAt(e.target), true);
|
|
155
|
-
|
|
156
|
-
// A page can be torn down mid-password. Closing the window on unload means
|
|
157
|
-
// an unclosed one really does mean "we never saw it end".
|
|
158
|
-
window.addEventListener('pagehide', closeSecret, true);
|
|
159
|
-
|
|
160
|
-
document.addEventListener('submit', (e) => {
|
|
161
|
-
send({ kind: 'submit', target: describe(e.target) });
|
|
162
|
-
}, true);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** The exact text handed to Page.addScriptToEvaluateOnNewDocument. */
|
|
166
|
-
export function injectedSource() {
|
|
167
|
-
return '(function(){\n' +
|
|
168
|
-
'var describeTarget = ' + describeTarget.toString() + ';\n' +
|
|
169
|
-
'var isSecretField = ' + isSecretField.toString() + ';\n' +
|
|
170
|
-
'var pageRecorder = ' + pageRecorder.toString() + ';\n' +
|
|
171
|
-
'try { pageRecorder(describeTarget, isSecretField); } catch (e) {}\n' +
|
|
172
|
-
'})();';
|
|
173
|
-
}
|
|
1
|
+
// The recorder that runs INSIDE every page of a desktop's browser.
|
|
2
|
+
//
|
|
3
|
+
// Injected through CDP (Page.addScriptToEvaluateOnNewDocument), so its source
|
|
4
|
+
// is stringified and shipped as text. That has one hard consequence: nothing
|
|
5
|
+
// here may reference module scope. The helpers are therefore passed into
|
|
6
|
+
// pageRecorder as arguments and stitched together by injectedSource() below,
|
|
7
|
+
// which keeps the functions the tests exercise and the functions the page runs
|
|
8
|
+
// literally the same code.
|
|
9
|
+
//
|
|
10
|
+
// The rule this file exists to enforce: describe the CONTROL, never its VALUE.
|
|
11
|
+
// The viewer already records what was typed, and that half gets redacted
|
|
12
|
+
// server-side against the secret windows this file reports. If a value ever
|
|
13
|
+
// leaked out of here it would bypass that entirely — it would be in the "safe"
|
|
14
|
+
// track, the one stored raw.
|
|
15
|
+
|
|
16
|
+
/** A control, as a person would point at it. No values, ever. */
|
|
17
|
+
export function describeTarget(el) {
|
|
18
|
+
if (!el || !el.tagName) return { tag: 'unknown' };
|
|
19
|
+
const attr = (n) => {
|
|
20
|
+
try { return el.getAttribute ? el.getAttribute(n) : null; } catch { return null; }
|
|
21
|
+
};
|
|
22
|
+
const clean = (s) => {
|
|
23
|
+
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
24
|
+
return t ? t.slice(0, 60) : null;
|
|
25
|
+
};
|
|
26
|
+
let labelText = null;
|
|
27
|
+
try {
|
|
28
|
+
if (el.labels && el.labels.length) labelText = clean(el.labels[0].innerText);
|
|
29
|
+
} catch { labelText = null; }
|
|
30
|
+
|
|
31
|
+
let css = null;
|
|
32
|
+
try {
|
|
33
|
+
const bits = [];
|
|
34
|
+
let node = el;
|
|
35
|
+
for (let depth = 0; node && node.tagName && depth < 4; depth++) {
|
|
36
|
+
let bit = node.tagName.toLowerCase();
|
|
37
|
+
if (node.id) { bits.unshift(bit + '#' + node.id); break; }
|
|
38
|
+
const cls = String(node.className || '').split(/\s+/).filter(Boolean).slice(0, 2);
|
|
39
|
+
if (cls.length) bit += '.' + cls.join('.');
|
|
40
|
+
bits.unshift(bit);
|
|
41
|
+
node = node.parentElement;
|
|
42
|
+
}
|
|
43
|
+
css = bits.join('>') || null;
|
|
44
|
+
} catch { css = null; }
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
tag: el.tagName.toLowerCase(),
|
|
48
|
+
role: attr('role') || null,
|
|
49
|
+
// innerText, not value: a button says what it does, a field does not.
|
|
50
|
+
text: el.tagName.toLowerCase() === 'input' ? null : clean(el.innerText),
|
|
51
|
+
label: labelText || clean(attr('aria-label')),
|
|
52
|
+
name: clean(el.name) || clean(attr('name')),
|
|
53
|
+
id: clean(el.id) || null,
|
|
54
|
+
placeholder: clean(el.placeholder) || clean(attr('placeholder')),
|
|
55
|
+
css,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Is focus in something we must go blind for?
|
|
61
|
+
*
|
|
62
|
+
* Deliberately wider than input[type=password]: plenty of one-time-code and
|
|
63
|
+
* "access code" fields are plain text inputs, and a lesson that teaches a
|
|
64
|
+
* login will walk straight through one. Word-bounded so that "shipping" is not
|
|
65
|
+
* a PIN and "cardigan" is not a card number.
|
|
66
|
+
*/
|
|
67
|
+
export function isSecretField(el) {
|
|
68
|
+
if (!el || !el.tagName) return false;
|
|
69
|
+
const attr = (n) => {
|
|
70
|
+
try { return el.getAttribute ? el.getAttribute(n) : null; } catch { return null; }
|
|
71
|
+
};
|
|
72
|
+
const type = String(el.type || attr('type') || '').toLowerCase();
|
|
73
|
+
if (type === 'password') return true;
|
|
74
|
+
|
|
75
|
+
const auto = String(attr('autocomplete') || '').toLowerCase();
|
|
76
|
+
if (auto.includes('password') || auto.includes('one-time-code') || auto.includes('cc-number')) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let labelText = '';
|
|
81
|
+
try {
|
|
82
|
+
if (el.labels && el.labels.length) labelText = String(el.labels[0].innerText || '');
|
|
83
|
+
} catch { labelText = ''; }
|
|
84
|
+
|
|
85
|
+
const hay = [el.name, attr('name'), el.id, el.placeholder, attr('placeholder'),
|
|
86
|
+
attr('aria-label'), labelText]
|
|
87
|
+
.map((v) => String(v == null ? '' : v))
|
|
88
|
+
.join(' ')
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9]+/g, ' ');
|
|
91
|
+
|
|
92
|
+
const SECRET = new RegExp('(^| )(password|passwd|pass|passcode|secret|token|otp|' +
|
|
93
|
+
'cvv|cvc|pin|card number|cardnumber|security code|access code|auth code)( |$)');
|
|
94
|
+
return SECRET.test(' ' + hay + ' ');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Watch one document. Returns nothing; reports through window.__addaiLearn,
|
|
99
|
+
* the CDP binding the recorder installs.
|
|
100
|
+
*
|
|
101
|
+
* Capture phase throughout, so a page that stops propagation on its own
|
|
102
|
+
* handlers (most single-page apps do) cannot make a lesson go blank.
|
|
103
|
+
*/
|
|
104
|
+
export function pageRecorder(describe, isSecret) {
|
|
105
|
+
if (window.__addaiLearnInstalled) return;
|
|
106
|
+
window.__addaiLearnInstalled = true;
|
|
107
|
+
|
|
108
|
+
const send = (ev) => {
|
|
109
|
+
try {
|
|
110
|
+
if (typeof window.__addaiLearn === 'function') window.__addaiLearn(JSON.stringify(ev));
|
|
111
|
+
} catch { /* a lesson must never break the page it is watching */ }
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Secret tracking has TWO independent triggers on purpose.
|
|
115
|
+
//
|
|
116
|
+
// focusin is the obvious one and it is not reliable: a browser window that
|
|
117
|
+
// does not itself have focus changes activeElement without firing any focus
|
|
118
|
+
// event at all (headless Chrome does exactly this, and so does a desktop
|
|
119
|
+
// whose window manager has put another window on top). Relying on it alone
|
|
120
|
+
// gives a silent failure whose consequence is a password in the timeline.
|
|
121
|
+
//
|
|
122
|
+
// So `input` opens a window too. Whatever else is true, a keystroke reaching
|
|
123
|
+
// a secret field is proof we should already be blind.
|
|
124
|
+
let secretOpen = false;
|
|
125
|
+
const openSecret = () => {
|
|
126
|
+
if (!secretOpen) { secretOpen = true; send({ kind: 'secret_focus' }); }
|
|
127
|
+
};
|
|
128
|
+
const closeSecret = () => {
|
|
129
|
+
if (secretOpen) { secretOpen = false; send({ kind: 'secret_blur' }); }
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const editable = (t) => {
|
|
133
|
+
const tag = t && t.tagName ? t.tagName.toLowerCase() : '';
|
|
134
|
+
return tag === 'input' || tag === 'textarea' || tag === 'select' ||
|
|
135
|
+
!!(t && t.isContentEditable);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
document.addEventListener('click', (e) => {
|
|
139
|
+
send({ kind: 'page_click', target: describe(e.target) });
|
|
140
|
+
// Deliberately does NOT close a secret window. Pressing "show password" or
|
|
141
|
+
// "Sign in" mid-entry is a click on something harmless, and closing there
|
|
142
|
+
// would leave a hole between it and the next keystroke — one that a
|
|
143
|
+
// coalesced `type` event could land in unredacted. Over-inclusive is the
|
|
144
|
+
// right direction for redaction; a window closes when focus really moves.
|
|
145
|
+
}, true);
|
|
146
|
+
|
|
147
|
+
const arrivedAt = (t) => {
|
|
148
|
+
if (isSecret(t)) { openSecret(); return; }
|
|
149
|
+
if (editable(t)) { closeSecret(); send({ kind: 'page_focus', target: describe(t) }); }
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
document.addEventListener('focusin', (e) => arrivedAt(e.target), true);
|
|
153
|
+
// The backstop. Never carries what was typed — only that typing happened.
|
|
154
|
+
document.addEventListener('input', (e) => arrivedAt(e.target), true);
|
|
155
|
+
|
|
156
|
+
// A page can be torn down mid-password. Closing the window on unload means
|
|
157
|
+
// an unclosed one really does mean "we never saw it end".
|
|
158
|
+
window.addEventListener('pagehide', closeSecret, true);
|
|
159
|
+
|
|
160
|
+
document.addEventListener('submit', (e) => {
|
|
161
|
+
send({ kind: 'submit', target: describe(e.target) });
|
|
162
|
+
}, true);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The exact text handed to Page.addScriptToEvaluateOnNewDocument. */
|
|
166
|
+
export function injectedSource() {
|
|
167
|
+
return '(function(){\n' +
|
|
168
|
+
'var describeTarget = ' + describeTarget.toString() + ';\n' +
|
|
169
|
+
'var isSecretField = ' + isSecretField.toString() + ';\n' +
|
|
170
|
+
'var pageRecorder = ' + pageRecorder.toString() + ';\n' +
|
|
171
|
+
'try { pageRecorder(describeTarget, isSecretField); } catch (e) {}\n' +
|
|
172
|
+
'})();';
|
|
173
|
+
}
|