@addai/node 0.27.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/vault-extension/assets/inter.woff2 +0 -0
- package/assets/vault-extension/background.js +45 -0
- package/assets/vault-extension/content.js +62 -0
- package/assets/vault-extension/icons/icon-128.png +0 -0
- package/assets/vault-extension/icons/icon-16.png +0 -0
- package/assets/vault-extension/icons/icon-32.png +0 -0
- package/assets/vault-extension/icons/icon-48.png +0 -0
- package/assets/vault-extension/manifest.json +39 -0
- package/dist/capabilities.js +2 -1
- package/dist/command-runner.js +109 -2
- package/dist/desktop/docker.d.ts +4 -1
- package/dist/desktop/docker.js +7 -3
- package/dist/desktop/engine.d.ts +13 -0
- package/dist/desktop/engine.js +35 -4
- package/dist/desktop/install-engine.d.ts +33 -2
- package/dist/desktop/install-engine.js +138 -12
- package/dist/desktop/learn/browser.d.ts +41 -0
- package/dist/desktop/learn/browser.js +136 -0
- package/dist/desktop/learn/injected.mjs +173 -0
- package/dist/desktop/learn/recorder.mjs +151 -0
- package/dist/desktop/learn/session.d.ts +51 -0
- package/dist/desktop/learn/session.js +224 -0
- package/dist/desktop/provider.d.ts +4 -0
- package/dist/desktop/start-engine.d.ts +7 -4
- package/dist/desktop/start-engine.js +50 -6
- package/dist/desktop/vault-extension.d.ts +21 -0
- package/dist/desktop/vault-extension.js +112 -0
- package/dist/desktop/vault-seed.mjs +112 -0
- package/dist/desktop/vault-session.d.ts +41 -0
- package/dist/desktop/vault-session.js +164 -0
- package/package.json +4 -3
- package/scripts/copy-assets.js +18 -0
- package/dist/tui.d.ts +0 -1
- package/dist/tui.js +0 -314
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.REPAIR_NEEDS_RESTART = void 0;
|
|
37
|
+
exports.findBrew = findBrew;
|
|
38
|
+
exports.macDmgScript = macDmgScript;
|
|
39
|
+
exports.installingUser = installingUser;
|
|
4
40
|
exports.installPlan = installPlan;
|
|
5
41
|
exports.repairPlan = repairPlan;
|
|
6
42
|
exports.runningAsRoot = runningAsRoot;
|
|
@@ -8,20 +44,101 @@ exports.sudoArgv = sudoArgv;
|
|
|
8
44
|
exports.installEngine = installEngine;
|
|
9
45
|
// Install a container engine on this machine, on request.
|
|
10
46
|
//
|
|
11
|
-
// Deliberately per-platform and deliberately honest about privilege:
|
|
12
|
-
//
|
|
13
|
-
// it. Rather than half-run and leave a broken
|
|
14
|
-
//
|
|
15
|
-
// fails mysteriously.
|
|
47
|
+
// Deliberately per-platform and deliberately honest about privilege: Linux and
|
|
48
|
+
// a Homebrew-less Mac both need root, and a daemon running as an ordinary user
|
|
49
|
+
// cannot get it. Rather than half-run and leave a broken install behind, it
|
|
50
|
+
// checks first and either takes the password round trip or says exactly what
|
|
51
|
+
// to do instead. A button that fails clearly beats one that fails mysteriously.
|
|
52
|
+
//
|
|
53
|
+
// What it must never do is refuse on behalf of a machine it hasn't looked at.
|
|
54
|
+
// This shipped choosing `brew` by name, so a Mac without Homebrew — most Macs —
|
|
55
|
+
// got "brew is not available here, install it by hand" from the button whose
|
|
56
|
+
// entire job was doing the installing.
|
|
16
57
|
const child_process_1 = require("child_process");
|
|
58
|
+
const os = __importStar(require("os"));
|
|
59
|
+
const path = __importStar(require("path"));
|
|
17
60
|
const win_1 = require("../win");
|
|
18
|
-
/**
|
|
19
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Where Homebrew actually lives.
|
|
63
|
+
*
|
|
64
|
+
* `brew` is not on the PATH of a daemon that did not come from a login shell —
|
|
65
|
+
* launchd hands a job `PATH=/usr/bin:/bin:/usr/sbin:/sbin`, and neither
|
|
66
|
+
* Homebrew prefix is in it. Spawning the bare name then fails ENOENT and the
|
|
67
|
+
* install reports "brew is not available here" on a machine that has brew,
|
|
68
|
+
* which is a lie the user cannot act on. Look where it really is instead.
|
|
69
|
+
*/
|
|
70
|
+
function findBrew(platform = process.platform) {
|
|
71
|
+
if (platform !== 'darwin')
|
|
72
|
+
return null;
|
|
73
|
+
return (0, win_1.findCliBinary)('brew', {
|
|
74
|
+
posixCandidates: [
|
|
75
|
+
'/opt/homebrew/bin/brew', // Apple Silicon
|
|
76
|
+
'/usr/local/bin/brew', // Intel
|
|
77
|
+
path.join(os.homedir(), 'homebrew', 'bin', 'brew'), // per-user prefix
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Install Docker Desktop from Docker's own disk image.
|
|
83
|
+
*
|
|
84
|
+
* The path for a Mac with no Homebrew — which is most Macs. This is the same
|
|
85
|
+
* download the `docker` cask fetches, driven by the installer Docker ships
|
|
86
|
+
* inside the app, so it is the vendor's supported unattended install rather
|
|
87
|
+
* than something we invented.
|
|
88
|
+
*
|
|
89
|
+
* Root is needed for one step: putting the app in /Applications and laying
|
|
90
|
+
* down its privileged helper. `--user` is what stops that cost being paid
|
|
91
|
+
* twice — without it, the first launch stops at an admin prompt on a machine
|
|
92
|
+
* nobody is sitting at.
|
|
93
|
+
*/
|
|
94
|
+
function macDmgScript(user) {
|
|
95
|
+
const u = user.replace(/[^A-Za-z0-9._-]/g, '');
|
|
96
|
+
const userFlag = u ? ` --user=${u}` : '';
|
|
97
|
+
// No progress bar on the download: every chunk of output is one database
|
|
98
|
+
// write on the command row, and a bar redrawing at 20Hz for 700MB would be
|
|
99
|
+
// thousands of them. A line per step is what the log is for.
|
|
100
|
+
return `set -e
|
|
101
|
+
case "$(uname -m)" in arm64) slug=arm64 ;; *) slug=amd64 ;; esac
|
|
102
|
+
tmp=$(mktemp -d)
|
|
103
|
+
cleanup() { hdiutil detach -quiet "$tmp/mnt" 2>/dev/null || true; rm -rf "$tmp"; }
|
|
104
|
+
trap cleanup EXIT
|
|
105
|
+
echo "downloading Docker Desktop for $slug (about 700MB, this is the slow part)"
|
|
106
|
+
curl -fsSL --retry 3 -o "$tmp/Docker.dmg" "https://desktop.docker.com/mac/main/$slug/Docker.dmg"
|
|
107
|
+
echo "mounting the image"
|
|
108
|
+
mkdir -p "$tmp/mnt"
|
|
109
|
+
hdiutil attach -nobrowse -readonly -mountpoint "$tmp/mnt" "$tmp/Docker.dmg"
|
|
110
|
+
echo "installing"
|
|
111
|
+
"$tmp/mnt/Docker.app/Contents/MacOS/install" --accept-license${userFlag}
|
|
112
|
+
echo "installed"`;
|
|
113
|
+
}
|
|
114
|
+
/** The current login name, for the `--user` the Docker installer wants. Never
|
|
115
|
+
* root: under `sudo` that would hand the app to the wrong account. */
|
|
116
|
+
function installingUser(env = process.env) {
|
|
117
|
+
return env.SUDO_USER || env.USER || env.LOGNAME || '';
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Pure, so the choice is testable on any machine — the two impure lookups
|
|
121
|
+
* (is brew here, who are we) are parameters with defaults rather than calls
|
|
122
|
+
* inside the body.
|
|
123
|
+
*/
|
|
124
|
+
function installPlan(platform, isRoot, brew = findBrew(platform), user = installingUser()) {
|
|
20
125
|
if (platform === 'darwin') {
|
|
21
|
-
// Homebrew installs Docker Desktop without sudo when brew owns its
|
|
126
|
+
// Homebrew installs Docker Desktop without sudo when brew owns its
|
|
127
|
+
// prefix, so it stays the first choice where it exists.
|
|
128
|
+
if (brew) {
|
|
129
|
+
return {
|
|
130
|
+
file: brew, args: ['install', '--cask', '--no-quarantine', 'docker'],
|
|
131
|
+
manual: 'brew install --cask docker', needsRoot: false,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// No Homebrew. This used to be the end of the road — the button said
|
|
135
|
+
// "install brew by hand" to someone who had asked us to do the installing.
|
|
136
|
+
// Docker's own image needs no package manager, only a password.
|
|
22
137
|
return {
|
|
23
|
-
file:
|
|
24
|
-
|
|
138
|
+
file: isRoot ? 'sh' : null,
|
|
139
|
+
args: ['-c', macDmgScript(user)],
|
|
140
|
+
manual: 'download Docker Desktop from https://docker.com/products/docker-desktop and open it once',
|
|
141
|
+
needsRoot: true,
|
|
25
142
|
};
|
|
26
143
|
}
|
|
27
144
|
if (platform === 'win32') {
|
|
@@ -71,6 +188,15 @@ async function installEngine(onLog, askPassword, alreadyInstalledButBlocked) {
|
|
|
71
188
|
let args = plan.args;
|
|
72
189
|
let password = null;
|
|
73
190
|
// Docker present but the socket is refused: repair, do not reinstall.
|
|
191
|
+
//
|
|
192
|
+
// Only on Linux. `usermod -aG docker` is a Linux answer to a Linux problem;
|
|
193
|
+
// on a Mac or a Windows box "installed but not answering" means Docker
|
|
194
|
+
// Desktop is closed, and running usermod there would spend the user's
|
|
195
|
+
// password on a command that does not exist.
|
|
196
|
+
if (alreadyInstalledButBlocked && process.platform !== 'linux') {
|
|
197
|
+
throw new Error('Docker is already installed here — it is just not running. '
|
|
198
|
+
+ 'Start it instead: press Start, or open Docker Desktop on that machine.');
|
|
199
|
+
}
|
|
74
200
|
const repair = alreadyInstalledButBlocked
|
|
75
201
|
? repairPlan(process.env.USER || process.env.LOGNAME || 'root')
|
|
76
202
|
: null;
|
|
@@ -104,7 +230,7 @@ async function installEngine(onLog, askPassword, alreadyInstalledButBlocked) {
|
|
|
104
230
|
args = sudo.args;
|
|
105
231
|
}
|
|
106
232
|
if (!file) {
|
|
107
|
-
throw new Error(`This needs root, and the node is not running as root. Run this on ${
|
|
233
|
+
throw new Error(`This needs root, and the node is not running as root. Run this on ${os.hostname()}:\n ${plan.manual}`);
|
|
108
234
|
}
|
|
109
235
|
onLog(`installing a container engine\n ${plan.manual}\n\n`);
|
|
110
236
|
const inv = (0, win_1.resolveCliInvocation)(file, args);
|
|
@@ -124,7 +250,7 @@ async function installEngine(onLog, askPassword, alreadyInstalledButBlocked) {
|
|
|
124
250
|
const cap = (b) => { const s = b.toString('utf8'); tail = (tail + s).slice(-2000); onLog(s); };
|
|
125
251
|
child.stdout?.on('data', cap);
|
|
126
252
|
child.stderr?.on('data', cap);
|
|
127
|
-
child.on('error', err => reject(new Error(
|
|
253
|
+
child.on('error', err => reject(new Error(`could not run ${file} on this machine. Install it by hand:\n ${plan.manual}\n(${err.message})`)));
|
|
128
254
|
child.on('exit', code => code === 0
|
|
129
255
|
? resolve()
|
|
130
256
|
: reject(new Error(`${repair ? 'permission fix' : 'install'} failed (exit ${code}). Try by hand:\n ${repair ? repair.manual : plan.manual}\n${tail.slice(-400)}`)));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare const DEBUG_PORT = 9222;
|
|
2
|
+
/** Where the browser really is, in the order we prefer to find it. Never
|
|
3
|
+
* /usr/local/bin/browser itself: once that IS the wrapper, resolving through
|
|
4
|
+
* it would point the wrapper at itself and exec forever. */
|
|
5
|
+
export declare const REAL_BROWSERS: string[];
|
|
6
|
+
export declare const WRAPPER_PATH = "/usr/local/bin/browser";
|
|
7
|
+
/** The in-container script that signs the browser in. Beside the extension,
|
|
8
|
+
* under the same bind mount, so it arrives by the same route. */
|
|
9
|
+
export declare const SEED_SCRIPT = "/conf/vault/vault-seed.mjs";
|
|
10
|
+
/**
|
|
11
|
+
* The wrapper, exactly.
|
|
12
|
+
*
|
|
13
|
+
* The address is pinned to loopback deliberately. The container publishes only
|
|
14
|
+
* VNC, but a debug port on 0.0.0.0 would still be reachable from the host, and
|
|
15
|
+
* CDP is complete control of the browser — cookies, storage, every page.
|
|
16
|
+
* Inside the container it crosses no boundary: the entity already has root
|
|
17
|
+
* there. Outside it, it would.
|
|
18
|
+
*/
|
|
19
|
+
export declare function wrapperScript(realBrowser: string, extensionDir?: string | null): string;
|
|
20
|
+
/** Is what is on disk already what we would write? Exact match, so a change to
|
|
21
|
+
* the script rewrites every desktop on its next start rather than leaving a
|
|
22
|
+
* fleet on two versions. Rewriting is idempotent and costs one exec. */
|
|
23
|
+
export declare function wrapperIsCurrent(content: string, realBrowser: string, extensionDir?: string | null): boolean;
|
|
24
|
+
export type RunInDesktop = (cmd: string[]) => Promise<{
|
|
25
|
+
ok: boolean;
|
|
26
|
+
out: string;
|
|
27
|
+
}>;
|
|
28
|
+
export interface WrapperResult {
|
|
29
|
+
changed: boolean;
|
|
30
|
+
/** The real browser behind the wrapper, or null when the desktop has none. */
|
|
31
|
+
browser: string | null;
|
|
32
|
+
note?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Put the wrapper in place if it is not already there.
|
|
36
|
+
*
|
|
37
|
+
* Safe to call on every desktop start and before every lesson: it reads what
|
|
38
|
+
* is there first and only writes when it differs, so the common case is two
|
|
39
|
+
* cheap execs and no change.
|
|
40
|
+
*/
|
|
41
|
+
export declare function ensureBrowserWrapper(run: RunInDesktop, confDir: string, extensionDir?: string | null): Promise<WrapperResult>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SEED_SCRIPT = exports.WRAPPER_PATH = exports.REAL_BROWSERS = exports.DEBUG_PORT = void 0;
|
|
37
|
+
exports.wrapperScript = wrapperScript;
|
|
38
|
+
exports.wrapperIsCurrent = wrapperIsCurrent;
|
|
39
|
+
exports.ensureBrowserWrapper = ensureBrowserWrapper;
|
|
40
|
+
// Making a desktop's browser watchable.
|
|
41
|
+
//
|
|
42
|
+
// CDP is how +Ai Learn knows WHICH control you clicked rather than only where
|
|
43
|
+
// on the screen you clicked it. It needs Chrome to be listening on a debug
|
|
44
|
+
// port, and that flag is read at process start — it cannot be added to a
|
|
45
|
+
// browser that is already running.
|
|
46
|
+
//
|
|
47
|
+
// Every browser launch in a desktop goes through one path: /usr/local/bin/
|
|
48
|
+
// browser, used by the dock launcher, by the MCP's open_url, and by anything
|
|
49
|
+
// typed in the terminal. In the image it is a symlink to Chrome (amd64) or
|
|
50
|
+
// Chromium (arm64). Replacing it with a three-line wrapper is therefore the
|
|
51
|
+
// whole change — and it is a container-local runtime change, not a new image.
|
|
52
|
+
//
|
|
53
|
+
// Written through /conf rather than over stdin: the daemon already owns that
|
|
54
|
+
// directory on the host and it appears inside the container, which is how
|
|
55
|
+
// setup.sh gets there. One less thing to escape.
|
|
56
|
+
const fs = __importStar(require("fs"));
|
|
57
|
+
const path = __importStar(require("path"));
|
|
58
|
+
exports.DEBUG_PORT = 9222;
|
|
59
|
+
/** Where the browser really is, in the order we prefer to find it. Never
|
|
60
|
+
* /usr/local/bin/browser itself: once that IS the wrapper, resolving through
|
|
61
|
+
* it would point the wrapper at itself and exec forever. */
|
|
62
|
+
exports.REAL_BROWSERS = [
|
|
63
|
+
'/usr/bin/google-chrome-stable',
|
|
64
|
+
'/usr/bin/chromium',
|
|
65
|
+
'/usr/bin/chromium-browser',
|
|
66
|
+
];
|
|
67
|
+
exports.WRAPPER_PATH = '/usr/local/bin/browser';
|
|
68
|
+
/** The in-container script that signs the browser in. Beside the extension,
|
|
69
|
+
* under the same bind mount, so it arrives by the same route. */
|
|
70
|
+
exports.SEED_SCRIPT = '/conf/vault/vault-seed.mjs';
|
|
71
|
+
/**
|
|
72
|
+
* The wrapper, exactly.
|
|
73
|
+
*
|
|
74
|
+
* The address is pinned to loopback deliberately. The container publishes only
|
|
75
|
+
* VNC, but a debug port on 0.0.0.0 would still be reachable from the host, and
|
|
76
|
+
* CDP is complete control of the browser — cookies, storage, every page.
|
|
77
|
+
* Inside the container it crosses no boundary: the entity already has root
|
|
78
|
+
* there. Outside it, it would.
|
|
79
|
+
*/
|
|
80
|
+
function wrapperScript(realBrowser, extensionDir) {
|
|
81
|
+
// Chrome 137 disabled --load-extension unless the switch that removed it is
|
|
82
|
+
// itself turned off. Both flags or neither: passing --load-extension alone
|
|
83
|
+
// is silently ignored, which looks exactly like a broken extension.
|
|
84
|
+
const ext = extensionDir
|
|
85
|
+
? ` --load-extension=${extensionDir}` +
|
|
86
|
+
' --disable-features=DisableLoadExtensionCommandLineSwitch'
|
|
87
|
+
: '';
|
|
88
|
+
// The seeder runs in the background and waits for the port, because the
|
|
89
|
+
// cookie can only be set once Chrome is listening — and Chrome only listens
|
|
90
|
+
// once it has started. Backgrounded and detached from the browser's fate:
|
|
91
|
+
// exec replaces this shell, so nothing here can hold the browser up.
|
|
92
|
+
const seed = extensionDir
|
|
93
|
+
? `[ -f ${exports.SEED_SCRIPT} ] && (node ${exports.SEED_SCRIPT} >/dev/null 2>&1 &)\n`
|
|
94
|
+
: '';
|
|
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} "$@"
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
/** Is what is on disk already what we would write? Exact match, so a change to
|
|
102
|
+
* the script rewrites every desktop on its next start rather than leaving a
|
|
103
|
+
* fleet on two versions. Rewriting is idempotent and costs one exec. */
|
|
104
|
+
function wrapperIsCurrent(content, realBrowser, extensionDir) {
|
|
105
|
+
return content === wrapperScript(realBrowser, extensionDir);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Put the wrapper in place if it is not already there.
|
|
109
|
+
*
|
|
110
|
+
* Safe to call on every desktop start and before every lesson: it reads what
|
|
111
|
+
* is there first and only writes when it differs, so the common case is two
|
|
112
|
+
* cheap execs and no change.
|
|
113
|
+
*/
|
|
114
|
+
async function ensureBrowserWrapper(run, confDir, extensionDir) {
|
|
115
|
+
const found = await run(['sh', '-c',
|
|
116
|
+
exports.REAL_BROWSERS.map(b => `[ -x ${b} ] && echo ${b}`).join(' || ') + ' || true']);
|
|
117
|
+
const real = found.out.trim().split('\n')[0]?.trim();
|
|
118
|
+
if (!real) {
|
|
119
|
+
return { changed: false, browser: null, note: 'this desktop has no browser installed' };
|
|
120
|
+
}
|
|
121
|
+
const current = await run(['sh', '-c', `cat ${exports.WRAPPER_PATH} 2>/dev/null || true`]);
|
|
122
|
+
if (wrapperIsCurrent(current.out, real, extensionDir))
|
|
123
|
+
return { changed: false, browser: real };
|
|
124
|
+
const dir = path.join(confDir, 'learn');
|
|
125
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
126
|
+
fs.writeFileSync(path.join(dir, 'browser'), wrapperScript(real, extensionDir), { mode: 0o755 });
|
|
127
|
+
// sudo, not `docker exec -u root`: the image gives the entity passwordless
|
|
128
|
+
// sudo inside its own container, and going through the same exec path as
|
|
129
|
+
// everything else keeps one way of talking to a desktop rather than two.
|
|
130
|
+
const put = await run(['sh', '-c',
|
|
131
|
+
`sudo cp /conf/learn/browser ${exports.WRAPPER_PATH} && sudo chmod +x ${exports.WRAPPER_PATH}`]);
|
|
132
|
+
if (!put.ok) {
|
|
133
|
+
return { changed: false, browser: real, note: `could not install the browser wrapper: ${put.out.trim().slice(0, 200)}` };
|
|
134
|
+
}
|
|
135
|
+
return { changed: true, browser: real };
|
|
136
|
+
}
|
|
@@ -0,0 +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
|
+
}
|