@realtimex/rtxexec 0.1.0 → 0.3.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 +112 -0
- package/package.json +1 -1
- package/src/browser.js +179 -0
- package/src/cli.js +26 -0
- package/src/client.js +8 -1
- package/src/ssh.js +45 -0
package/README.md
CHANGED
|
@@ -41,3 +41,115 @@ 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.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
## Structured credential items
|
|
84
|
+
|
|
85
|
+
RealTimeX stores Login, Card, Identity, SSH key and Secure note items. Management
|
|
86
|
+
stays in Settings or the moderator CLI; rtxexec only uses saved values.
|
|
87
|
+
List metadata to discover `reference` and `fieldNames`. To select a field:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
rtxexec --env API_TOKEN=secret://service#apiToken -- program
|
|
91
|
+
rtxexec --stdin secret://note#notes -- program
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A Login can contain just a password, the built-in API key / Token (`apiToken`),
|
|
95
|
+
or custom fields; a username and website are optional for terminal use. New Login
|
|
96
|
+
defaults to password when present, otherwise `apiToken`; Secure note defaults to
|
|
97
|
+
notes. Existing scalar references keep their value. An explicit move into Password
|
|
98
|
+
or API key / Token preserves both default and `#value` references, including after
|
|
99
|
+
rotation.
|
|
100
|
+
Use explicit field references for Card, Identity and SSH items.
|
|
101
|
+
|
|
102
|
+
### Fill Card or Identity forms
|
|
103
|
+
|
|
104
|
+
Prepare a page with agent-browser, identify its visible top-level selectors,
|
|
105
|
+
and use the exact CDP target ID from `browser-tabs`:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
rtxexec browser-fill secret://card --cdp 9235 --tab TARGET_ID \
|
|
109
|
+
--field 'number=#card-number' --field 'securityCode=#cvv'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Only mapped fields are requested. The item's permitted origins and workspace
|
|
113
|
+
scope are enforced. Inputs, textareas and selects are supported; iframe forms
|
|
114
|
+
are not. This command never submits. Check non-secret status and perform any
|
|
115
|
+
submission separately within the authorized task. Avoid snapshots, recordings
|
|
116
|
+
and field-value reads while saved data is in the page. `browser-login` remains
|
|
117
|
+
available and now requests only the username/password fields selected.
|
|
118
|
+
|
|
119
|
+
### SSH and Git
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
rtxexec ssh secret://deploy-key -- ssh user@host
|
|
123
|
+
rtxexec ssh secret://deploy-key -- git fetch origin
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Requires OpenSSH (and Git for Git commands). The app unlocks the saved key for
|
|
127
|
+
this execution. rtxexec writes it to a restricted temporary directory, supplies
|
|
128
|
+
the path to SSH or Git, and removes the directory when the child finishes or
|
|
129
|
+
fails. Unix uses directory mode 0700 and file mode 0600; Windows requires icacls
|
|
130
|
+
to restrict the directory before writing. Existing host-key verification stays
|
|
131
|
+
in effect. SIGKILL or a machine crash may prevent cleanup. The CLI keeps no
|
|
132
|
+
persistent vault or secret cache. Output masking is best effort; this is an
|
|
133
|
+
accidental-disclosure safeguard, not a boundary against deliberate local access.
|
|
134
|
+
|
|
135
|
+
## Linked SSO logins
|
|
136
|
+
|
|
137
|
+
A Login can keep its normal username/password and link multiple saved Login items
|
|
138
|
+
for SSO. Management uses `ssoCredentialIds` (moderator flag
|
|
139
|
+
`--sso-credential-ids id1,id2`; an empty flag clears links). Names and descriptions
|
|
140
|
+
identify the provider and account; no separate provider instructions are needed.
|
|
141
|
+
|
|
142
|
+
Use agent-browser to choose the matching sign-in button and reach the provider.
|
|
143
|
+
Then explicitly select the linked Login:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
rtxexec browser-login secret://rtgit --sso secret://google \
|
|
147
|
+
--cdp 9235 --tab TARGET_ID \
|
|
148
|
+
--username-selector '#username' --password-selector '#password'
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The server checks both items' enabled state and workspace scope, and the provider's
|
|
152
|
+
allowed website origin. It records usage for both without copying values into the
|
|
153
|
+
site item. Omitting `--sso` uses normal credentials; selection never automatically
|
|
154
|
+
follows further links. Ask when the provider/account is ambiguous, and handle
|
|
155
|
+
already signed-in sessions or MFA through the normal browser workflow.
|
package/package.json
CHANGED
package/src/browser.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
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-tabs') reference = argv[i++];
|
|
10
|
+
const allowed = command === 'browser-tabs' ? ['cdp'] : command === 'browser-fill' ? ['cdp', 'tab', 'field', 'sso'] : ['cdp', 'tab', 'username-selector', 'password-selector', 'submit-selector', 'sso'];
|
|
11
|
+
for (; i < argv.length; i += 2) {
|
|
12
|
+
const key = argv[i]?.replace(/^--/, '');
|
|
13
|
+
if (!argv[i]?.startsWith('--') || !allowed.includes(key) || (key !== 'field' && options[key] !== undefined) || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new UsageError('Invalid browser option. See rtxexec --help.');
|
|
14
|
+
if (key === 'field') {
|
|
15
|
+
const value = argv[i + 1]; const split = value.indexOf('=');
|
|
16
|
+
const field = value.slice(0, split); const selector = value.slice(split + 1);
|
|
17
|
+
if (split < 1 || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(field) || !selector || selector.length > 4096 || ['constructor', 'prototype', '__proto__'].includes(field)) throw new UsageError('Use --field fieldName=selector.');
|
|
18
|
+
options.fields ||= [];
|
|
19
|
+
if (options.fields.some((entry) => entry.name === field || entry.selector === selector) || options.fields.length >= 32) throw new UsageError('Use up to 32 distinct fields and selectors.');
|
|
20
|
+
options.fields.push({ name: field, selector });
|
|
21
|
+
} else options[key] = argv[i + 1];
|
|
22
|
+
}
|
|
23
|
+
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.');
|
|
24
|
+
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.');
|
|
25
|
+
if (command === 'browser-fill' && (!reference?.startsWith('secret://') || reference.includes('#') || !/^[a-zA-Z0-9_-]{1,128}$/.test(options.tab || '') || !options.fields?.length)) throw new UsageError('Provide an item reference, --tab and at least one --field name=selector.');
|
|
26
|
+
if (options.sso && (!options.sso.startsWith('secret://') || options.sso.includes('#') || options.sso.length > 1024)) throw new UsageError('Use --sso secret://provider with a linked Login item.');
|
|
27
|
+
return { command, reference, ...options };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function browserTargets(port, fetchImpl = fetch) {
|
|
31
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/json/list`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
|
|
32
|
+
if (!response.ok) throw new UsageError('Could not list browser tabs.');
|
|
33
|
+
let targets;
|
|
34
|
+
try { targets = await response.json(); } catch { throw new UsageError('Invalid CDP target response.'); }
|
|
35
|
+
if (!Array.isArray(targets)) throw new UsageError('Invalid CDP target response.');
|
|
36
|
+
return targets.filter((target) => {
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(target.url);
|
|
39
|
+
return target.type === 'page' && ['http:', 'https:'].includes(url.protocol) && url.pathname !== '/cli-browser/index.html';
|
|
40
|
+
} catch { return false; }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function connectCdp(address, port, WebSocketImpl = globalThis.WebSocket) {
|
|
45
|
+
if (!WebSocketImpl) throw new UsageError('Browser login requires Node.js 22 or newer.');
|
|
46
|
+
const url = new URL(address);
|
|
47
|
+
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.');
|
|
48
|
+
const socket = new WebSocketImpl(url);
|
|
49
|
+
const pending = new Map(); let nextId = 0;
|
|
50
|
+
const rejectAll = () => { for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('CDP connection ended')); } pending.clear(); };
|
|
51
|
+
socket.addEventListener('close', rejectAll);
|
|
52
|
+
socket.addEventListener('error', rejectAll);
|
|
53
|
+
socket.addEventListener('message', ({ data }) => {
|
|
54
|
+
let message; try { message = JSON.parse(data); } catch { return; }
|
|
55
|
+
const entry = pending.get(message.id); if (!entry) return;
|
|
56
|
+
pending.delete(message.id); clearTimeout(entry.timer);
|
|
57
|
+
if (message.error) entry.reject(new Error('CDP operation failed'));
|
|
58
|
+
else entry.resolve(message.result);
|
|
59
|
+
});
|
|
60
|
+
await new Promise((resolve, reject) => {
|
|
61
|
+
const timer = setTimeout(() => { socket.close(); reject(new Error('CDP connection timeout')); }, 5000);
|
|
62
|
+
socket.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
63
|
+
socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed')); }, { once: true });
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
send(method, params = {}) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const id = ++nextId;
|
|
69
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP operation timeout')); }, 10000);
|
|
70
|
+
pending.set(id, { resolve, reject, timer });
|
|
71
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
close() { rejectAll(); socket.close(); },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fixed code executed in an isolated world. Values travel only as CDP arguments,
|
|
79
|
+
// never interpolated into source or returned to the caller. Top-level forms only.
|
|
80
|
+
export function fillLogin(username, password, selectors, expectedOrigin) {
|
|
81
|
+
const sameOrigin = () => location.origin === expectedOrigin;
|
|
82
|
+
const find = (selector) => { if (!selector) return null; const matches = document.querySelectorAll(selector); return matches.length === 1 ? matches[0] : null; };
|
|
83
|
+
const user = find(selectors.username); const pass = find(selectors.password); const submit = find(selectors.submit);
|
|
84
|
+
const fields = [[user, username, selectors.username], [pass, password, selectors.password]];
|
|
85
|
+
const safeForm = (element) => !element?.form || new URL(element.form.action || location.href).origin === expectedOrigin;
|
|
86
|
+
const usable = (element) => {
|
|
87
|
+
if (!element || element.disabled || element.matches(':disabled') || element.getClientRects().length === 0) return false;
|
|
88
|
+
const style = getComputedStyle(element);
|
|
89
|
+
if (style.visibility !== 'visible') return false;
|
|
90
|
+
for (let node = element; node; node = node.parentElement) {
|
|
91
|
+
if (Number(getComputedStyle(node).opacity) === 0) return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
};
|
|
95
|
+
if (!sameOrigin()) return { error: 'origin' };
|
|
96
|
+
for (const [element, , selector] of fields) {
|
|
97
|
+
if (!selector) continue;
|
|
98
|
+
if (!(element instanceof HTMLInputElement) || element.readOnly || !usable(element) || !safeForm(element)) return { error: 'field' };
|
|
99
|
+
}
|
|
100
|
+
if (pass && pass.type !== 'password') return { error: 'password-field' };
|
|
101
|
+
if (selectors.submit && (!usable(submit) || !safeForm(submit) || (submit.formAction && new URL(submit.formAction).origin !== expectedOrigin))) return { error: 'submit' };
|
|
102
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
103
|
+
for (const [element, value, selector] of fields) {
|
|
104
|
+
if (!selector) continue;
|
|
105
|
+
if (!sameOrigin() || !element.isConnected || !usable(element) || element.readOnly || (element === pass && element.type !== "password") || !safeForm(element)) return { error: 'changed' };
|
|
106
|
+
setter.call(element, value);
|
|
107
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
108
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
109
|
+
}
|
|
110
|
+
if (submit) {
|
|
111
|
+
if (!sameOrigin() || !submit.isConnected || !usable(submit) || !safeForm(submit) || (submit.formAction && new URL(submit.formAction).origin !== expectedOrigin)) return { error: 'changed' };
|
|
112
|
+
submit.click();
|
|
113
|
+
}
|
|
114
|
+
return { status: submit ? 'submitted' : 'filled' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
// Fill-only: submission remains an explicit, separate browser action.
|
|
119
|
+
export function fillFields(values, selectors, expectedOrigin) {
|
|
120
|
+
const usable = (element) => {
|
|
121
|
+
if (!element || !element.isConnected || element.disabled || element.matches(':disabled') || element.readOnly || !element.getClientRects().length) return false;
|
|
122
|
+
if (getComputedStyle(element).visibility !== 'visible') return false;
|
|
123
|
+
for (let node = element; node; node = node.parentElement) if (Number(getComputedStyle(node).opacity) === 0) return false;
|
|
124
|
+
return !element.form || new URL(element.form.action || location.href).origin === expectedOrigin;
|
|
125
|
+
};
|
|
126
|
+
if (location.origin !== expectedOrigin) return { error: 'origin' };
|
|
127
|
+
const fields = [];
|
|
128
|
+
for (const { name, selector } of selectors) {
|
|
129
|
+
const matches = document.querySelectorAll(selector);
|
|
130
|
+
const element = matches.length === 1 ? matches[0] : null;
|
|
131
|
+
if (!usable(element) || !(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement)) return { error: 'field' };
|
|
132
|
+
if (element instanceof HTMLInputElement && ['file', 'hidden', 'checkbox', 'radio', 'button', 'submit', 'reset', 'image'].includes(element.type)) return { error: 'field' };
|
|
133
|
+
if (name === 'password' && (!(element instanceof HTMLInputElement) || element.type !== 'password')) return { error: 'password-field' };
|
|
134
|
+
if (element instanceof HTMLSelectElement && !Array.from(element.options).some(option => option.value === values[name] && !option.disabled)) return { error: 'option' };
|
|
135
|
+
if (fields.some(entry => entry.element === element)) return { error: 'duplicate' };
|
|
136
|
+
fields.push({ element, name, type: element.type });
|
|
137
|
+
}
|
|
138
|
+
for (const { element, name, type } of fields) {
|
|
139
|
+
if (location.origin !== expectedOrigin || !usable(element) || element.type !== type) return { error: 'changed' };
|
|
140
|
+
const prototype = element instanceof HTMLInputElement ? HTMLInputElement.prototype : element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLTextAreaElement.prototype;
|
|
141
|
+
Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, values[name]);
|
|
142
|
+
if (element.value !== values[name]) return { error: 'value' };
|
|
143
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
144
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
145
|
+
}
|
|
146
|
+
return { status: 'filled' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function runBrowser(plan, env, { targets = browserTargets, connect = connectCdp, resolver = resolveSecrets } = {}) {
|
|
150
|
+
const pages = await targets(plan.cdp);
|
|
151
|
+
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 }; }) };
|
|
152
|
+
const target = pages.find(({ id }) => id === plan.tab);
|
|
153
|
+
if (!target) throw new UsageError('Selected browser tab is unavailable. Run browser-tabs again.');
|
|
154
|
+
const cdp = await connect(target.webSocketDebuggerUrl, plan.cdp);
|
|
155
|
+
try {
|
|
156
|
+
const { frameTree } = await cdp.send('Page.getFrameTree');
|
|
157
|
+
let origin;
|
|
158
|
+
try {
|
|
159
|
+
const url = new URL(frameTree?.frame?.url);
|
|
160
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
|
|
161
|
+
origin = url.origin;
|
|
162
|
+
} catch { throw new UsageError('Selected page is not ready. Wait for navigation and run browser-tabs again.'); }
|
|
163
|
+
const { executionContextId } = await cdp.send('Page.createIsolatedWorld', { frameId: frameTree.frame.id, worldName: 'rtxexec-login' });
|
|
164
|
+
const requested = plan.command === 'browser-fill' ? plan.fields.map(entry => entry.name) : ['username', 'password'].filter(name => plan[`${name}-selector`]);
|
|
165
|
+
const values = await resolver({ references: [plan.reference], command: 'rtxexec', browser: { origin, targetId: target.id, fields: requested, ...(plan.sso ? { ssoReference: plan.sso } : {}) } }, env);
|
|
166
|
+
const credential = values[0];
|
|
167
|
+
const fields = credential?.fields || credential;
|
|
168
|
+
if (requested.some(name => typeof fields?.[name] !== 'string' || !fields[name]) || !Array.isArray(credential?.allowedOrigins) || !credential.allowedOrigins.includes(origin)) throw new UsageError('RealTimeX did not authorize the requested fields for this website.');
|
|
169
|
+
const args = plan.command === 'browser-fill'
|
|
170
|
+
? [Object.fromEntries(requested.map(name => [name, fields[name]])), plan.fields, origin]
|
|
171
|
+
: [fields.username || '', fields.password || '', { username: plan['username-selector'], password: plan['password-selector'], submit: plan['submit-selector'] }, origin];
|
|
172
|
+
const result = await cdp.send('Runtime.callFunctionOn', {
|
|
173
|
+
executionContextId, functionDeclaration: (plan.command === 'browser-fill' ? fillFields : fillLogin).toString(), returnByValue: true,
|
|
174
|
+
arguments: args.map((value) => ({ value })),
|
|
175
|
+
});
|
|
176
|
+
if (result.exceptionDetails || !['filled', 'submitted'].includes(result.result?.value?.status)) throw new UsageError('Form fill was not completed. Check the selected page, origin and visible form selectors; values were not printed.');
|
|
177
|
+
return { status: result.result.value.status, targetId: target.id, origin };
|
|
178
|
+
} finally { cdp.close(); }
|
|
179
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseBrowserArguments, runBrowser } from "./browser.js";
|
|
2
|
+
import { parseSshArguments, runSsh } from "./ssh.js";
|
|
1
3
|
import { UsageError } from "./error.js";
|
|
2
4
|
import { spawn } from 'node:child_process';
|
|
3
5
|
import { constants } from 'node:os';
|
|
@@ -14,6 +16,24 @@ Usage: rtxexec [bindings] -- executable [arguments]
|
|
|
14
16
|
--secret alias=secret://name Substitute {{alias}} inside argument values
|
|
15
17
|
--stdin secret://name Write the exact value to child stdin, then close it
|
|
16
18
|
|
|
19
|
+
Browser use (Node.js 22+, existing local CDP browser):
|
|
20
|
+
rtxexec browser-tabs --cdp <port>
|
|
21
|
+
rtxexec browser-login secret://name --cdp <port> --tab <CDP-target-id>
|
|
22
|
+
--username-selector <css> --password-selector <css> [--submit-selector <css>]
|
|
23
|
+
rtxexec browser-fill secret://name --cdp <port> --tab <CDP-target-id>
|
|
24
|
+
--field number="#card-number" --field securityCode="#cvv"
|
|
25
|
+
Add --sso secret://provider to explicitly use a linked SSO Login.
|
|
26
|
+
browser-fill fills only; use a separate authorized action to submit.
|
|
27
|
+
Named terminal fields use secret://name#field (for example #apiToken).
|
|
28
|
+
Omit either field selector for a multi-step login. No navigation or screenshots.
|
|
29
|
+
Supports top-level forms; use agent-browser to prepare the page first.
|
|
30
|
+
Filled/submitted does not mean authenticated: verify a non-secret success state.
|
|
31
|
+
|
|
32
|
+
SSH/Git use:
|
|
33
|
+
rtxexec ssh secret://name -- ssh user@host
|
|
34
|
+
rtxexec ssh secret://name -- git fetch origin
|
|
35
|
+
Requires OpenSSH. Creates an owner-only temporary key, removed after exit.
|
|
36
|
+
|
|
17
37
|
Manage secrets with realtimex-pp-cli or Settings > Secrets.
|
|
18
38
|
Requires the running app and its terminal-session environment. No secret cache.
|
|
19
39
|
Runs executables directly, without a shell. Output is UTF-8 with known values
|
|
@@ -58,6 +78,12 @@ export async function execute(plan, injected, { stdout = process.stdout, stderr
|
|
|
58
78
|
|
|
59
79
|
export async function main(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, resolver = resolveSecrets } = {}) {
|
|
60
80
|
try {
|
|
81
|
+
if (['browser-tabs', 'browser-login', 'browser-fill'].includes(argv[0])) {
|
|
82
|
+
const result = await runBrowser(parseBrowserArguments(argv), env, { resolver });
|
|
83
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (argv[0] === "ssh") return await runSsh(parseSshArguments(argv), env, (plan, injected) => execute(plan, injected, { stdout, stderr }), { resolver });
|
|
61
87
|
const plan = parseArguments(argv);
|
|
62
88
|
if (plan.help) { stdout.write(help); return 0; }
|
|
63
89
|
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 } : {}), ...(plan.ssh ? { ssh: true } : {}) }),
|
|
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,13 @@ 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 item is not allowed on the selected website.',
|
|
47
|
+
SECRET_LOGIN_REQUIRED: 'Select a Login, Card or Identity item for browser fill.',
|
|
48
|
+
SECRET_FIELD_NOT_FOUND: 'The selected field has no saved value.',
|
|
49
|
+
SECRET_SSH_REQUIRED: 'Select an SSH key item.',
|
|
50
|
+
SECRET_SSO_INVALID: 'Select a saved Login linked to this item for SSO.',
|
|
51
|
+
SECRET_SSO_DENIED: 'The linked SSO Login is disabled or unavailable in this workspace.',
|
|
52
|
+
SECRET_BROWSER_REQUIRED: 'Use rtxexec browser-login for Login credentials.',
|
|
46
53
|
SECRET_NOT_FOUND: 'A referenced secret does not exist.',
|
|
47
54
|
SECRET_SCOPE_DENIED: 'A referenced secret is not available in this workspace.',
|
|
48
55
|
SECRET_DISABLED: 'A referenced secret is disabled.',
|
package/src/ssh.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdtemp, writeFile, chmod, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir, userInfo } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { UsageError } from './error.js';
|
|
7
|
+
import { resolveSecrets } from './client.js';
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
const quote = value => "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
|
11
|
+
export function parseSshArguments(argv) {
|
|
12
|
+
if (!argv[1]?.startsWith('secret://') || argv[1].includes('#') || argv[2] !== '--' || !argv[3]) throw new UsageError('Use rtxexec ssh secret://name -- ssh|git [arguments].');
|
|
13
|
+
const name = path.win32.basename(path.basename(argv[3])).replace(/\.exe$/i, '').toLowerCase();
|
|
14
|
+
if (!['ssh', 'git'].includes(name)) throw new UsageError('SSH key execution supports ssh and git.');
|
|
15
|
+
return { references: [argv[1]], command: argv[3], args: argv.slice(4), ssh: true, sshCommand: name };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runSsh(plan, env, execute, { resolver = resolveSecrets, root = tmpdir() } = {}) {
|
|
19
|
+
const [credential] = await resolver(plan, env);
|
|
20
|
+
if (typeof credential?.privateKey !== 'string' || !credential.privateKey.includes('-----BEGIN OPENSSH PRIVATE KEY-----') || Buffer.byteLength(credential.privateKey) > 65536) throw new UsageError('RealTimeX did not provide a usable SSH key.');
|
|
21
|
+
let directory;
|
|
22
|
+
try {
|
|
23
|
+
directory = await mkdtemp(path.join(root, 'rtxexec-ssh-'));
|
|
24
|
+
if (process.platform === 'win32') {
|
|
25
|
+
// Secure the empty directory before writing material. Fail closed if ACLs
|
|
26
|
+
// cannot be set (e.g. a non-NTFS temp directory).
|
|
27
|
+
await run('icacls.exe', [directory, '/inheritance:r', '/grant:r', `${userInfo().username}:(OI)(CI)F`], { windowsHide: true });
|
|
28
|
+
} else await chmod(directory, 0o700);
|
|
29
|
+
const keyPath = path.join(directory, 'identity');
|
|
30
|
+
await writeFile(keyPath, credential.privateKey, { mode: 0o600, flag: 'wx' });
|
|
31
|
+
const sshArgs = ['-i', keyPath, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none'];
|
|
32
|
+
const childEnv = { ...env };
|
|
33
|
+
let args = plan.args;
|
|
34
|
+
if (plan.sshCommand === 'ssh') args = [...sshArgs, ...args];
|
|
35
|
+
else {
|
|
36
|
+
// Git consumes this command through its own shell. Only generated paths
|
|
37
|
+
// and fixed options are included, with POSIX quoting (also used by Git for Windows).
|
|
38
|
+
childEnv.GIT_SSH_COMMAND = ['ssh', ...sshArgs.map(value => process.platform === 'win32' ? value.replace(/\\/g, '/') : value)].map(quote).join(' ');
|
|
39
|
+
childEnv.GIT_SSH_VARIANT = 'ssh';
|
|
40
|
+
}
|
|
41
|
+
return await execute(plan, { args, env: childEnv, stdin: undefined, values: [credential.privateKey] });
|
|
42
|
+
} finally {
|
|
43
|
+
if (directory) await rm(directory, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|