@phnx-labs/agents-cli 1.20.28 → 1.20.29
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/dist/commands/exec.js +22 -10
- package/dist/commands/secrets.js +93 -6
- package/dist/commands/sessions.js +1 -0
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/index.js +2 -1
- package/dist/lib/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/session/db.d.ts +1 -0
- package/dist/lib/session/db.js +4 -4
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +228 -0
- package/dist/lib/session/parse.d.ts +7 -0
- package/dist/lib/session/parse.js +110 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/package.json +1 -1
package/dist/commands/exec.js
CHANGED
|
@@ -324,11 +324,12 @@ export function registerRunCommand(program) {
|
|
|
324
324
|
process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens\n`));
|
|
325
325
|
process.exit(loopExitCode(result.stoppedBy));
|
|
326
326
|
}
|
|
327
|
-
const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
|
|
327
|
+
const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
|
|
328
328
|
import('../lib/exec.js'),
|
|
329
329
|
import('../lib/agents.js'),
|
|
330
330
|
import('../lib/profiles.js'),
|
|
331
331
|
import('../lib/secrets/bundles.js'),
|
|
332
|
+
import('../lib/secrets/remote.js'),
|
|
332
333
|
import('../lib/rotate.js'),
|
|
333
334
|
import('../lib/versions.js'),
|
|
334
335
|
import('../lib/plugins.js'),
|
|
@@ -776,17 +777,28 @@ export function registerRunCommand(program) {
|
|
|
776
777
|
// ones. Any resolution failure (missing keychain item, blocked exec ref)
|
|
777
778
|
// aborts before spawn so the agent never sees a partial env.
|
|
778
779
|
let secretsEnv = {};
|
|
779
|
-
for (const
|
|
780
|
+
for (const bundleRef of options.secrets) {
|
|
780
781
|
try {
|
|
781
|
-
const { bundle,
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
782
|
+
const { bundle: bundleName, host } = splitBundleRef(bundleRef);
|
|
783
|
+
if (host) {
|
|
784
|
+
// Remote bundle (`bundle@host`): resolve over SSH and inject
|
|
785
|
+
// ephemerally — values never touch this machine's keychain or disk.
|
|
786
|
+
const target = await resolveSshTarget(host);
|
|
787
|
+
const bundleEnv = await remoteResolveEnv(target, bundleName);
|
|
788
|
+
console.log(chalk.gray(`[secrets] Resolved ${bundleName}@${host}: ${Object.keys(bundleEnv).length} keys (remote, ephemeral)`));
|
|
789
|
+
secretsEnv = { ...secretsEnv, ...bundleEnv };
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, { caller: `agent ${agent}` });
|
|
793
|
+
const entries = describeBundle(bundle);
|
|
794
|
+
const counts = {};
|
|
795
|
+
for (const e of entries) {
|
|
796
|
+
counts[e.kind] = (counts[e.kind] || 0) + 1;
|
|
797
|
+
}
|
|
798
|
+
const breakdown = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ');
|
|
799
|
+
console.log(chalk.gray(`[secrets] Resolved ${bundleName}: ${entries.length} keys (${breakdown})`));
|
|
800
|
+
secretsEnv = { ...secretsEnv, ...bundleEnv };
|
|
786
801
|
}
|
|
787
|
-
const breakdown = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ');
|
|
788
|
-
console.log(chalk.gray(`[secrets] Resolved ${bundleName}: ${entries.length} keys (${breakdown})`));
|
|
789
|
-
secretsEnv = { ...secretsEnv, ...bundleEnv };
|
|
790
802
|
}
|
|
791
803
|
catch (err) {
|
|
792
804
|
console.error(chalk.red(err.message));
|
package/dist/commands/secrets.js
CHANGED
|
@@ -10,6 +10,7 @@ import chalk from 'chalk';
|
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import { spawnSync } from 'child_process';
|
|
12
12
|
import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
|
|
13
|
+
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, resolveSshTarget, } from '../lib/secrets/remote.js';
|
|
13
14
|
import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
14
15
|
import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
15
16
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
@@ -160,6 +161,47 @@ export function bundleEnvToDotenv(env) {
|
|
|
160
161
|
}
|
|
161
162
|
return lines.join('\n') + '\n';
|
|
162
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Browse `agents secrets <args>` on one or more remote hosts over SSH and print
|
|
166
|
+
* each host's stdout verbatim (lossless — no parsing). With >1 host the output
|
|
167
|
+
* is grouped under a `── <host> ──` header. `tty` forces an interactive ssh
|
|
168
|
+
* session (run sequentially) so a remote Touch-ID / passphrase prompt can
|
|
169
|
+
* surface (e.g. `view --reveal`); otherwise hosts are queried in parallel.
|
|
170
|
+
* Exits non-zero if any host fails.
|
|
171
|
+
*/
|
|
172
|
+
async function browseRemote(targets, args, tty) {
|
|
173
|
+
const multi = targets.length > 1;
|
|
174
|
+
let failures = 0;
|
|
175
|
+
const render = (name, res) => {
|
|
176
|
+
if (multi)
|
|
177
|
+
console.log(chalk.bold.cyan(`\n── ${name} ──`));
|
|
178
|
+
if (res.code === 0) {
|
|
179
|
+
if (res.stdout)
|
|
180
|
+
process.stdout.write(res.stdout.endsWith('\n') ? res.stdout : `${res.stdout}\n`);
|
|
181
|
+
if (res.stderr.trim())
|
|
182
|
+
process.stderr.write(chalk.gray(res.stderr));
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
failures++;
|
|
186
|
+
const msg = (res.stderr || res.stdout || '').trim();
|
|
187
|
+
const why = res.timedOut ? 'timed out' : res.code === null ? 'ssh failed' : `exit ${res.code}`;
|
|
188
|
+
console.error(chalk.red(`${name}: ${why}${msg ? `: ${msg}` : ''}`));
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
if (tty) {
|
|
192
|
+
for (const t of targets) {
|
|
193
|
+
const target = await resolveSshTarget(t);
|
|
194
|
+
render(t, remoteSecretsRaw(target, args, { tty: true }));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const resolved = await Promise.all(targets.map((t) => resolveSshTarget(t)));
|
|
199
|
+
const results = resolved.map((target) => remoteSecretsRaw(target, args));
|
|
200
|
+
targets.forEach((t, i) => render(t, results[i]));
|
|
201
|
+
}
|
|
202
|
+
if (failures > 0)
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
163
205
|
/** Strip ANSI escape sequences so padding can be computed on visible width. */
|
|
164
206
|
function visibleWidth(s) {
|
|
165
207
|
// eslint-disable-next-line no-control-regex
|
|
@@ -450,8 +492,15 @@ export function registerSecretsCommands(program) {
|
|
|
450
492
|
cmd
|
|
451
493
|
.command('list')
|
|
452
494
|
.alias('ls')
|
|
453
|
-
.description('List configured secrets bundles')
|
|
454
|
-
.
|
|
495
|
+
.description('List configured secrets bundles (use --host/--hosts to list bundles on other machines over SSH)')
|
|
496
|
+
.option('--host <target>', 'List bundles on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
|
|
497
|
+
.option('--hosts <list>', 'Comma-separated hosts to list in one shot, e.g. yosemite-s0,yosemite-s1')
|
|
498
|
+
.action(async (opts) => {
|
|
499
|
+
const targets = parseHostsOption(opts);
|
|
500
|
+
if (targets.length > 0) {
|
|
501
|
+
await browseRemote(targets, ['list'], false);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
455
504
|
const bundles = listBundles();
|
|
456
505
|
if (bundles.length === 0) {
|
|
457
506
|
console.log(chalk.gray('No secrets bundles configured.'));
|
|
@@ -481,8 +530,28 @@ export function registerSecretsCommands(program) {
|
|
|
481
530
|
.description('Show a bundle. Keychain values are masked by default — pass --reveal to see them.')
|
|
482
531
|
.option('--reveal', 'Print keychain-backed values in the clear (TTY only unless --plaintext)')
|
|
483
532
|
.option('--plaintext', 'Allow --reveal in non-interactive shells (use with care)')
|
|
533
|
+
.option('--host <target>', 'Show a bundle on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
|
|
534
|
+
.option('--hosts <list>', 'Comma-separated hosts to show in one shot, e.g. yosemite-s0,yosemite-s1')
|
|
484
535
|
.action(async (name, opts) => {
|
|
485
536
|
try {
|
|
537
|
+
const targets = parseHostsOption(opts);
|
|
538
|
+
if (targets.length > 0) {
|
|
539
|
+
if (!name) {
|
|
540
|
+
console.error(chalk.red('A bundle name is required when viewing a remote host (interactive pick needs a local terminal).'));
|
|
541
|
+
process.exit(1);
|
|
542
|
+
}
|
|
543
|
+
const args = ['view', name];
|
|
544
|
+
if (opts.reveal)
|
|
545
|
+
args.push('--reveal');
|
|
546
|
+
if (opts.plaintext)
|
|
547
|
+
args.push('--plaintext');
|
|
548
|
+
// With --reveal, force a TTY so the remote keychain prompt can surface
|
|
549
|
+
// (and the remote's "--reveal in a non-TTY needs --plaintext" gate is
|
|
550
|
+
// satisfied) — only when this side is itself interactive.
|
|
551
|
+
const tty = Boolean(opts.reveal) && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
552
|
+
await browseRemote(targets, args, tty);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
486
555
|
const resolvedName = name ?? (await pickBundleName('view'));
|
|
487
556
|
const bundle = readBundle(resolvedName);
|
|
488
557
|
const entries = describeBundle(bundle);
|
|
@@ -1094,6 +1163,7 @@ Examples:
|
|
|
1094
1163
|
.option('--host <target...>', 'Push the bundle over SSH to this target (host alias or user@host); repeatable for multiple machines')
|
|
1095
1164
|
.option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
|
|
1096
1165
|
.option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
|
|
1166
|
+
.option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
|
|
1097
1167
|
.action(async (bundleName, opts) => {
|
|
1098
1168
|
try {
|
|
1099
1169
|
const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
|
|
@@ -1204,11 +1274,21 @@ Examples:
|
|
|
1204
1274
|
console.log(chalk.green(`Exported to 1Password vault '${vault}': ${parts.join(', ')}.`));
|
|
1205
1275
|
return;
|
|
1206
1276
|
}
|
|
1277
|
+
if (opts.format && opts.format !== 'shell' && opts.format !== 'json') {
|
|
1278
|
+
console.error(chalk.red(`Invalid --format ${JSON.stringify(opts.format)}. Expected 'shell' or 'json'.`));
|
|
1279
|
+
process.exit(1);
|
|
1280
|
+
}
|
|
1207
1281
|
if (!opts.plaintext) {
|
|
1208
1282
|
console.error(chalk.red('export prints secrets in the clear and requires --plaintext (works for TTY and pipes alike).'));
|
|
1209
1283
|
process.exit(1);
|
|
1210
1284
|
}
|
|
1211
1285
|
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `export to shell` });
|
|
1286
|
+
if (opts.format === 'json') {
|
|
1287
|
+
// Lossless, machine-readable form consumed by `remoteResolveEnv` over
|
|
1288
|
+
// SSH. Single object of KEY -> value; values verbatim (newlines, quotes).
|
|
1289
|
+
process.stdout.write(JSON.stringify(env));
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1212
1292
|
const prefix = bundleToEnvPrefix(resolvedBundleName);
|
|
1213
1293
|
for (const [k, v] of Object.entries(env)) {
|
|
1214
1294
|
const exportKey = isReservedEnvName(k) ? `${prefix}_${k}` : k;
|
|
@@ -1226,17 +1306,24 @@ Examples:
|
|
|
1226
1306
|
});
|
|
1227
1307
|
cmd
|
|
1228
1308
|
.command('exec <bundle> [command...]')
|
|
1229
|
-
.description('Run a command with the bundle\'s secrets injected into the environment')
|
|
1309
|
+
.description('Run a command with the bundle\'s secrets injected into the environment (use --host to resolve the bundle from a remote machine, ephemerally)')
|
|
1310
|
+
.option('--host <target>', 'Resolve <bundle> on a remote host over SSH and inject it (ephemeral — never stored on this machine)')
|
|
1230
1311
|
.allowUnknownOption()
|
|
1231
|
-
.action(async (bundleName, commandParts) => {
|
|
1312
|
+
.action(async (bundleName, commandParts, execOpts) => {
|
|
1232
1313
|
try {
|
|
1233
1314
|
if (commandParts.length === 0) {
|
|
1234
1315
|
console.error(chalk.red('Usage: agents secrets exec <bundle> -- <command...>'));
|
|
1235
1316
|
process.exit(1);
|
|
1236
1317
|
}
|
|
1237
|
-
const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
|
|
1238
1318
|
const [cmd, ...args] = commandParts;
|
|
1239
|
-
|
|
1319
|
+
let secretEnv;
|
|
1320
|
+
if (execOpts.host) {
|
|
1321
|
+
secretEnv = await remoteResolveEnv(await resolveSshTarget(execOpts.host), bundleName);
|
|
1322
|
+
}
|
|
1323
|
+
else {
|
|
1324
|
+
const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
|
|
1325
|
+
secretEnv = readAndResolveBundleEnv(bundleName, { caller: `command ${cmd}` }).env;
|
|
1326
|
+
}
|
|
1240
1327
|
const { spawn } = await import('child_process');
|
|
1241
1328
|
const proc = spawn(cmd, args, {
|
|
1242
1329
|
stdio: 'inherit',
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents devices` (registry) + `agents ssh` (smart wrapper).
|
|
3
|
+
*
|
|
4
|
+
* `agents devices` keeps a registry of SSH device profiles — platform, login
|
|
5
|
+
* user, address, and auth — self-populated from `tailscale status --json`.
|
|
6
|
+
* `agents ssh <name>` then connects through one hardened path: preflight
|
|
7
|
+
* (offline → fail fast instead of a 2-minute hang), platform-aware exec
|
|
8
|
+
* (PowerShell on Windows), and password-from-bundle auth via an askpass shim.
|
|
9
|
+
* Rendering the registry to an ssh_config include also lets plain ssh / scp /
|
|
10
|
+
* rsync / `agents sessions --host` resolve the same logical names.
|
|
11
|
+
*/
|
|
12
|
+
import type { Command } from 'commander';
|
|
13
|
+
/** Register both `agents ssh` and `agents devices`. */
|
|
14
|
+
export declare function registerSshCommands(program: Command): void;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents devices` (registry) + `agents ssh` (smart wrapper).
|
|
3
|
+
*
|
|
4
|
+
* `agents devices` keeps a registry of SSH device profiles — platform, login
|
|
5
|
+
* user, address, and auth — self-populated from `tailscale status --json`.
|
|
6
|
+
* `agents ssh <name>` then connects through one hardened path: preflight
|
|
7
|
+
* (offline → fail fast instead of a 2-minute hang), platform-aware exec
|
|
8
|
+
* (PowerShell on Windows), and password-from-bundle auth via an askpass shim.
|
|
9
|
+
* Rendering the registry to an ssh_config include also lets plain ssh / scp /
|
|
10
|
+
* rsync / `agents sessions --host` resolve the same logical names.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as os from 'os';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import ora from 'ora';
|
|
18
|
+
import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
|
|
19
|
+
import { getDevice, loadDevices, removeDevice, upsertDevice, } from '../lib/devices/registry.js';
|
|
20
|
+
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
|
|
21
|
+
import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
22
|
+
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
23
|
+
/** Parse `user@host` or `host` into pieces. */
|
|
24
|
+
function parseTarget(target) {
|
|
25
|
+
const at = target.indexOf('@');
|
|
26
|
+
if (at === -1)
|
|
27
|
+
return { host: target };
|
|
28
|
+
return { user: target.slice(0, at), host: target.slice(at + 1) };
|
|
29
|
+
}
|
|
30
|
+
/** One-line summary of a device for `list`. */
|
|
31
|
+
function deviceSummary(d) {
|
|
32
|
+
const addr = hostNameFor(d) ?? chalk.gray('no address');
|
|
33
|
+
const online = d.tailscale
|
|
34
|
+
? d.tailscale.online
|
|
35
|
+
? chalk.green('online')
|
|
36
|
+
: chalk.gray('offline')
|
|
37
|
+
: chalk.gray('unknown');
|
|
38
|
+
const reach = d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' (relayed)') : '';
|
|
39
|
+
return ` ${chalk.bold(d.name.padEnd(16))} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}`;
|
|
40
|
+
}
|
|
41
|
+
/** Resolve a device or exit with a clear error. */
|
|
42
|
+
async function mustGetDevice(name) {
|
|
43
|
+
const d = await getDevice(name);
|
|
44
|
+
if (!d) {
|
|
45
|
+
console.error(chalk.red(`Unknown device '${name}'. See 'agents devices list'.`));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
return d;
|
|
49
|
+
}
|
|
50
|
+
/** Register the `agents devices` command tree. */
|
|
51
|
+
function registerDevicesCommands(program) {
|
|
52
|
+
const devicesCmd = program
|
|
53
|
+
.command('devices')
|
|
54
|
+
.description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
|
|
55
|
+
.addHelpText('after', `
|
|
56
|
+
Typical workflow:
|
|
57
|
+
agents devices sync # ingest tailscale nodes (auto-detect platform)
|
|
58
|
+
agents devices list # see what's registered
|
|
59
|
+
agents devices set win-mini --auth password --bundle muqsit
|
|
60
|
+
agents devices render --write # write ~/.ssh/config.d/agents include
|
|
61
|
+
`);
|
|
62
|
+
devicesCmd
|
|
63
|
+
.command('sync')
|
|
64
|
+
.description('Ingest `tailscale status --json` and create/update device profiles (auto-detects platform, address, reachability).')
|
|
65
|
+
.action(async () => {
|
|
66
|
+
const spinner = ora('Reading tailscale status...').start();
|
|
67
|
+
try {
|
|
68
|
+
const nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
69
|
+
spinner.text = `Updating ${nodes.length} device${nodes.length === 1 ? '' : 's'}...`;
|
|
70
|
+
for (const node of nodes) {
|
|
71
|
+
await upsertDevice(node.name, nodeToDeviceInput(node));
|
|
72
|
+
}
|
|
73
|
+
spinner.succeed(`Synced ${nodes.length} device${nodes.length === 1 ? '' : 's'} from Tailscale`);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
spinner.fail(err.message);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
devicesCmd
|
|
81
|
+
.command('list')
|
|
82
|
+
.alias('ls')
|
|
83
|
+
.description('List registered devices with platform, address, and reachability.')
|
|
84
|
+
.action(async () => {
|
|
85
|
+
const reg = await loadDevices();
|
|
86
|
+
const names = Object.keys(reg).sort();
|
|
87
|
+
if (names.length === 0) {
|
|
88
|
+
console.log(chalk.gray("No devices. Run 'agents devices sync' or 'agents devices add <name> <user@host>'."));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
console.log(chalk.bold(`Devices (${names.length})`));
|
|
92
|
+
for (const name of names)
|
|
93
|
+
console.log(deviceSummary(reg[name]));
|
|
94
|
+
});
|
|
95
|
+
devicesCmd
|
|
96
|
+
.command('show <name>')
|
|
97
|
+
.description('Show the full profile for one device.')
|
|
98
|
+
.action(async (name) => {
|
|
99
|
+
const d = await mustGetDevice(name);
|
|
100
|
+
console.log(JSON.stringify(d, null, 2));
|
|
101
|
+
});
|
|
102
|
+
devicesCmd
|
|
103
|
+
.command('add <name> <target>')
|
|
104
|
+
.description('Add a device manually (target is user@host or host).')
|
|
105
|
+
.option('--platform <platform>', 'windows | linux | macos')
|
|
106
|
+
.action(async (name, target, opts) => {
|
|
107
|
+
try {
|
|
108
|
+
const { host, user } = parseTarget(target);
|
|
109
|
+
const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
|
|
110
|
+
const d = await upsertDevice(name, {
|
|
111
|
+
platform: opts.platform ?? undefined,
|
|
112
|
+
user,
|
|
113
|
+
address: { via: 'manual', dnsName: isIp ? undefined : host, ip: isIp ? host : undefined },
|
|
114
|
+
});
|
|
115
|
+
console.log(chalk.green(`Added device '${name}'`) + chalk.gray(` (${d.platform}, ${user ? user + '@' : ''}${host})`));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
console.error(chalk.red(err.message));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
devicesCmd
|
|
123
|
+
.command('set <name>')
|
|
124
|
+
.description('Update fields on an existing device (platform, user, auth).')
|
|
125
|
+
.option('--platform <platform>', 'windows | linux | macos')
|
|
126
|
+
.option('--user <user>', 'login user')
|
|
127
|
+
.option('--auth <method>', 'key | password')
|
|
128
|
+
.option('--bundle <bundle>', 'secrets bundle holding the password (for --auth password)')
|
|
129
|
+
.option('--bundle-key <key>', "key within the bundle (default 'password')")
|
|
130
|
+
.action(async (name, opts) => {
|
|
131
|
+
try {
|
|
132
|
+
const existing = await mustGetDevice(name);
|
|
133
|
+
const auth = opts.auth || opts.bundle || opts.bundleKey
|
|
134
|
+
? {
|
|
135
|
+
method: opts.auth ?? existing.auth.method,
|
|
136
|
+
bundle: opts.bundle ?? existing.auth.bundle,
|
|
137
|
+
bundleKey: opts.bundleKey ?? existing.auth.bundleKey,
|
|
138
|
+
}
|
|
139
|
+
: undefined;
|
|
140
|
+
const d = await upsertDevice(name, {
|
|
141
|
+
platform: opts.platform ?? undefined,
|
|
142
|
+
user: opts.user ?? undefined,
|
|
143
|
+
auth,
|
|
144
|
+
});
|
|
145
|
+
console.log(chalk.green(`Updated device '${name}'`) + chalk.gray(` (auth: ${d.auth.method}${d.auth.bundle ? ` via ${d.auth.bundle}` : ''})`));
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.error(chalk.red(err.message));
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
devicesCmd
|
|
153
|
+
.command('rm <name>')
|
|
154
|
+
.alias('remove')
|
|
155
|
+
.description('Remove a device from the registry.')
|
|
156
|
+
.action(async (name) => {
|
|
157
|
+
const ok = await removeDevice(name);
|
|
158
|
+
if (!ok) {
|
|
159
|
+
console.error(chalk.red(`Unknown device '${name}'.`));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
console.log(chalk.green(`Removed device '${name}'`));
|
|
163
|
+
});
|
|
164
|
+
devicesCmd
|
|
165
|
+
.command('render')
|
|
166
|
+
.description('Render the registry to ssh_config. Prints to stdout, or use --write to update ~/.ssh/config.d/agents.')
|
|
167
|
+
.option('--write', 'write to ~/.ssh/config.d/agents instead of printing')
|
|
168
|
+
.action(async (opts) => {
|
|
169
|
+
const reg = await loadDevices();
|
|
170
|
+
const text = renderSshConfig(reg);
|
|
171
|
+
if (!opts.write) {
|
|
172
|
+
process.stdout.write(text);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const dir = path.join(os.homedir(), '.ssh', 'config.d');
|
|
176
|
+
const file = path.join(dir, 'agents');
|
|
177
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
178
|
+
fs.writeFileSync(file, text, { mode: 0o600 });
|
|
179
|
+
console.log(chalk.green(`Wrote ${file}`));
|
|
180
|
+
console.log(chalk.gray('Add this to ~/.ssh/config (once): Include config.d/agents'));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** Register the `agents ssh` smart wrapper. */
|
|
184
|
+
function registerSshWrapper(program) {
|
|
185
|
+
const sshCmd = program
|
|
186
|
+
.command('ssh <name> [cmd...]')
|
|
187
|
+
.description('Connect to a registered device. Preflights reachability, picks the right shell, and authenticates (key or password-from-bundle).')
|
|
188
|
+
.allowUnknownOption()
|
|
189
|
+
.addHelpText('after', `
|
|
190
|
+
Examples:
|
|
191
|
+
agents ssh win-mini # interactive login
|
|
192
|
+
agents ssh win-mini hostname # run a command (PowerShell on Windows)
|
|
193
|
+
agents ssh yosemite-s0 uptime # run a command (POSIX)
|
|
194
|
+
|
|
195
|
+
Devices come from 'agents devices'. Password auth pulls the secret from a
|
|
196
|
+
secrets bundle via an askpass shim — the password never touches argv.
|
|
197
|
+
`)
|
|
198
|
+
.action(async (name, cmd) => {
|
|
199
|
+
// Hidden askpass bridge: ssh execs the shim, which re-invokes us here.
|
|
200
|
+
if (name === '__askpass') {
|
|
201
|
+
await runAskpass();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const device = await mustGetDevice(name);
|
|
205
|
+
// Preflight: a device Tailscale last saw offline would otherwise hang
|
|
206
|
+
// for the full ConnectTimeout. Fail fast with a clear message instead.
|
|
207
|
+
if (device.tailscale && !device.tailscale.online) {
|
|
208
|
+
console.error(chalk.red(`Device '${name}' is offline (Tailscale last saw it ${device.tailscale.lastSeen ?? 'a while ago'}).`));
|
|
209
|
+
console.error(chalk.gray("Run 'agents devices sync' to refresh reachability."));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
if (device.tailscale?.online && !device.tailscale.direct) {
|
|
213
|
+
console.error(chalk.yellow(`Note: connection to '${name}' is relayed (DERP ${device.tailscale.relay ?? '?'}) — expect higher latency.`));
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const shim = writeAskpassShim();
|
|
217
|
+
const { args, env } = buildSshInvocation(device, cmd, shim);
|
|
218
|
+
const res = spawnSync('ssh', args, {
|
|
219
|
+
stdio: 'inherit',
|
|
220
|
+
env: { ...process.env, ...env },
|
|
221
|
+
});
|
|
222
|
+
process.exit(res.status ?? 1);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
console.error(chalk.red(err.message));
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
// Keep the hidden askpass invocation out of help.
|
|
230
|
+
void sshCmd;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The askpass side of password auth. Invoked by the shim (which ssh execs with
|
|
234
|
+
* SSH_ASKPASS): read the target bundle/key from the environment the wrapper
|
|
235
|
+
* set, resolve it through the existing Keychain path, and print the password
|
|
236
|
+
* to stdout for ssh to consume.
|
|
237
|
+
*/
|
|
238
|
+
async function runAskpass() {
|
|
239
|
+
const bundle = process.env[ASKPASS_BUNDLE_ENV];
|
|
240
|
+
const key = process.env[ASKPASS_KEY_ENV] ?? 'password';
|
|
241
|
+
if (!bundle) {
|
|
242
|
+
console.error(`askpass: ${ASKPASS_BUNDLE_ENV} not set`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents ssh' });
|
|
247
|
+
const value = env[key];
|
|
248
|
+
if (value === undefined) {
|
|
249
|
+
console.error(`askpass: key '${key}' not found in bundle '${bundle}'`);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
process.stdout.write(value);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
console.error(`askpass: ${err?.message ?? err}`);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Register both `agents ssh` and `agents devices`. */
|
|
260
|
+
export function registerSshCommands(program) {
|
|
261
|
+
registerSshWrapper(program);
|
|
262
|
+
registerDevicesCommands(program);
|
|
263
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -51,7 +51,7 @@ if (IS_DEV_BUILD) {
|
|
|
51
51
|
// module on each invocation (which loaded the whole ~50-module tree before the
|
|
52
52
|
// first byte of output), the registry maps a command name to a thunk that
|
|
53
53
|
// imports only what that command needs. See src/lib/startup/command-registry.ts.
|
|
54
|
-
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadHosts, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
|
|
54
|
+
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadBrowser, loadComputer, loadHosts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
|
|
55
55
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
56
56
|
import { IS_WINDOWS } from './lib/platform/index.js';
|
|
57
57
|
// Transparent shim delegate: the generated Windows `.cmd` shims invoke
|
|
@@ -784,6 +784,7 @@ async function registerAllEagerCommands() {
|
|
|
784
784
|
await reg(loadBrowser);
|
|
785
785
|
await reg(loadComputer);
|
|
786
786
|
await reg(loadHosts);
|
|
787
|
+
await reg(loadSsh);
|
|
787
788
|
registerJobsCronAliasCommand(program, 'jobs');
|
|
788
789
|
registerJobsCronAliasCommand(program, 'cron');
|
|
789
790
|
registerUpgradeCommand(program);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type DeviceProfile } from './registry.js';
|
|
2
|
+
/** Env var the askpass shim reads to know which bundle holds the password. */
|
|
3
|
+
export declare const ASKPASS_BUNDLE_ENV = "AGENTS_SSH_BUNDLE";
|
|
4
|
+
/** Env var the askpass shim reads to know which key in the bundle is the password. */
|
|
5
|
+
export declare const ASKPASS_KEY_ENV = "AGENTS_SSH_KEY";
|
|
6
|
+
/**
|
|
7
|
+
* Build the `user@host` (or bare `host`) ssh target for a device and validate
|
|
8
|
+
* it against the shared injection guard. Throws if the device has no address.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sshTargetFor(device: DeviceProfile): string;
|
|
11
|
+
/**
|
|
12
|
+
* Wrap a remote command for the device's shell. Windows devices speak
|
|
13
|
+
* PowerShell, so a bare command is run through `powershell -NoProfile
|
|
14
|
+
* -Command`; POSIX devices get the command verbatim (the remote login shell
|
|
15
|
+
* parses it). Returns undefined when no command was given (interactive login).
|
|
16
|
+
*/
|
|
17
|
+
export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Build the argv (after the `ssh` program name) and the environment overlay
|
|
20
|
+
* for connecting to a device. For password auth this points `SSH_ASKPASS` at
|
|
21
|
+
* the shim and disables pubkey + the host's interactive password prompt so the
|
|
22
|
+
* shim is the only auth path. Pure (no spawn) so it is unit-testable.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string): {
|
|
25
|
+
args: string[];
|
|
26
|
+
env: Record<string, string>;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Write (idempotently) the askpass shim — a tiny executable that re-invokes
|
|
30
|
+
* this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
|
|
31
|
+
* args, so the shim carries no secret itself; it only bridges ssh's askpass
|
|
32
|
+
* protocol back into the CLI, which then resolves the bundle.
|
|
33
|
+
*/
|
|
34
|
+
export declare function writeAskpassShim(): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection layer for `agents ssh` — turn a device profile into a real ssh
|
|
3
|
+
* invocation, with platform-aware command wrapping and password-from-bundle
|
|
4
|
+
* auth.
|
|
5
|
+
*
|
|
6
|
+
* Auth is genuinely two first-class, non-interactive methods:
|
|
7
|
+
* - `key` — the system ssh agent / on-disk keys (BatchMode-friendly).
|
|
8
|
+
* - `password` — the secret is pulled from a Keychain-backed secrets bundle
|
|
9
|
+
* by an askpass shim. The wrapper points `SSH_ASKPASS` at the
|
|
10
|
+
* shim and forces its use; ssh calls the shim, the shim calls
|
|
11
|
+
* back into `agents ssh __askpass`, which resolves the bundle
|
|
12
|
+
* via the existing `readAndResolveBundleEnv` path and prints
|
|
13
|
+
* the password to ssh. The password never touches argv or an
|
|
14
|
+
* expect buffer.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
|
+
import { getCacheDir } from '../state.js';
|
|
20
|
+
import { hostNameFor } from './ssh-config.js';
|
|
21
|
+
/** Env var the askpass shim reads to know which bundle holds the password. */
|
|
22
|
+
export const ASKPASS_BUNDLE_ENV = 'AGENTS_SSH_BUNDLE';
|
|
23
|
+
/** Env var the askpass shim reads to know which key in the bundle is the password. */
|
|
24
|
+
export const ASKPASS_KEY_ENV = 'AGENTS_SSH_KEY';
|
|
25
|
+
/**
|
|
26
|
+
* Build the `user@host` (or bare `host`) ssh target for a device and validate
|
|
27
|
+
* it against the shared injection guard. Throws if the device has no address.
|
|
28
|
+
*/
|
|
29
|
+
export function sshTargetFor(device) {
|
|
30
|
+
const host = hostNameFor(device);
|
|
31
|
+
if (!host) {
|
|
32
|
+
throw new Error(`Device '${device.name}' has no address (dnsName/ip). Run \`agents devices sync\` or \`agents devices add\`.`);
|
|
33
|
+
}
|
|
34
|
+
const target = device.user ? `${device.user}@${host}` : host;
|
|
35
|
+
assertValidSshTarget(target);
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Wrap a remote command for the device's shell. Windows devices speak
|
|
40
|
+
* PowerShell, so a bare command is run through `powershell -NoProfile
|
|
41
|
+
* -Command`; POSIX devices get the command verbatim (the remote login shell
|
|
42
|
+
* parses it). Returns undefined when no command was given (interactive login).
|
|
43
|
+
*/
|
|
44
|
+
export function wrapRemoteCommand(device, cmd) {
|
|
45
|
+
if (cmd.length === 0)
|
|
46
|
+
return undefined;
|
|
47
|
+
const joined = cmd.join(' ');
|
|
48
|
+
if (device.shell === 'powershell') {
|
|
49
|
+
return `powershell -NoProfile -Command ${shellQuote(joined)}`;
|
|
50
|
+
}
|
|
51
|
+
return joined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Build the argv (after the `ssh` program name) and the environment overlay
|
|
55
|
+
* for connecting to a device. For password auth this points `SSH_ASKPASS` at
|
|
56
|
+
* the shim and disables pubkey + the host's interactive password prompt so the
|
|
57
|
+
* shim is the only auth path. Pure (no spawn) so it is unit-testable.
|
|
58
|
+
*/
|
|
59
|
+
export function buildSshInvocation(device, cmd, askpassShimPath) {
|
|
60
|
+
const target = sshTargetFor(device);
|
|
61
|
+
const remote = wrapRemoteCommand(device, cmd);
|
|
62
|
+
const env = {};
|
|
63
|
+
const args = ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10'];
|
|
64
|
+
if (device.auth.method === 'password') {
|
|
65
|
+
if (!device.auth.bundle) {
|
|
66
|
+
throw new Error(`Device '${device.name}' uses password auth but has no secrets bundle. Set one with \`agents devices set ${device.name} --bundle <name>\`.`);
|
|
67
|
+
}
|
|
68
|
+
env.SSH_ASKPASS = askpassShimPath;
|
|
69
|
+
env.SSH_ASKPASS_REQUIRE = 'force';
|
|
70
|
+
env[ASKPASS_BUNDLE_ENV] = device.auth.bundle;
|
|
71
|
+
env[ASKPASS_KEY_ENV] = device.auth.bundleKey ?? 'password';
|
|
72
|
+
args.push('-o', 'PreferredAuthentications=password', '-o', 'PubkeyAuthentication=no', '-o', 'NumberOfPasswordPrompts=1');
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
args.push('-o', 'BatchMode=yes');
|
|
76
|
+
}
|
|
77
|
+
// An interactive login (no remote command) needs a real tty.
|
|
78
|
+
if (!remote)
|
|
79
|
+
args.push('-tt');
|
|
80
|
+
args.push(target);
|
|
81
|
+
if (remote)
|
|
82
|
+
args.push(remote);
|
|
83
|
+
return { args, env };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Write (idempotently) the askpass shim — a tiny executable that re-invokes
|
|
87
|
+
* this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
|
|
88
|
+
* args, so the shim carries no secret itself; it only bridges ssh's askpass
|
|
89
|
+
* protocol back into the CLI, which then resolves the bundle.
|
|
90
|
+
*/
|
|
91
|
+
export function writeAskpassShim() {
|
|
92
|
+
const dir = path.join(getCacheDir(), 'devices');
|
|
93
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
94
|
+
const shimPath = path.join(dir, 'askpass.sh');
|
|
95
|
+
// process.execPath = the node/bun binary; argv[1] = this CLI's entry script.
|
|
96
|
+
const node = process.execPath;
|
|
97
|
+
const entry = process.argv[1] ?? '';
|
|
98
|
+
const body = `#!/bin/sh\n# Generated by agents-cli — bridges ssh SSH_ASKPASS back into the CLI.\nexec ${shellQuote(node)} ${shellQuote(entry)} ssh __askpass\n`;
|
|
99
|
+
fs.writeFileSync(shimPath, body, { mode: 0o700 });
|
|
100
|
+
return shimPath;
|
|
101
|
+
}
|