@phnx-labs/agents-cli 1.20.65 → 1.20.66

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +119 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.d.ts +48 -0
  5. package/dist/commands/exec.js +65 -0
  6. package/dist/commands/monitors.js +8 -0
  7. package/dist/commands/plugins.js +28 -7
  8. package/dist/commands/sessions-browser.d.ts +82 -0
  9. package/dist/commands/sessions-browser.js +320 -0
  10. package/dist/commands/sessions.d.ts +1 -0
  11. package/dist/commands/sessions.js +121 -4
  12. package/dist/commands/share.d.ts +2 -0
  13. package/dist/commands/share.js +150 -0
  14. package/dist/commands/ssh.js +9 -1
  15. package/dist/index.js +2 -1
  16. package/dist/lib/agents.d.ts +28 -0
  17. package/dist/lib/agents.js +68 -0
  18. package/dist/lib/devices/connect.d.ts +18 -1
  19. package/dist/lib/devices/connect.js +10 -2
  20. package/dist/lib/devices/known-hosts.d.ts +62 -0
  21. package/dist/lib/devices/known-hosts.js +137 -0
  22. package/dist/lib/hosts/dispatch.js +20 -2
  23. package/dist/lib/hosts/remote-cmd.js +8 -4
  24. package/dist/lib/monitors/engine.js +4 -0
  25. package/dist/lib/monitors/sources/device.js +13 -3
  26. package/dist/lib/picker.d.ts +53 -0
  27. package/dist/lib/picker.js +214 -1
  28. package/dist/lib/plugins.d.ts +31 -1
  29. package/dist/lib/plugins.js +74 -13
  30. package/dist/lib/secrets/agent.d.ts +35 -0
  31. package/dist/lib/secrets/agent.js +114 -55
  32. package/dist/lib/secrets/filestore.d.ts +9 -0
  33. package/dist/lib/secrets/filestore.js +21 -8
  34. package/dist/lib/secrets/index.d.ts +7 -0
  35. package/dist/lib/secrets/index.js +26 -6
  36. package/dist/lib/share/capture.d.ts +29 -0
  37. package/dist/lib/share/capture.js +140 -0
  38. package/dist/lib/share/config.d.ts +35 -0
  39. package/dist/lib/share/config.js +100 -0
  40. package/dist/lib/share/og.d.ts +25 -0
  41. package/dist/lib/share/og.js +84 -0
  42. package/dist/lib/share/provision.d.ts +10 -0
  43. package/dist/lib/share/provision.js +91 -0
  44. package/dist/lib/share/publish.d.ts +57 -0
  45. package/dist/lib/share/publish.js +145 -0
  46. package/dist/lib/share/worker-template.d.ts +2 -0
  47. package/dist/lib/share/worker-template.js +82 -0
  48. package/dist/lib/shims.d.ts +13 -0
  49. package/dist/lib/shims.js +42 -2
  50. package/dist/lib/ssh-exec.d.ts +24 -0
  51. package/dist/lib/ssh-exec.js +15 -3
  52. package/dist/lib/startup/command-registry.d.ts +1 -0
  53. package/dist/lib/startup/command-registry.js +2 -0
  54. package/dist/lib/types.d.ts +10 -0
  55. package/package.json +1 -1
@@ -27,6 +27,7 @@ import { clearPendingSentinel } from '../lib/devices/pending.js';
27
27
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
28
28
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
29
29
  import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
30
+ import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
30
31
  import { planFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
31
32
  import { fleetCapacity, fmtBytes, headroom, probeFleetStats, } from '../lib/devices/health.js';
32
33
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
@@ -558,7 +559,14 @@ secrets bundle via an askpass shim — the password never touches argv.
558
559
  }
