@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.
@@ -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 bundleName of options.secrets) {
780
+ for (const bundleRef of options.secrets) {
780
781
  try {
781
- const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, { caller: `agent ${agent}` });
782
- const entries = describeBundle(bundle);
783
- const counts = {};
784
- for (const e of entries) {
785
- counts[e.kind] = (counts[e.kind] || 0) + 1;
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));
@@ -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
- .action(async () => {
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
- const { env: secretEnv } = readAndResolveBundleEnv(bundleName, { caller: `command ${cmd}` });
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',
@@ -753,6 +753,7 @@ export function buildResumeCommand(session) {
753
753
  case 'hermes':
754
754
  case 'grok':
755
755
  case 'kimi':
756
+ case 'droid':
756
757
  // Grok (and some others) sessions are captured artifacts, not resumable the same way.
757
758
  return null;
758
759
  }
@@ -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
+ }