@realtimex/rtxexec 0.2.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 CHANGED
@@ -78,3 +78,78 @@ The destination/browser necessarily receives the values. Do not take snapshots,
78
78
  screenshots, recordings, DOM-value reads, or network traces while credentials are
79
79
  present. A page may display a username after login. This reduces accidental exposure;
80
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@realtimex/rtxexec",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run commands with secrets from the RealTimeX app vault",
5
5
  "type": "module",
6
6
  "bin": { "rtxexec": "bin/rtxexec.js" },
package/src/browser.js CHANGED
@@ -6,15 +6,24 @@ export function parseBrowserArguments(argv) {
6
6
  const options = {};
7
7
  let reference;
8
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'];
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
11
  for (; i < argv.length; i += 2) {
12
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];
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];
15
22
  }
16
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.');
17
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.');
18
27
  return { command, reference, ...options };
19
28
  }
20
29
 
@@ -105,6 +114,38 @@ export function fillLogin(username, password, selectors, expectedOrigin) {
105
114
  return { status: submit ? 'submitted' : 'filled' };
106
115
  }
107
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
+
108
149
  export async function runBrowser(plan, env, { targets = browserTargets, connect = connectCdp, resolver = resolveSecrets } = {}) {
109
150
  const pages = await targets(plan.cdp);
110
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 }; }) };
@@ -120,14 +161,19 @@ export async function runBrowser(plan, env, { targets = browserTargets, connect
120
161
  origin = url.origin;
121
162
  } catch { throw new UsageError('Selected page is not ready. Wait for navigation and run browser-tabs again.'); }
122
163
  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);
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);
124
166
  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.');
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];
126
172
  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 })),
173
+ executionContextId, functionDeclaration: (plan.command === 'browser-fill' ? fillFields : fillLogin).toString(), returnByValue: true,
174
+ arguments: args.map((value) => ({ value })),
129
175
  });
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.');
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.');
131
177
  return { status: result.result.value.status, targetId: target.id, origin };
132
178
  } finally { cdp.close(); }
133
179
  }
package/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { parseBrowserArguments, runBrowser } from "./browser.js";
2
+ import { parseSshArguments, runSsh } from "./ssh.js";
2
3
  import { UsageError } from "./error.js";
3
4
  import { spawn } from 'node:child_process';
4
5
  import { constants } from 'node:os';
@@ -19,10 +20,20 @@ Browser use (Node.js 22+, existing local CDP browser):
19
20
  rtxexec browser-tabs --cdp <port>
20
21
  rtxexec browser-login secret://name --cdp <port> --tab <CDP-target-id>
21
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).
22
28
  Omit either field selector for a multi-step login. No navigation or screenshots.
23
29
  Supports top-level forms; use agent-browser to prepare the page first.
24
30
  Filled/submitted does not mean authenticated: verify a non-secret success state.
25
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
+
26
37
  Manage secrets with realtimex-pp-cli or Settings > Secrets.
27
38
  Requires the running app and its terminal-session environment. No secret cache.
28
39
  Runs executables directly, without a shell. Output is UTF-8 with known values
@@ -67,11 +78,12 @@ export async function execute(plan, injected, { stdout = process.stdout, stderr
67
78
 
68
79
  export async function main(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, resolver = resolveSecrets } = {}) {
69
80
  try {
70
- if (['browser-tabs', 'browser-login'].includes(argv[0])) {
81
+ if (['browser-tabs', 'browser-login', 'browser-fill'].includes(argv[0])) {
71
82
  const result = await runBrowser(parseBrowserArguments(argv), env, { resolver });
72
83
  stdout.write(`${JSON.stringify(result)}\n`);
73
84
  return 0;
74
85
  }
86
+ if (argv[0] === "ssh") return await runSsh(parseSshArguments(argv), env, (plan, injected) => execute(plan, injected, { stdout, stderr }), { resolver });
75
87
  const plan = parseArguments(argv);
76
88
  if (plan.help) { stdout.write(help); return 0; }
77
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)), ...(plan.browser ? { browser: plan.browser } : {}) }),
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,8 +43,12 @@ 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.',
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.',
48
52
  SECRET_BROWSER_REQUIRED: 'Use rtxexec browser-login for Login credentials.',
49
53
  SECRET_NOT_FOUND: 'A referenced secret does not exist.',
50
54
  SECRET_SCOPE_DENIED: 'A referenced secret is not available in this workspace.',
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
+ }