@realtimex/rtxexec 0.1.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 +43 -0
- package/bin/rtxexec.js +3 -0
- package/package.json +13 -0
- package/src/arguments.js +46 -0
- package/src/cli.js +73 -0
- package/src/client.js +55 -0
- package/src/error.js +1 -0
- package/src/mask.js +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# rtxexec
|
|
2
|
+
|
|
3
|
+
Run local commands with secrets stored in the RealTimeX app. Node.js 20+;
|
|
4
|
+
macOS, Linux, and Windows. Install with `npm install -g @realtimex/rtxexec`.
|
|
5
|
+
|
|
6
|
+
Create/manage secrets in Settings > Secrets or with the moderator
|
|
7
|
+
`realtimex-pp-cli`. This package deliberately has no vault-management commands.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
rtxexec --env GH_TOKEN=secret://github-token -- gh issue list
|
|
11
|
+
rtxexec --stdin secret://registry-token -- docker login registry.example.com --username alice --password-stdin
|
|
12
|
+
rtxexec --secret token=secret://github-token -- curl -H 'Authorization: Bearer {{token}}' https://api.github.com/user
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run inside a RealTimeX terminal session. The app supplies
|
|
16
|
+
`REALTIMEX_TERMINAL_SESSION_TOKEN` and `REALTIMEX_BASE_URL` (ending in `/cli`).
|
|
17
|
+
`SERVER_URL` is a fallback. Connections are local only and never follow redirects.
|
|
18
|
+
Workspace authorization comes from the server's authenticated session, not a
|
|
19
|
+
caller-provided `RTX_WORKSPACE_SLUG`. Missing/disabled/out-of-scope values fail
|
|
20
|
+
before the child starts. Secrets are retrieved anew; there is no local cache.
|
|
21
|
+
|
|
22
|
+
The optional `run` subcommand is also accepted. Bindings precede `--`; everything after it is the executable and literal argument
|
|
23
|
+
array. Placeholder expansion happens once, inside arguments, with no shell eval.
|
|
24
|
+
Quote placeholders for your shell (single quotes in Bash, zsh, and PowerShell).
|
|
25
|
+
Percent-encode legacy names containing spaces in secret references. Environment
|
|
26
|
+
bindings affect only the child. `--stdin` sends the exact value without adding a
|
|
27
|
+
newline and closes stdin; without it, stdin is inherited.
|
|
28
|
+
|
|
29
|
+
The child receives the real value. Known plaintext, URI-encoded, JSON-escaped,
|
|
30
|
+
and base64 values are masked in UTF-8 stdout/stderr, including split chunks.
|
|
31
|
+
This reduces accidental disclosure; it does not prevent arbitrary child programs
|
|
32
|
+
from saving or transmitting secrets. Argument injection can expose values in
|
|
33
|
+
process inspection. Do not ask agents to retrieve raw secrets or bypass masking.
|
|
34
|
+
|
|
35
|
+
Exit codes are propagated. Signal exits use 128 + signal number. POSIX signals
|
|
36
|
+
are forwarded to the child process group; Windows uses Node's child termination
|
|
37
|
+
behavior and cannot promise POSIX process-group semantics. Output is piped, not a
|
|
38
|
+
PTY: full-screen tools and programs requiring an interactive TTY are unsupported.
|
|
39
|
+
Use native executables on Windows; invoke JavaScript CLIs as `node path/to/cli.js`
|
|
40
|
+
when the installed launcher is a .cmd/.bat file. Shell pipelines must be composed
|
|
41
|
+
explicitly; the wrapper does not interpret shell syntax.
|
|
42
|
+
|
|
43
|
+
`npm test` runs isolated fixtures. `npm pack --dry-run` validates package contents.
|
package/bin/rtxexec.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@realtimex/rtxexec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run commands with secrets from the RealTimeX app vault",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "rtxexec": "bin/rtxexec.js" },
|
|
7
|
+
"files": ["bin", "src", "README.md"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"scripts": { "test": "node --test" },
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"publishConfig": { "access": "public" },
|
|
12
|
+
"repository": { "type": "git", "url": "git+https://github.com/therealtimex/realtimex-sdk.git", "directory": "rtxexec" }
|
|
13
|
+
}
|
package/src/arguments.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { UsageError } from "./error.js";
|
|
2
|
+
const aliasPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3
|
+
const referencePattern = /^secret:\/\/.+$/;
|
|
4
|
+
|
|
5
|
+
export function parseArguments(argv) {
|
|
6
|
+
if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) return { help: true };
|
|
7
|
+
if (argv.length === 1 && argv[0] === '--version') return { version: true };
|
|
8
|
+
const start = argv[0] === 'run' ? 1 : 0;
|
|
9
|
+
if (!['--env', '--secret', '--stdin'].includes(argv[start])) throw new UsageError('Use rtxexec [bindings] -- command [arguments]. Secret management belongs to realtimex-pp-cli.');
|
|
10
|
+
const separator = argv.indexOf('--');
|
|
11
|
+
if (separator < 0 || !argv[separator + 1]) throw new UsageError('Specify -- followed by an executable.');
|
|
12
|
+
const env = new Map(); const aliases = new Map(); let stdin;
|
|
13
|
+
for (let i = start; i < separator; i += 2) {
|
|
14
|
+
const option = argv[i]; const binding = argv[i + 1];
|
|
15
|
+
if (!['--env', '--secret', '--stdin'].includes(option) || i + 1 >= separator) throw new UsageError('Expected --env NAME=secret://name, --secret alias=secret://name, or --stdin secret://name.');
|
|
16
|
+
if (option === '--stdin') {
|
|
17
|
+
if (stdin || !referencePattern.test(binding)) throw new UsageError('Supply one secret reference for --stdin.');
|
|
18
|
+
stdin = binding; continue;
|
|
19
|
+
}
|
|
20
|
+
const equals = binding.indexOf('=');
|
|
21
|
+
const name = binding.slice(0, equals); const reference = binding.slice(equals + 1);
|
|
22
|
+
const target = option === '--env' ? env : aliases;
|
|
23
|
+
if (equals < 1 || !aliasPattern.test(name) || !referencePattern.test(reference) || target.has(name)) throw new UsageError('Invalid or duplicate secret binding.');
|
|
24
|
+
target.set(name, reference);
|
|
25
|
+
}
|
|
26
|
+
const references = [...new Set([...env.values(), ...aliases.values(), ...(stdin ? [stdin] : [])])];
|
|
27
|
+
if (!references.length || references.length > 32 || references.some((ref) => ref.length > 1024)) throw new UsageError('Bind between 1 and 32 secrets.');
|
|
28
|
+
const command = argv[separator + 1]; const args = argv.slice(separator + 2);
|
|
29
|
+
if (command.includes('{{') || command.includes('\0')) throw new UsageError('The executable cannot contain a secret placeholder.');
|
|
30
|
+
for (const arg of args) for (const match of arg.matchAll(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g)) {
|
|
31
|
+
if (!aliases.has(match[1])) throw new UsageError('An argument uses an undeclared secret placeholder.');
|
|
32
|
+
}
|
|
33
|
+
return { command, args, env, aliases, stdin, references };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function inject(plan, secrets, environment) {
|
|
37
|
+
const values = new Map(secrets.map(({ reference, value }) => [reference, value]));
|
|
38
|
+
for (const reference of plan.references) {
|
|
39
|
+
const value = values.get(reference);
|
|
40
|
+
if (typeof value !== 'string' || !value || value.includes('\0') || Buffer.byteLength(value) > 65536) throw new UsageError('The app returned an invalid or missing secret value.');
|
|
41
|
+
}
|
|
42
|
+
const env = { ...environment };
|
|
43
|
+
for (const [name, reference] of plan.env) env[name] = values.get(reference);
|
|
44
|
+
const args = plan.args.map((arg) => arg.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, (_, name) => values.get(plan.aliases.get(name))));
|
|
45
|
+
return { args, env, stdin: plan.stdin ? values.get(plan.stdin) : undefined, values: [...values.values()] };
|
|
46
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { UsageError } from "./error.js";
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { constants } from 'node:os';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { finished } from 'node:stream/promises';
|
|
6
|
+
import { parseArguments, inject } from './arguments.js';
|
|
7
|
+
import { resolveSecrets } from './client.js';
|
|
8
|
+
import { SecretMask } from './mask.js';
|
|
9
|
+
|
|
10
|
+
const help = `rtxexec — run commands using RealTimeX secrets
|
|
11
|
+
|
|
12
|
+
Usage: rtxexec [bindings] -- executable [arguments]
|
|
13
|
+
--env NAME=secret://name Inject a child environment variable
|
|
14
|
+
--secret alias=secret://name Substitute {{alias}} inside argument values
|
|
15
|
+
--stdin secret://name Write the exact value to child stdin, then close it
|
|
16
|
+
|
|
17
|
+
Manage secrets with realtimex-pp-cli or Settings > Secrets.
|
|
18
|
+
Requires the running app and its terminal-session environment. No secret cache.
|
|
19
|
+
Runs executables directly, without a shell. Output is UTF-8 with known values
|
|
20
|
+
masked; transformed output, files, network traffic, and child arguments may
|
|
21
|
+
expose secrets. Full-screen/TTY applications are not supported. On Windows,
|
|
22
|
+
use native executables or node with a script path, rather than .cmd/.bat files.
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export async function execute(plan, injected, { stdout = process.stdout, stderr = process.stderr, signalSource = process, spawnImpl = spawn } = {}) {
|
|
26
|
+
const child = spawnImpl(plan.command, injected.args, {
|
|
27
|
+
env: injected.env, shell: false, windowsHide: true,
|
|
28
|
+
detached: process.platform !== 'win32',
|
|
29
|
+
stdio: [injected.stdin === undefined ? 'inherit' : 'pipe', 'pipe', 'pipe'],
|
|
30
|
+
});
|
|
31
|
+
const outMask = new SecretMask(injected.values); const errMask = new SecretMask(injected.values);
|
|
32
|
+
child.stdout.pipe(outMask).pipe(stdout, { end: false });
|
|
33
|
+
child.stderr.pipe(errMask).pipe(stderr, { end: false });
|
|
34
|
+
const streams = Promise.all([finished(outMask), finished(errMask)]);
|
|
35
|
+
const forwards = new Map(['SIGINT', 'SIGTERM', 'SIGHUP'].map((signal) => [signal, () => {
|
|
36
|
+
try {
|
|
37
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
|
|
38
|
+
else child.kill(signal);
|
|
39
|
+
} catch { /* Child already exited. */ }
|
|
40
|
+
}]));
|
|
41
|
+
for (const [signal, handler] of forwards) signalSource.on(signal, handler);
|
|
42
|
+
try {
|
|
43
|
+
if (injected.stdin !== undefined) {
|
|
44
|
+
child.stdin.on('error', () => {}); // Programs may close stdin before consuming it.
|
|
45
|
+
child.stdin.end(injected.stdin);
|
|
46
|
+
}
|
|
47
|
+
const outcome = await new Promise((resolve) => {
|
|
48
|
+
child.once('error', (error) => resolve({ failed: error.code === 'ENOENT' ? 127 : 126 }));
|
|
49
|
+
child.once('close', (code, signal) => resolve({ code, signal }));
|
|
50
|
+
});
|
|
51
|
+
await streams;
|
|
52
|
+
if (outcome.failed) { stderr.write('rtxexec: could not launch executable. Check its installation and permissions.\n'); return outcome.failed; }
|
|
53
|
+
return outcome.signal ? 128 + (constants.signals[outcome.signal] || 1) : outcome.code ?? 1;
|
|
54
|
+
} finally {
|
|
55
|
+
for (const [signal, handler] of forwards) signalSource.removeListener(signal, handler);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function main(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, resolver = resolveSecrets } = {}) {
|
|
60
|
+
try {
|
|
61
|
+
const plan = parseArguments(argv);
|
|
62
|
+
if (plan.help) { stdout.write(help); return 0; }
|
|
63
|
+
if (plan.version) { stdout.write(`${JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version}\n`); return 0; }
|
|
64
|
+
const secrets = await resolver(plan, env);
|
|
65
|
+
const injected = inject(plan, secrets, env);
|
|
66
|
+
return await execute(plan, injected, { stdout, stderr });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// All expected messages are authored locally; never print fetch/spawn errors,
|
|
69
|
+
// which can contain authorization headers or expanded child arguments.
|
|
70
|
+
stderr.write(`rtxexec: ${error instanceof UsageError ? error.message : "Execution failed; no diagnostic containing secret values was printed."}\n`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { UsageError } from "./error.js";
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function resolveEndpoint(env) {
|
|
5
|
+
const base = env.REALTIMEX_BASE_URL || (env.SERVER_URL ? `${env.SERVER_URL.replace(/\/$/, '')}/cli` : '');
|
|
6
|
+
let url;
|
|
7
|
+
try { url = new URL(base); } catch { throw new UsageError('RealTimeX connection is missing. Run this command in a RealTimeX terminal session.'); }
|
|
8
|
+
if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.search || url.hash) {
|
|
9
|
+
throw new UsageError('rtxexec requires a local RealTimeX server URL.');
|
|
10
|
+
}
|
|
11
|
+
const prefix = url.pathname.replace(/\/$/, '');
|
|
12
|
+
if (!prefix.endsWith('/cli')) throw new UsageError('REALTIMEX_BASE_URL must end with /cli.');
|
|
13
|
+
url.pathname = `${prefix}/secrets/resolve`;
|
|
14
|
+
return url;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function resolveSecrets(plan, env = process.env, fetchImpl = fetch) {
|
|
18
|
+
const url = resolveEndpoint(env);
|
|
19
|
+
const token = env.REALTIMEX_TERMINAL_SESSION_TOKEN;
|
|
20
|
+
if (!token) throw new UsageError('No terminal session token. Run rtxexec inside RealTimeX.');
|
|
21
|
+
let response;
|
|
22
|
+
try {
|
|
23
|
+
response = await fetchImpl(url, {
|
|
24
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
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)) }),
|
|
27
|
+
});
|
|
28
|
+
} catch { throw new UsageError('Could not reach RealTimeX. Check that the app is running; no command was launched.'); }
|
|
29
|
+
let text = '';
|
|
30
|
+
try {
|
|
31
|
+
const reader = response.body.getReader(); const decoder = new TextDecoder(); let bytes = 0;
|
|
32
|
+
try {
|
|
33
|
+
for (;;) {
|
|
34
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
35
|
+
bytes += value.length;
|
|
36
|
+
if (bytes > 4 * 1024 * 1024) { await reader.cancel(); throw new UsageError(); }
|
|
37
|
+
text += decoder.decode(value, { stream: true });
|
|
38
|
+
}
|
|
39
|
+
text += decoder.decode();
|
|
40
|
+
} finally { reader.releaseLock(); }
|
|
41
|
+
} catch { throw new UsageError('Could not read the credential response; no command was launched.'); }
|
|
42
|
+
let body;
|
|
43
|
+
try { body = JSON.parse(text); } catch { throw new UsageError('Invalid RealTimeX response; no command was launched.'); }
|
|
44
|
+
if (!response.ok || body.success !== true) {
|
|
45
|
+
const messages = {
|
|
46
|
+
SECRET_NOT_FOUND: 'A referenced secret does not exist.',
|
|
47
|
+
SECRET_SCOPE_DENIED: 'A referenced secret is not available in this workspace.',
|
|
48
|
+
SECRET_DISABLED: 'A referenced secret is disabled.',
|
|
49
|
+
SECRET_UNDECRYPTABLE: 'A secret cannot be decrypted. Replace its value in Settings > Secrets.',
|
|
50
|
+
};
|
|
51
|
+
throw new UsageError(messages[body.code] || `RealTimeX denied secret resolution (HTTP ${response.status}). Check session authentication and secret settings.`);
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(body.secrets) || body.secrets.length !== plan.references.length || body.secrets.some((item) => !item || !plan.references.includes(item.reference))) throw new UsageError('Invalid RealTimeX credential response.');
|
|
54
|
+
return body.secrets;
|
|
55
|
+
}
|
package/src/error.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class UsageError extends Error {}
|
package/src/mask.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Transform } from 'node:stream';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
|
|
4
|
+
export function secretVariants(values) {
|
|
5
|
+
return [...new Set(values.filter(Boolean).flatMap((value) => [value, encodeURIComponent(value), Buffer.from(value).toString('base64'), JSON.stringify(value).slice(1, -1)]))].sort((a, b) => b.length - a.length);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Hold only a suffix that can still become a secret. A fixed tail is unnecessary
|
|
9
|
+
// and would delay ordinary short output and interactive-looking progress lines.
|
|
10
|
+
export class SecretMask extends Transform {
|
|
11
|
+
constructor(values) {
|
|
12
|
+
super(); this.secrets = secretVariants(values); this.pending = ''; this.decoder = new StringDecoder('utf8');
|
|
13
|
+
}
|
|
14
|
+
drain(final) {
|
|
15
|
+
let at = 0; let output = '';
|
|
16
|
+
while (at < this.pending.length) {
|
|
17
|
+
const tail = this.pending.slice(at);
|
|
18
|
+
if (!final && this.secrets.some((secret) => secret.length > tail.length && secret.startsWith(tail))) break;
|
|
19
|
+
const match = this.secrets.find((secret) => tail.startsWith(secret));
|
|
20
|
+
if (match) { output += '[redacted]'; at += match.length; }
|
|
21
|
+
else { output += this.pending[at]; at++; }
|
|
22
|
+
}
|
|
23
|
+
this.pending = this.pending.slice(at);
|
|
24
|
+
if (output) this.push(output);
|
|
25
|
+
}
|
|
26
|
+
_transform(chunk, encoding, callback) {
|
|
27
|
+
this.pending += this.decoder.write(chunk); this.drain(false); callback();
|
|
28
|
+
}
|
|
29
|
+
_flush(callback) {
|
|
30
|
+
this.pending += this.decoder.end(); this.drain(true); callback();
|
|
31
|
+
}
|
|
32
|
+
}
|