@realtimex/rtxexec 0.1.0 → 0.2.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/README.md +37 -0
- package/package.json +1 -1
- package/src/browser.js +133 -0
- package/src/cli.js +14 -0
- package/src/client.js +4 -1
package/README.md
CHANGED
|
@@ -41,3 +41,40 @@ when the installed launcher is a .cmd/.bat file. Shell pipelines must be compose
|
|
|
41
41
|
explicitly; the wrapper does not interpret shell syntax.
|
|
42
42
|
|
|
43
43
|
`npm test` runs isolated fixtures. `npm pack --dry-run` validates package contents.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## Browser Login credentials (0.2.0)
|
|
47
|
+
|
|
48
|
+
Create a **Login** in RealTimeX Settings > Secrets (encrypted username/password,
|
|
49
|
+
login URL and exact allowed origins). Management belongs to the moderator CLI;
|
|
50
|
+
rtxexec only uses credentials. Browser operations require Node.js 22 or newer.
|
|
51
|
+
|
|
52
|
+
Prepare the page using agent-browser on your existing RealTimeX browser. Then:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
rtxexec browser-tabs --cdp 9235
|
|
56
|
+
rtxexec browser-login secret://company-login --cdp 9235 --tab <target-id> \
|
|
57
|
+
--username-selector '#username' --password-selector '#password' \
|
|
58
|
+
--submit-selector 'button[type=submit]'
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use the full target ID from `browser-tabs`, not agent-browser's `t1`/`t2` alias.
|
|
62
|
+
The command never navigates or chooses a tab automatically. Each CSS selector must
|
|
63
|
+
match exactly one visible top-level form control. Iframe forms are not supported.
|
|
64
|
+
For two-step logins, specify only the username selector first, then prepare the
|
|
65
|
+
password step and run again with only its selector. Omit submit to fill only.
|
|
66
|
+
A `filled` or `submitted` result does not prove authentication: verify a non-secret
|
|
67
|
+
success state separately. MFA and CAPTCHA stay in the normal browser workflow.
|
|
68
|
+
|
|
69
|
+
This works with agent-browser 0.27.0 because it uses the local CDP connection
|
|
70
|
+
instead of unsupported credential-provider flags. Values are passed as CDP
|
|
71
|
+
arguments in an isolated execution context, never inserted into JavaScript source,
|
|
72
|
+
saved in another vault, or printed in normal output. Scope, enabled state and exact
|
|
73
|
+
origin are checked before resolution; the form's origin/action is rechecked during
|
|
74
|
+
filling. History records resolution with browser origin/target and terminal context,
|
|
75
|
+
not an assertion that login succeeded.
|
|
76
|
+
|
|
77
|
+
The destination/browser necessarily receives the values. Do not take snapshots,
|
|
78
|
+
screenshots, recordings, DOM-value reads, or network traces while credentials are
|
|
79
|
+
present. A page may display a username after login. This reduces accidental exposure;
|
|
80
|
+
it does not prevent intentional local extraction or malicious website behavior.
|
package/package.json
CHANGED
package/src/browser.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { UsageError } from './error.js';
|
|
2
|
+
import { resolveSecrets } from './client.js';
|
|
3
|
+
|
|
4
|
+
export function parseBrowserArguments(argv) {
|
|
5
|
+
const command = argv[0];
|
|
6
|
+
const options = {};
|
|
7
|
+
let reference;
|
|
8
|
+
let i = 1;
|
|
9
|
+
if (command === 'browser-login') reference = argv[i++];
|
|
10
|
+
const allowed = command === 'browser-tabs' ? ['cdp'] : ['cdp', 'tab', 'username-selector', 'password-selector', 'submit-selector'];
|
|
11
|
+
for (; i < argv.length; i += 2) {
|
|
12
|
+
const key = argv[i]?.replace(/^--/, '');
|
|
13
|
+
if (!argv[i]?.startsWith('--') || !allowed.includes(key) || options[key] !== undefined || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new UsageError('Invalid browser option. See rtxexec --help.');
|
|
14
|
+
options[key] = argv[i + 1];
|
|
15
|
+
}
|
|
16
|
+
if (!/^\d{1,5}$/.test(options.cdp || '') || Number(options.cdp) < 1 || Number(options.cdp) > 65535) throw new UsageError('Provide a local CDP port with --cdp.');
|
|
17
|
+
if (command === 'browser-login' && (!reference?.startsWith('secret://') || !/^[a-zA-Z0-9_-]{1,128}$/.test(options.tab || '') || (!options['username-selector'] && !options['password-selector']))) throw new UsageError('Provide a secret reference, --tab from browser-tabs, and at least one username/password selector.');
|
|
18
|
+
return { command, reference, ...options };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function browserTargets(port, fetchImpl = fetch) {
|
|
22
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/json/list`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
|
|
23
|
+
if (!response.ok) throw new UsageError('Could not list browser tabs.');
|
|
24
|
+
let targets;
|
|
25
|
+
try { targets = await response.json(); } catch { throw new UsageError('Invalid CDP target response.'); }
|
|
26
|
+
if (!Array.isArray(targets)) throw new UsageError('Invalid CDP target response.');
|
|
27
|
+
return targets.filter((target) => {
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(target.url);
|
|
30
|
+
return target.type === 'page' && ['http:', 'https:'].includes(url.protocol) && url.pathname !== '/cli-browser/index.html';
|
|
31
|
+
} catch { return false; }
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function connectCdp(address, port, WebSocketImpl = globalThis.WebSocket) {
|
|
36
|
+
if (!WebSocketImpl) throw new UsageError('Browser login requires Node.js 22 or newer.');
|
|
37
|
+
const url = new URL(address);
|
|
38
|
+
if (url.protocol !== 'ws:' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.port !== String(Number(port)) || url.username || url.password) throw new UsageError('CDP must use the selected local browser port.');
|
|
39
|
+
const socket = new WebSocketImpl(url);
|
|
40
|
+
const pending = new Map(); let nextId = 0;
|
|
41
|
+
const rejectAll = () => { for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('CDP connection ended')); } pending.clear(); };
|
|
42
|
+
socket.addEventListener('close', rejectAll);
|
|
43
|
+
socket.addEventListener('error', rejectAll);
|
|
44
|
+
socket.addEventListener('message', ({ data }) => {
|
|
45
|
+
let message; try { message = JSON.parse(data); } catch { return; }
|
|
46
|
+
const entry = pending.get(message.id); if (!entry) return;
|
|
47
|
+
pending.delete(message.id); clearTimeout(entry.timer);
|
|
48
|
+
if (message.error) entry.reject(new Error('CDP operation failed'));
|
|
49
|
+
else entry.resolve(message.result);
|
|
50
|
+
});
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
const timer = setTimeout(() => { socket.close(); reject(new Error('CDP connection timeout')); }, 5000);
|
|
53
|
+
socket.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
54
|
+
socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed')); }, { once: true });
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
send(method, params = {}) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const id = ++nextId;
|
|
60
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP operation timeout')); }, 10000);
|
|
61
|
+
pending.set(id, { resolve, reject, timer });
|
|
62
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
close() { rejectAll(); socket.close(); },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fixed code executed in an isolated world. Values travel only as CDP arguments,
|
|
70
|
+
// never interpolated into source or returned to the caller. Top-level forms only.
|
|
71
|
+
export function fillLogin(username, password, selectors, expectedOrigin) {
|
|
72
|
+
const sameOrigin = () => location.origin === expectedOrigin;
|
|
73
|
+
const find = (selector) => { if (!selector) return null; const matches = document.querySelectorAll(selector); return matches.length === 1 ? matches[0] : null; };
|
|
74
|
+
const user = find(selectors.username); const pass = find(selectors.password); const submit = find(selectors.submit);
|
|
75
|
+
const fields = [[user, username, selectors.username], [pass, password, selectors.password]];
|
|
76
|
+
const safeForm = (element) => !element?.form || new URL(element.form.action || location.href).origin === expectedOrigin;
|
|
77
|
+
const usable = (element) => {
|
|
78
|
+
if (!element || element.disabled || element.matches(':disabled') || element.getClientRects().length === 0) return false;
|
|
79
|
+
const style = getComputedStyle(element);
|
|
80
|
+
if (style.visibility !== 'visible') return false;
|
|
81
|
+
for (let node = element; node; node = node.parentElement) {
|
|
82
|
+
if (Number(getComputedStyle(node).opacity) === 0) return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
86
|
+
if (!sameOrigin()) return { error: 'origin' };
|
|
87
|
+
for (const [element, , selector] of fields) {
|
|
88
|
+
if (!selector) continue;
|
|
89
|
+
if (!(element instanceof HTMLInputElement) || element.readOnly || !usable(element) || !safeForm(element)) return { error: 'field' };
|
|
90
|
+
}
|
|
91
|
+
if (pass && pass.type !== 'password') return { error: 'password-field' };
|
|
92
|
+
if (selectors.submit && (!usable(submit) || !safeForm(submit) || (submit.formAction && new URL(submit.formAction).origin !== expectedOrigin))) return { error: 'submit' };
|
|
93
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
94
|
+
for (const [element, value, selector] of fields) {
|
|
95
|
+
if (!selector) continue;
|
|
96
|
+
if (!sameOrigin() || !element.isConnected || !usable(element) || element.readOnly || (element === pass && element.type !== "password") || !safeForm(element)) return { error: 'changed' };
|
|
97
|
+
setter.call(element, value);
|
|
98
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
99
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
100
|
+
}
|
|
101
|
+
if (submit) {
|
|
102
|
+
if (!sameOrigin() || !submit.isConnected || !usable(submit) || !safeForm(submit) || (submit.formAction && new URL(submit.formAction).origin !== expectedOrigin)) return { error: 'changed' };
|
|
103
|
+
submit.click();
|
|
104
|
+
}
|
|
105
|
+
return { status: submit ? 'submitted' : 'filled' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function runBrowser(plan, env, { targets = browserTargets, connect = connectCdp, resolver = resolveSecrets } = {}) {
|
|
109
|
+
const pages = await targets(plan.cdp);
|
|
110
|
+
if (plan.command === 'browser-tabs') return { tabs: pages.map(({ id, url }) => { const clean = new URL(url); clean.search = ''; clean.hash = ''; clean.username = ''; clean.password = ''; return { id, url: clean.href }; }) };
|
|
111
|
+
const target = pages.find(({ id }) => id === plan.tab);
|
|
112
|
+
if (!target) throw new UsageError('Selected browser tab is unavailable. Run browser-tabs again.');
|
|
113
|
+
const cdp = await connect(target.webSocketDebuggerUrl, plan.cdp);
|
|
114
|
+
try {
|
|
115
|
+
const { frameTree } = await cdp.send('Page.getFrameTree');
|
|
116
|
+
let origin;
|
|
117
|
+
try {
|
|
118
|
+
const url = new URL(frameTree?.frame?.url);
|
|
119
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
|
|
120
|
+
origin = url.origin;
|
|
121
|
+
} catch { throw new UsageError('Selected page is not ready. Wait for navigation and run browser-tabs again.'); }
|
|
122
|
+
const { executionContextId } = await cdp.send('Page.createIsolatedWorld', { frameId: frameTree.frame.id, worldName: 'rtxexec-login' });
|
|
123
|
+
const values = await resolver({ references: [plan.reference], command: 'rtxexec', browser: { origin, targetId: target.id } }, env);
|
|
124
|
+
const credential = values[0];
|
|
125
|
+
if (typeof credential?.username !== 'string' || !credential.username || typeof credential.password !== 'string' || !credential.password || !Array.isArray(credential.allowedOrigins) || !credential.allowedOrigins.includes(origin)) throw new UsageError('RealTimeX did not authorize a Login credential for this website.');
|
|
126
|
+
const result = await cdp.send('Runtime.callFunctionOn', {
|
|
127
|
+
executionContextId, functionDeclaration: fillLogin.toString(), returnByValue: true,
|
|
128
|
+
arguments: [credential.username, credential.password, { username: plan['username-selector'], password: plan['password-selector'], submit: plan['submit-selector'] }, origin].map((value) => ({ value })),
|
|
129
|
+
});
|
|
130
|
+
if (result.exceptionDetails || !['filled', 'submitted'].includes(result.result?.value?.status)) throw new UsageError('Login was not completed. Check the selected page, origin and visible form selectors; values were not printed.');
|
|
131
|
+
return { status: result.result.value.status, targetId: target.id, origin };
|
|
132
|
+
} finally { cdp.close(); }
|
|
133
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseBrowserArguments, runBrowser } from "./browser.js";
|
|
1
2
|
import { UsageError } from "./error.js";
|
|
2
3
|
import { spawn } from 'node:child_process';
|
|
3
4
|
import { constants } from 'node:os';
|
|
@@ -14,6 +15,14 @@ Usage: rtxexec [bindings] -- executable [arguments]
|
|
|
14
15
|
--secret alias=secret://name Substitute {{alias}} inside argument values
|
|
15
16
|
--stdin secret://name Write the exact value to child stdin, then close it
|
|
16
17
|
|
|
18
|
+
Browser use (Node.js 22+, existing local CDP browser):
|
|
19
|
+
rtxexec browser-tabs --cdp <port>
|
|
20
|
+
rtxexec browser-login secret://name --cdp <port> --tab <CDP-target-id>
|
|
21
|
+
--username-selector <css> --password-selector <css> [--submit-selector <css>]
|
|
22
|
+
Omit either field selector for a multi-step login. No navigation or screenshots.
|
|
23
|
+
Supports top-level forms; use agent-browser to prepare the page first.
|
|
24
|
+
Filled/submitted does not mean authenticated: verify a non-secret success state.
|
|
25
|
+
|
|
17
26
|
Manage secrets with realtimex-pp-cli or Settings > Secrets.
|
|
18
27
|
Requires the running app and its terminal-session environment. No secret cache.
|
|
19
28
|
Runs executables directly, without a shell. Output is UTF-8 with known values
|
|
@@ -58,6 +67,11 @@ export async function execute(plan, injected, { stdout = process.stdout, stderr
|
|
|
58
67
|
|
|
59
68
|
export async function main(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, resolver = resolveSecrets } = {}) {
|
|
60
69
|
try {
|
|
70
|
+
if (['browser-tabs', 'browser-login'].includes(argv[0])) {
|
|
71
|
+
const result = await runBrowser(parseBrowserArguments(argv), env, { resolver });
|
|
72
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
61
75
|
const plan = parseArguments(argv);
|
|
62
76
|
if (plan.help) { stdout.write(help); return 0; }
|
|
63
77
|
if (plan.version) { stdout.write(`${JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version}\n`); return 0; }
|
package/src/client.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function resolveSecrets(plan, env = process.env, fetchImpl = fetch)
|
|
|
23
23
|
response = await fetchImpl(url, {
|
|
24
24
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
25
25
|
headers: { Authorization: `RealtimeX-Terminal ${token}`, 'Content-Type': 'application/json' },
|
|
26
|
-
body: JSON.stringify({ references: plan.references, executable: path.win32.basename(path.basename(plan.command)) }),
|
|
26
|
+
body: JSON.stringify({ references: plan.references, executable: path.win32.basename(path.basename(plan.command)), ...(plan.browser ? { browser: plan.browser } : {}) }),
|
|
27
27
|
});
|
|
28
28
|
} catch { throw new UsageError('Could not reach RealTimeX. Check that the app is running; no command was launched.'); }
|
|
29
29
|
let text = '';
|
|
@@ -43,6 +43,9 @@ export async function resolveSecrets(plan, env = process.env, fetchImpl = fetch)
|
|
|
43
43
|
try { body = JSON.parse(text); } catch { throw new UsageError('Invalid RealTimeX response; no command was launched.'); }
|
|
44
44
|
if (!response.ok || body.success !== true) {
|
|
45
45
|
const messages = {
|
|
46
|
+
SECRET_ORIGIN_DENIED: 'This login is not allowed on the selected website.',
|
|
47
|
+
SECRET_LOGIN_REQUIRED: 'Select a Login credential for browser login.',
|
|
48
|
+
SECRET_BROWSER_REQUIRED: 'Use rtxexec browser-login for Login credentials.',
|
|
46
49
|
SECRET_NOT_FOUND: 'A referenced secret does not exist.',
|
|
47
50
|
SECRET_SCOPE_DENIED: 'A referenced secret is not available in this workspace.',
|
|
48
51
|
SECRET_DISABLED: 'A referenced secret is disabled.',
|