559
560
  try {
560
561
  const shim = writeAskpassShim();
561
- const { args, env } = buildSshInvocation(device, cmd, shim);
562
+ // Pin the host key on first connect and verify strictly thereafter: the
563
+ // managed known_hosts store must exist before ssh writes the learned key
564
+ // into it, and a host already recorded there is checked with
565
+ // StrictHostKeyChecking=yes (RUSH-1767).
566
+ ensureManagedKnownHostsDir();
567
+ const addr = hostNameFor(device);
568
+ const pinned = addr ? isHostPinned(addr) : false;
569
+ const { args, env } = buildSshInvocation(device, cmd, shim, { pinned });
562
570
  const res = spawnSync('ssh', args, {
563
571
  stdio: 'inherit',
564
572
  env: { ...process.env, ...env },
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
50
50
  // module on each invocation (which loaded the whole ~50-module tree before the
51
51
  // first byte of output), the registry maps a command name to a thunk that
52
52
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
53
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
53
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadShare, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
54
54
  import { applyGlobalHelpConventions } from './lib/help.js';
55
55
  import { renderWhatsNew } from './lib/whats-new.js';
56
56
  import { emit, redactArgs } from './lib/events.js';
@@ -667,6 +667,7 @@ async function registerEagerForRequest(name) {
667
667
  */
668
668
  async function registerAllEagerCommands() {
669
669
  await reg(loadView);
670
+ await reg(loadShare);
670
671
  await reg(loadInspect);
671
672
  await reg(loadFeedback);
672
673
  await reg(loadCommands);
@@ -164,6 +164,34 @@ export interface DroidAuthPayload {
164
164
  * in usage.ts.
165
165
  */
166
166
  export declare function decryptDroidAuthPayload(base: string): DroidAuthPayload | null;
167
+ /**
168
+ * Decrypt a Droid `auth.v2.file` (AES-256-GCM `ivB64:tagB64:ctB64`) using the
169
+ * raw 32-byte key stored base64 in `auth.v2.key`, given the EXACT paths to both.
170
+ * Same crypto as decryptDroidAuthPayload but without the account-global HOME
171
+ * fallback, so the identity of a SPECIFIC version home resolves against only
172
+ * that home's files (carryForwardAuthFiles needs per-dir identity). Returns null
173
+ * on any failure (missing file/key, wrong key length, bad GCM tag, malformed
174
+ * JSON). Never throws.
175
+ */
176
+ export declare function decryptDroidAuthFile(filePath: string, keyPath: string): DroidAuthPayload | null;
177
+ /**
178
+ * Stable account identity for a *file-auth* agent's credential directory
179
+ * (droid / kimi / antigravity), or null when the directory holds no decodable
180
+ * account claim. Unlike a naive top-level JSON key-scan (which matched NO real
181
+ * credential file), this decrypts / decodes each agent's REAL on-disk format so
182
+ * the identity resolves against production credentials:
183
+ * - droid: AES-256-GCM auth.v2.file (+ auth.v2.key) -> WorkOS access-token JWT
184
+ * -> email / org_id / sub.
185
+ * - kimi: credentials/kimi-code.json -> access-token JWT -> user_id / sub.
186
+ * - antigravity: antigravity-oauth-token -> token.refresh_token -> JWT sub
187
+ * when the token is a JWT, else the raw refresh-token value (opaque Google
188
+ * consumer tokens are stable per login).
189
+ * Two directories for the SAME account compare equal; two DIFFERENT accounts
190
+ * compare distinct. Used by carryForwardAuthFiles to refuse overwriting one
191
+ * account's login with a credential that belongs to a DIFFERENT account
192
+ * (RUSH-1764). Never throws.
193
+ */
194
+ export declare function readAuthAccountIdentity(agent: AgentId, configDir: string): string | null;
167
195
  /**
168
196
  * Antigravity (`agy`) stores its OAuth token via the Go keyring library
169
197
  * (zalando/go-keyring), which is platform-split:
@@ -1036,6 +1036,18 @@ export function decryptDroidAuthPayload(base) {
1036
1036
  const keyPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.key');
1037
1037
  if (!filePath || !keyPath)
1038
1038
  return null;
1039
+ return decryptDroidAuthFile(filePath, keyPath);
1040
+ }
1041
+ /**
1042
+ * Decrypt a Droid `auth.v2.file` (AES-256-GCM `ivB64:tagB64:ctB64`) using the
1043
+ * raw 32-byte key stored base64 in `auth.v2.key`, given the EXACT paths to both.
1044
+ * Same crypto as decryptDroidAuthPayload but without the account-global HOME
1045
+ * fallback, so the identity of a SPECIFIC version home resolves against only
1046
+ * that home's files (carryForwardAuthFiles needs per-dir identity). Returns null
1047
+ * on any failure (missing file/key, wrong key length, bad GCM tag, malformed
1048
+ * JSON). Never throws.
1049
+ */
1050
+ export function decryptDroidAuthFile(filePath, keyPath) {
1039
1051
  try {
1040
1052
  const blob = fs.readFileSync(filePath, 'utf-8').trim();
1041
1053
  const key = Buffer.from(fs.readFileSync(keyPath, 'utf-8').trim(), 'base64');
@@ -1057,6 +1069,62 @@ export function decryptDroidAuthPayload(base) {
1057
1069
  return null;
1058
1070
  }
1059
1071
  }
1072
+ /**
1073
+ * Stable account identity for a *file-auth* agent's credential directory
1074
+ * (droid / kimi / antigravity), or null when the directory holds no decodable
1075
+ * account claim. Unlike a naive top-level JSON key-scan (which matched NO real
1076
+ * credential file), this decrypts / decodes each agent's REAL on-disk format so
1077
+ * the identity resolves against production credentials:
1078
+ * - droid: AES-256-GCM auth.v2.file (+ auth.v2.key) -> WorkOS access-token JWT
1079
+ * -> email / org_id / sub.
1080
+ * - kimi: credentials/kimi-code.json -> access-token JWT -> user_id / sub.
1081
+ * - antigravity: antigravity-oauth-token -> token.refresh_token -> JWT sub
1082
+ * when the token is a JWT, else the raw refresh-token value (opaque Google
1083
+ * consumer tokens are stable per login).
1084
+ * Two directories for the SAME account compare equal; two DIFFERENT accounts
1085
+ * compare distinct. Used by carryForwardAuthFiles to refuse overwriting one
1086
+ * account's login with a credential that belongs to a DIFFERENT account
1087
+ * (RUSH-1764). Never throws.
1088
+ */
1089
+ export function readAuthAccountIdentity(agent, configDir) {
1090
+ try {
1091
+ switch (agent) {
1092
+ case 'droid': {
1093
+ const payload = decryptDroidAuthFile(path.join(configDir, 'auth.v2.file'), path.join(configDir, 'auth.v2.key'));
1094
+ const claims = typeof payload?.access_token === 'string' ? decodeJwtPayload(payload.access_token) : null;
1095
+ if (!claims)
1096
+ return null;
1097
+ return buildIdentityKey(agent, [
1098
+ ['email', normalizeIdentityPart(claims.email)],
1099
+ ['org', normalizeIdentityPart(claims.org_id ?? payload?.active_organization_id)],
1100
+ ['sub', normalizeIdentityPart(claims.sub)],
1101
+ ]);
1102
+ }
1103
+ case 'kimi': {
1104
+ const data = JSON.parse(fs.readFileSync(path.join(configDir, 'credentials', 'kimi-code.json'), 'utf-8'));
1105
+ const accessToken = data?.access_token;
1106
+ const claims = typeof accessToken === 'string' ? decodeJwtPayload(accessToken) : null;
1107
+ return buildIdentityKey(agent, [
1108
+ ['user', normalizeIdentityPart(claims?.user_id ?? claims?.sub)],
1109
+ ]);
1110
+ }
1111
+ case 'antigravity': {
1112
+ const data = JSON.parse(fs.readFileSync(path.join(configDir, 'antigravity-oauth-token'), 'utf-8'));
1113
+ const refreshToken = data?.token?.refresh_token;
1114
+ if (typeof refreshToken !== 'string' || !refreshToken)
1115
+ return null;
1116
+ const claims = decodeJwtPayload(refreshToken);
1117
+ const sub = normalizeIdentityPart(claims?.sub ?? claims?.user_id);
1118
+ return buildIdentityKey(agent, [['sub', sub ?? refreshToken]]);
1119
+ }
1120
+ default:
1121
+ return null;
1122
+ }
1123
+ }
1124
+ catch {
1125
+ return null;
1126
+ }
1127
+ }
1060
1128
  /**
1061
1129
  * Derive Droid account identity from the decrypted credential. The
1062
1130
  * `access_token` is a WorkOS JWT carrying an `email` claim (plus org_id /
@@ -16,13 +16,30 @@ export declare function sshTargetFor(device: DeviceProfile): string;
16
16
  * login).
17
17
  */
18
18
  export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
19
+ /** Host-key posture for {@link buildSshInvocation}. */
20
+ export interface SshHostKeyOptions {
21
+ /**
22
+ * True when the device's host key is already pinned in the managed
23
+ * known_hosts store — connections then verify with `StrictHostKeyChecking=yes`
24
+ * (a key swap is refused). False (the default) keeps `accept-new` for a
25
+ * genuine first enrollment, whose learned key lands in the managed store and
26
+ * pins the host for every subsequent connect. See {@link hostKeyCheckingOpts}.
27
+ */
28
+ pinned?: boolean;
29
+ /** Managed known_hosts path override (tests). Defaults to the CLI-managed store. */
30
+ knownHostsFile?: string;
31
+ }
19
32
  /**
20
33
  * Build the argv (after the `ssh` program name) and the environment overlay
21
34
  * for connecting to a device. For password auth this points `SSH_ASKPASS` at
22
35
  * the shim and disables pubkey + the host's interactive password prompt so the
23
36
  * shim is the only auth path. Pure (no spawn) so it is unit-testable.
37
+ *
38
+ * Host-key checking runs against the CLI-managed known_hosts store (never the
39
+ * user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
40
+ * `accept-new` to learn+pin the key on first connect (RUSH-1767).
24
41
  */
25
- export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string): {
42
+ export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions): {
26
43
  args: string[];
27
44
  env: Record<string, string>;
28
45
  };
@@ -18,6 +18,7 @@ import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
19
  import { encodePwshBase64 } from '../pwsh.js';
20
20
  import { getCacheDir } from '../state.js';
21
+ import { hostKeyCheckingOpts } from './known-hosts.js';
21
22
  import { hostNameFor } from './ssh-config.js';
22
23
  /** Env var the askpass shim reads to know which bundle holds the password. */
23
24
  export const ASKPASS_BUNDLE_ENV = 'AGENTS_SSH_BUNDLE';
@@ -57,12 +58,19 @@ export function wrapRemoteCommand(device, cmd) {
57
58
  * for connecting to a device. For password auth this points `SSH_ASKPASS` at
58
59
  * the shim and disables pubkey + the host's interactive password prompt so the
59
60
  * shim is the only auth path. Pure (no spawn) so it is unit-testable.
61
+ *
62
+ * Host-key checking runs against the CLI-managed known_hosts store (never the
63
+ * user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
64
+ * `accept-new` to learn+pin the key on first connect (RUSH-1767).
60
65
  */
61
- export function buildSshInvocation(device, cmd, askpassShimPath) {
66
+ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
62
67
  const target = sshTargetFor(device);
63
68
  const remote = wrapRemoteCommand(device, cmd);
64
69
  const env = {};
65
- const args = ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10'];
70
+ const args = [
71
+ ...hostKeyCheckingOpts(hostKey.pinned ?? false, hostKey.knownHostsFile),
72
+ '-o', 'ConnectTimeout=10',
73
+ ];
66
74
  if (device.auth.method === 'password') {
67
75
  if (!device.auth.bundle) {
68
76
  throw new Error(`Device '${device.name}' uses password auth but has no secrets bundle. Set one with \`agents devices set ${device.name} --bundle <name>\`.`);
@@ -0,0 +1,62 @@
1
+ /** Path to the CLI-managed known_hosts store (created lazily, mode 0600). */
2
+ export declare function managedKnownHostsPath(): string;
3
+ /** Ensure the parent directory of the managed store exists (mode 0700). */
4
+ export declare function ensureManagedKnownHostsDir(file?: string): void;
5
+ /** Read the managed store, or '' when it does not exist yet. */
6
+ export declare function readManagedKnownHosts(file?: string): string;
7
+ /**
8
+ * True if `host` has at least one pinned key line in `content`. Pure (content
9
+ * in) so the match logic is unit-testable without touching disk. Matching is
10
+ * case-insensitive on the hostname, as OpenSSH does.
11
+ */
12
+ export declare function isHostPinnedIn(content: string, host: string): boolean;
13
+ /** True if `host` is pinned in the managed store on disk. */
14
+ export declare function isHostPinned(host: string, file?: string): boolean;
15
+ /**
16
+ * The host-key-checking ssh options for a connection.
17
+ *
18
+ * Always points `UserKnownHostsFile` at the managed store so learned and pinned
19
+ * keys live in exactly one CLI-owned file. `StrictHostKeyChecking` is `yes` once
20
+ * the host is pinned (a key swap is refused) and `accept-new` before that
21
+ * (genuine first enrollment learns the key into the managed store, which pins it
22
+ * for every subsequent connect). Pure given `pinned`, so the policy is testable.
23
+ */
24
+ export declare function hostKeyCheckingOpts(pinned: boolean, file?: string): string[];
25
+ /**
26
+ * The key lines in `scanned` (ssh-keyscan output) not already present in
27
+ * `existing`. Comments and blank lines are dropped; whitespace is normalized so
28
+ * a re-scan of an already-pinned key is a no-op. Pure, so the idempotent-append
29
+ * contract is unit-testable without spawning ssh-keyscan.
30
+ */
31
+ export declare function newKnownHostsLines(existing: string, scanned: string): string[];
32
+ export interface PinResult {
33
+ /** True if the host is pinned in the managed store after this call. */
34
+ pinned: boolean;
35
+ /** How many new key lines were appended. */
36
+ added: number;
37
+ }
38
+ /**
39
+ * Merge `ssh-keyscan` output for `host` into the managed store at `file`,
40
+ * idempotently, and report whether `host` is pinned afterward. Split out from
41
+ * {@link pinHostKey} so the store-write half — the part that decides a scanned
42
+ * key now counts as pinned — is unit-testable with real keyscan text and no
43
+ * network (the spawn stays in `pinHostKey`).
44
+ */
45
+ export declare function recordScannedKeys(host: string, scanned: string, file?: string): PinResult;
46
+ /**
47
+ * `ssh-keyscan` a host at a trusted moment and append any new key lines to the
48
+ * managed store, idempotently. This is the explicit pin path; the implicit one
49
+ * is a normal `accept-new` connection whose learned key lands in the same store.
50
+ * Returns whether the host is pinned afterward.
51
+ *
52
+ * Host-name-agnostic: it scans whatever address it is handed, so it also pins a
53
+ * host `agents ssh` can't reach — notably a bare `~/.ssh/config` `Host` alias,
54
+ * which is not a registered device. The `--copy-creds` gate (commands/exec.ts)
55
+ * calls this for exactly that case so the credential copy is usable for
56
+ * ssh-config-alias hosts (RUSH-1767).
57
+ */
58
+ export declare function pinHostKey(host: string, opts?: {
59
+ file?: string;
60
+ timeoutMs?: number;
61
+ port?: number;
62
+ }): PinResult;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Managed known_hosts pinning for the device fleet (RUSH-1767).
3
+ *
4
+ * The shared SSH baseline uses `StrictHostKeyChecking=accept-new`
5
+ * (trust-on-first-use): it silently accepts whatever key answers on the FIRST
6
+ * connect, so a machine-in-the-middle present in that window is trusted forever
7
+ * and never re-checked. This module gives the CLI its own known_hosts store,
8
+ * kept apart from the user's `~/.ssh/known_hosts`, so a device's host key can be
9
+ * *pinned*: once a key is recorded here, connections verify against it with
10
+ * `StrictHostKeyChecking=yes`, so a later key swap is refused instead of
11
+ * silently re-accepted.
12
+ *
13
+ * The learn-then-pin flow: the first `agents ssh`/fleet connection to a host is
14
+ * still `accept-new`, but it writes the learned key into THIS store, which pins
15
+ * it for every subsequent connect. Credential copies (`run --host --copy-creds`)
16
+ * refuse to run against a host that isn't pinned here — see
17
+ * `commands/exec.ts` — so tokens never ride an unverified first connect.
18
+ */
19
+ import * as fs from 'fs';
20
+ import * as path from 'path';
21
+ import { spawnSync } from 'child_process';
22
+ import { getCacheDir } from '../state.js';
23
+ import { assertValidSshTarget } from '../ssh-exec.js';
24
+ import { parseKnownHosts } from '../hosts/ssh-config.js';
25
+ /** Path to the CLI-managed known_hosts store (created lazily, mode 0600). */
26
+ export function managedKnownHostsPath() {
27
+ return path.join(getCacheDir(), 'devices', 'known_hosts');
28
+ }
29
+ /** Ensure the parent directory of the managed store exists (mode 0700). */
30
+ export function ensureManagedKnownHostsDir(file = managedKnownHostsPath()) {
31
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
32
+ }
33
+ /** Read the managed store, or '' when it does not exist yet. */
34
+ export function readManagedKnownHosts(file = managedKnownHostsPath()) {
35
+ try {
36
+ return fs.readFileSync(file, 'utf-8');
37
+ }
38
+ catch {
39
+ return '';
40
+ }
41
+ }
42
+ /**
43
+ * True if `host` has at least one pinned key line in `content`. Pure (content
44
+ * in) so the match logic is unit-testable without touching disk. Matching is
45
+ * case-insensitive on the hostname, as OpenSSH does.
46
+ */
47
+ export function isHostPinnedIn(content, host) {
48
+ const needle = host.trim().toLowerCase();
49
+ if (!needle)
50
+ return false;
51
+ return parseKnownHosts(content).some((h) => h.toLowerCase() === needle);
52
+ }
53
+ /** True if `host` is pinned in the managed store on disk. */
54
+ export function isHostPinned(host, file = managedKnownHostsPath()) {
55
+ return isHostPinnedIn(readManagedKnownHosts(file), host);
56
+ }
57
+ /**
58
+ * The host-key-checking ssh options for a connection.
59
+ *
60
+ * Always points `UserKnownHostsFile` at the managed store so learned and pinned
61
+ * keys live in exactly one CLI-owned file. `StrictHostKeyChecking` is `yes` once
62
+ * the host is pinned (a key swap is refused) and `accept-new` before that
63
+ * (genuine first enrollment learns the key into the managed store, which pins it
64
+ * for every subsequent connect). Pure given `pinned`, so the policy is testable.
65
+ */
66
+ export function hostKeyCheckingOpts(pinned, file = managedKnownHostsPath()) {
67
+ return [
68
+ '-o', `UserKnownHostsFile=${file}`,
69
+ '-o', `StrictHostKeyChecking=${pinned ? 'yes' : 'accept-new'}`,
70
+ ];
71
+ }
72
+ /**
73
+ * The key lines in `scanned` (ssh-keyscan output) not already present in
74
+ * `existing`. Comments and blank lines are dropped; whitespace is normalized so
75
+ * a re-scan of an already-pinned key is a no-op. Pure, so the idempotent-append
76
+ * contract is unit-testable without spawning ssh-keyscan.
77
+ */
78
+ export function newKnownHostsLines(existing, scanned) {
79
+ const have = new Set(existing.split('\n').map((l) => l.trim()).filter(Boolean));
80
+ const seen = new Set();
81
+ const fresh = [];
82
+ for (const raw of scanned.split('\n')) {
83
+ const line = raw.trim();
84
+ if (!line || line.startsWith('#') || have.has(line) || seen.has(line))
85
+ continue;
86
+ seen.add(line);
87
+ fresh.push(line);
88
+ }
89
+ return fresh;
90
+ }
91
+ /**
92
+ * Merge `ssh-keyscan` output for `host` into the managed store at `file`,
93
+ * idempotently, and report whether `host` is pinned afterward. Split out from
94
+ * {@link pinHostKey} so the store-write half — the part that decides a scanned
95
+ * key now counts as pinned — is unit-testable with real keyscan text and no
96
+ * network (the spawn stays in `pinHostKey`).
97
+ */
98
+ export function recordScannedKeys(host, scanned, file = managedKnownHostsPath()) {
99
+ ensureManagedKnownHostsDir(file);
100
+ const existing = readManagedKnownHosts(file);
101
+ const fresh = newKnownHostsLines(existing, scanned);
102
+ if (fresh.length > 0) {
103
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
104
+ fs.appendFileSync(file, prefix + fresh.join('\n') + '\n', { mode: 0o600 });
105
+ }
106
+ return { pinned: isHostPinned(host, file), added: fresh.length };
107
+ }
108
+ /**
109
+ * `ssh-keyscan` a host at a trusted moment and append any new key lines to the
110
+ * managed store, idempotently. This is the explicit pin path; the implicit one
111
+ * is a normal `accept-new` connection whose learned key lands in the same store.
112
+ * Returns whether the host is pinned afterward.
113
+ *
114
+ * Host-name-agnostic: it scans whatever address it is handed, so it also pins a
115
+ * host `agents ssh` can't reach — notably a bare `~/.ssh/config` `Host` alias,
116
+ * which is not a registered device. The `--copy-creds` gate (commands/exec.ts)
117
+ * calls this for exactly that case so the credential copy is usable for
118
+ * ssh-config-alias hosts (RUSH-1767).
119
+ */
120
+ export function pinHostKey(host, opts = {}) {
121
+ // Same injection guard as sshExec: a host starting with `-` (or carrying shell
122
+ // metacharacters) must never reach ssh-keyscan as a bare argv where it could
123
+ // be parsed as a flag.
124
+ assertValidSshTarget(host);
125
+ const file = opts.file ?? managedKnownHostsPath();
126
+ const timeoutMs = opts.timeoutMs ?? 8000;
127
+ ensureManagedKnownHostsDir(file);
128
+ const args = ['-T', String(Math.max(1, Math.ceil(timeoutMs / 1000)))];
129
+ if (opts.port)
130
+ args.push('-p', String(opts.port));
131
+ args.push(host);
132
+ const res = spawnSync('ssh-keyscan', args, { encoding: 'utf-8', timeout: timeoutMs });
133
+ if (res.status !== 0 || !res.stdout) {
134
+ return { pinned: isHostPinned(host, file), added: 0 };
135
+ }
136
+ return recordScannedKeys(host, res.stdout, file);
137
+ }
@@ -17,6 +17,7 @@ import { resolveRemoteOsSync } from './remote-os.js';
17
17
  import { saveTask, updateTask, terminalPatch } from './tasks.js';
18
18
  import { followHostTask } from './progress.js';
19
19
  import { wrapHostCommandWithCredentials } from './credentials.js';
20
+ import { hostKeyCheckingOpts } from '../devices/known-hosts.js';
20
21
  // Use $HOME (not ~) so the path is correct whether or not it's quoted and
21
22
  // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
22
23
  // injection-safe to interpolate unquoted into remote commands.
@@ -180,10 +181,19 @@ async function launchDetached(host, target, opts) {
180
181
  if (opts.copyCreds) {
181
182
  inner = wrapHostCommandWithCredentials(inner, opts.copyCreds);
182
183
  }
184
+ // When credentials ride this launch, verify the host key strictly against the
185
+ // managed pin (the gate in exec.ts already required the host to be pinned) and
186
+ // force a fresh connection — reusing a control socket opened by an earlier
187
+ // accept-new connection would bypass the strict check (RUSH-1767).
188
+ const credHostKeyOpts = opts.copyCreds ? hostKeyCheckingOpts(true) : undefined;
183
189
  // Outer: ensure dir, launch the login-shell wrapper as a new process-group
184
190
  // leader, and print that leader PID.
185
191
  const launch = `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
186
- const res = sshExec(target, launch, { timeoutMs: 30000, multiplex: true });
192
+ const res = sshExec(target, launch, {
193
+ timeoutMs: 30000,
194
+ multiplex: !opts.copyCreds,
195
+ hostKeyOpts: credHostKeyOpts,
196
+ });
187
197
  if (res.code !== 0) {
188
198
  throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
189
199
  }
@@ -362,7 +372,15 @@ export async function runInteractiveOnHost(host, opts) {
362
372
  if (opts.copyCreds) {
363
373
  remoteCmd = wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds);
364
374
  }
365
- return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
375
+ // Credentials ride this stream when --copy-creds is set: verify the host key
376
+ // strictly against the managed pin and force a fresh connection so a stale
377
+ // accept-new control socket can't bypass the check (RUSH-1767).
378
+ const credHostKeyOpts = opts.copyCreds ? hostKeyCheckingOpts(true) : undefined;
379
+ return sshStream(target, remoteCmd, {
380
+ tty: process.stdin.isTTY,
381
+ multiplex: !opts.copyCreds,
382
+ hostKeyOpts: credHostKeyOpts,
383
+ });
366
384
  }
367
385
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
368
386
  export async function dispatchToHost(host, opts) {
@@ -227,12 +227,16 @@ export function buildWindowsAgentsCommand(cmd) {
227
227
  */
228
228
  export function buildWindowsStdinImportCommand(bundle, opts = {}) {
229
229
  const force = opts.force ? ' --force' : '';
230
+ // Create AND write the temp file INSIDE the try so its finally always cleans
231
+ // up: if GetTempFileName succeeds but WriteAllText (or the import) then throws,
232
+ // the secret-bearing temp file would otherwise be left behind (RUSH-1764). $tmp
233
+ // starts null so a GetTempFileName that itself throws leaves nothing to remove.
230
234
  const script = [
231
235
  '$in = [Console]::In.ReadToEnd()',
232
- '$tmp = [System.IO.Path]::GetTempFileName()',
233
- '[System.IO.File]::WriteAllText($tmp, $in)',
234
- `try { & agents secrets import ${powershellQuote(bundle)} --from $tmp${force}; $code = $LASTEXITCODE } ` +
235
- `finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }`,
236
+ '$tmp = $null',
237
+ `try { $tmp = [System.IO.Path]::GetTempFileName(); [System.IO.File]::WriteAllText($tmp, $in); ` +
238
+ `& agents secrets import ${powershellQuote(bundle)} --from $tmp${force}; $code = $LASTEXITCODE } ` +
239
+ `finally { if ($tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } }`,
236
240
  'if ($null -eq $code) { $code = 1 }',
237
241
  'exit $code',
238
242
  ].join('; ');
@@ -170,6 +170,10 @@ export class MonitorEngine {
170
170
  const windowMs = parseInterval(monitor.rateLimit.per) ?? 60_000;
171
171
  fireTimes = recordFireTime(monitor.name, now, windowMs);
172
172
  if (fireTimes.length > monitor.rateLimit.max) {
173
+ // Record the tripped event in fire history too, so `agents monitors runs`
174
+ // reflects what `view`'s `lastFiredAt` shows — the firehose event the guard
175
+ // exists to surface must not be invisible in the fire log.
176
+ writeFireRecord(event, { action: monitor.action.type, ok: false, error: 'rate limited — auto-paused' });
173
177
  writeState(monitor.name, decision.value, decision.dedupeKey, { lastFiredAt: event.firedAt, fireTimes });
174
178
  try {
175
179
  setMonitorEnabled(monitor.name, false);
@@ -18,9 +18,19 @@ export async function evaluate(source) {
18
18
  const registry = await loadDevices();
19
19
  const wanted = normalizeHost(name);
20
20
  const entry = Object.entries(registry).find(([k]) => normalizeHost(k) === wanted);
21
- const stats = entry && normalizeHost(entry[0]) !== machineId()
22
- ? await probeDeviceStats(entry[1])
23
- : await probeLocalStats(name);
21
+ // An unregistered / removed device must NOT silently fall back to the local
22
+ // machine's stats — that would watch the wrong box under the requested name
23
+ // (the "no fallback logic" convention). `add` validates --watch-device up
24
+ // front, so this catches the device-removed-after-creation case at eval time.
25
+ if (!entry) {
26
+ return {
27
+ raw: `error\tdevice not registered: ${name}`,
28
+ meta: { error: true, reachable: false, device: name },
29
+ };
30
+ }
31
+ const stats = normalizeHost(entry[0]) === machineId()
32
+ ? await probeLocalStats(name)
33
+ : await probeDeviceStats(entry[1]);
24
34
  const bucket = headroom(stats);
25
35
  return {
26
36
  raw: `${stats.reachable ? 'reachable' : 'unreachable'}\t${bucket}`,
@@ -52,3 +52,56 @@ export declare function itemPicker<T>(config: PickerConfig<T>): Promise<PickedIt
52
52
  * row, so a quick single-pick still works.
53
53
  */
54
54
  export declare function multiItemPicker<T>(config: MultiPickerConfig<T>): Promise<T[] | null>;
55
+ /** Configuration for the dynamic (async-refetch) picker prompt. */
56
+ export interface DynamicPickerConfig<T, F> {
57
+ message: string;
58
+ /** The initial filter state. Changing it (via a keybinding) re-runs {@link load}. */
59
+ initialFilter: F;
60
+ /** Async loader for the current filter state. Its result is the row pool. */
61
+ load: (filter: F) => Promise<T[]>;
62
+ labelFor: (item: T, query: string) => string;
63
+ /** Stable identity for an item (used for the active-row cursor across reloads). */
64
+ keyFor: (item: T) => string;
65
+ /** Client-side text filter over the loaded pool (the `S` search). */
66
+ matches?: (item: T, query: string) => boolean;
67
+ buildPreview?: (item: T) => string;
68
+ /** Dim summary of the current filter state, rendered in the header. */
69
+ headerFor?: (filter: F) => string;
70
+ /** The hotkey-legend help line; receives the mode so it can adapt. */
71
+ helpFor?: (filter: F, mode: 'nav' | 'search') => string;
72
+ /**
73
+ * Single-key bindings (by key name) that transform the filter. Returning the
74
+ * SAME reference is a no-op; a new object triggers a reload.
75
+ */
76
+ keyBindings?: Record<string, (filter: F) => F>;
77
+ /**
78
+ * Side-effecting keys that don't change the filter (e.g. `y` copies a command).
79
+ * Receives the live search `query` so the effect can be search-aware. Return a
80
+ * short string to flash under the list.
81
+ */
82
+ onKey?: (name: string, filter: F, active: T | undefined, query: string) => string | void;
83
+ /** Key that enters search mode (default `s`). */
84
+ searchKey?: string;
85
+ /** Key that toggles the preview pane (default `tab`). */
86
+ previewKey?: string;
87
+ pageSize?: number;
88
+ emptyMessage?: string;
89
+ loadingMessage?: string;
90
+ enterHint?: string;
91
+ }
92
+ /** The result returned when the user selects a row: the item plus the live filter. */
93
+ export interface DynamicPicked<T, F> {
94
+ item: T;
95
+ filter: F;
96
+ }
97
+ /**
98
+ * Async-refetch variant of {@link itemPicker}. Holds a `filter` object in state and
99
+ * re-runs `load(filter)` whenever a keybinding mutates it (with a loading placeholder
100
+ * while the fetch — e.g. an SSH fleet fan-out — is in flight). A separate `S` search
101
+ * mode filters the loaded pool client-side. `enter` returns the active row + the live
102
+ * filter; `esc` cancels (from search mode, `esc` first exits search).
103
+ *
104
+ * Same render/pagination/preview machinery as the static pickers — only the data
105
+ * source and keymap are dynamic.
106
+ */
107
+ export declare function dynamicPicker<T, F>(config: DynamicPickerConfig<T, F>): Promise<DynamicPicked<T, F> | null>;