@phnx-labs/agents-cli 1.20.65 → 1.20.67

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 (59) hide show
  1. package/CHANGELOG.md +45 -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 +71 -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/commands/teams.js +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/lib/agents.d.ts +28 -0
  18. package/dist/lib/agents.js +76 -0
  19. package/dist/lib/devices/connect.d.ts +18 -1
  20. package/dist/lib/devices/connect.js +10 -2
  21. package/dist/lib/devices/known-hosts.d.ts +62 -0
  22. package/dist/lib/devices/known-hosts.js +137 -0
  23. package/dist/lib/exec.d.ts +15 -0
  24. package/dist/lib/exec.js +31 -6
  25. package/dist/lib/hosts/dispatch.js +20 -2
  26. package/dist/lib/hosts/remote-cmd.js +8 -4
  27. package/dist/lib/monitors/engine.js +4 -0
  28. package/dist/lib/monitors/sources/device.js +13 -3
  29. package/dist/lib/picker.d.ts +53 -0
  30. package/dist/lib/picker.js +214 -1
  31. package/dist/lib/plugins.d.ts +31 -1
  32. package/dist/lib/plugins.js +74 -13
  33. package/dist/lib/runner.js +6 -7
  34. package/dist/lib/secrets/agent.d.ts +35 -0
  35. package/dist/lib/secrets/agent.js +114 -55
  36. package/dist/lib/secrets/filestore.d.ts +9 -0
  37. package/dist/lib/secrets/filestore.js +21 -8
  38. package/dist/lib/secrets/index.d.ts +7 -0
  39. package/dist/lib/secrets/index.js +26 -6
  40. package/dist/lib/share/capture.d.ts +29 -0
  41. package/dist/lib/share/capture.js +140 -0
  42. package/dist/lib/share/config.d.ts +35 -0
  43. package/dist/lib/share/config.js +100 -0
  44. package/dist/lib/share/og.d.ts +25 -0
  45. package/dist/lib/share/og.js +84 -0
  46. package/dist/lib/share/provision.d.ts +10 -0
  47. package/dist/lib/share/provision.js +91 -0
  48. package/dist/lib/share/publish.d.ts +57 -0
  49. package/dist/lib/share/publish.js +145 -0
  50. package/dist/lib/share/worker-template.d.ts +2 -0
  51. package/dist/lib/share/worker-template.js +82 -0
  52. package/dist/lib/shims.d.ts +13 -0
  53. package/dist/lib/shims.js +42 -2
  54. package/dist/lib/ssh-exec.d.ts +24 -0
  55. package/dist/lib/ssh-exec.js +15 -3
  56. package/dist/lib/startup/command-registry.d.ts +1 -0
  57. package/dist/lib/startup/command-registry.js +2 -0
  58. package/dist/lib/types.d.ts +20 -0
  59. 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 },
@@ -1059,7 +1059,7 @@ export function registerTeamsCommands(program) {
1059
1059
  .alias('a')
1060
1060
  .description("Add a teammate to work on a task. Runs in background; returns immediately. Use 'status' to check in.")
1061
1061
  .option('-n, --name <name>', 'Friendly name for this teammate (e.g. alice). Required if using --after. Unique within team.')
1062
- .option('-m, --mode <mode>', `Permissions: plan (read-only) | edit (can write files) | auto (smart classifier auto-approves safe ops) | skip (bypass all permission prompts). 'full' accepted as alias for skip.`, 'edit')
1062
+ .option('-m, --mode <mode>', `Permissions: plan (read-only) | edit (can write files) | auto (smart classifier auto-approves safe ops) | skip (bypass all permission prompts). 'full' accepted as alias for skip. Teammates run headless: plan works headless on claude/codex/droid/opencode; kimi/grok/cursor/antigravity have no headless plan mode and auto-downgrade a plan request to auto.`, 'edit')
1063
1063
  .option('-e, --effort <effort>', `Reasoning intensity: ${VALID_EFFORTS.join('|')}`, 'medium')
1064
1064
  .option('--model <model>', 'Override the effort tier and use this specific model (e.g. claude-opus-4-6)')
1065
1065
  .option('--env <key=value>', 'Set an environment variable for this teammate (repeatable for multiple vars)', (val, prev) => [...prev, val], [])
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:
@@ -505,6 +505,10 @@ export const AGENTS = {
505
505
  workflows: false,
506
506
  memory: true,
507
507
  modes: ['plan', 'edit', 'skip'],
508
+ // grok's `--permission-mode plan` silently stalls a headless `-p` run at
509
+ // its ExitPlanMode gate (no TTY to approve). Headless plan auto-downgrades
510
+ // to auto (→ edit via resolveMode). Interactive plan is unaffected.
511
+ headlessPlan: false,
508
512
  rulesImports: true,
509
513
  },
510
514
  },
@@ -544,6 +548,10 @@ export const AGENTS = {
544
548
  workflows: true,
545
549
  memory: false,
546
550
  modes: ['plan', 'edit', 'auto', 'skip'],
551
+ // kimi's headless `-p` refuses to combine with `--plan` (`Cannot combine
552
+ // --prompt with --plan`). Headless plan auto-downgrades to auto (kimi -p
553
+ // auto-runs). Interactive plan is unaffected.
554
+ headlessPlan: false,
547
555
  rulesImports: false,
548
556
  },
549
557
  },
@@ -1036,6 +1044,18 @@ export function decryptDroidAuthPayload(base) {
1036
1044
  const keyPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.key');
1037
1045
  if (!filePath || !keyPath)
1038
1046
  return null;
1047
+ return decryptDroidAuthFile(filePath, keyPath);
1048
+ }
1049
+ /**
1050
+ * Decrypt a Droid `auth.v2.file` (AES-256-GCM `ivB64:tagB64:ctB64`) using the
1051
+ * raw 32-byte key stored base64 in `auth.v2.key`, given the EXACT paths to both.
1052
+ * Same crypto as decryptDroidAuthPayload but without the account-global HOME
1053
+ * fallback, so the identity of a SPECIFIC version home resolves against only
1054
+ * that home's files (carryForwardAuthFiles needs per-dir identity). Returns null
1055
+ * on any failure (missing file/key, wrong key length, bad GCM tag, malformed
1056
+ * JSON). Never throws.
1057
+ */
1058
+ export function decryptDroidAuthFile(filePath, keyPath) {
1039
1059
  try {
1040
1060
  const blob = fs.readFileSync(filePath, 'utf-8').trim();
1041
1061
  const key = Buffer.from(fs.readFileSync(keyPath, 'utf-8').trim(), 'base64');
@@ -1057,6 +1077,62 @@ export function decryptDroidAuthPayload(base) {
1057
1077
  return null;
1058
1078
  }
1059
1079
  }
1080
+ /**
1081
+ * Stable account identity for a *file-auth* agent's credential directory
1082
+ * (droid / kimi / antigravity), or null when the directory holds no decodable
1083
+ * account claim. Unlike a naive top-level JSON key-scan (which matched NO real
1084
+ * credential file), this decrypts / decodes each agent's REAL on-disk format so
1085
+ * the identity resolves against production credentials:
1086
+ * - droid: AES-256-GCM auth.v2.file (+ auth.v2.key) -> WorkOS access-token JWT
1087
+ * -> email / org_id / sub.
1088
+ * - kimi: credentials/kimi-code.json -> access-token JWT -> user_id / sub.
1089
+ * - antigravity: antigravity-oauth-token -> token.refresh_token -> JWT sub
1090
+ * when the token is a JWT, else the raw refresh-token value (opaque Google
1091
+ * consumer tokens are stable per login).
1092
+ * Two directories for the SAME account compare equal; two DIFFERENT accounts
1093
+ * compare distinct. Used by carryForwardAuthFiles to refuse overwriting one
1094
+ * account's login with a credential that belongs to a DIFFERENT account
1095
+ * (RUSH-1764). Never throws.
1096
+ */
1097
+ export function readAuthAccountIdentity(agent, configDir) {
1098
+ try {
1099
+ switch (agent) {
1100
+ case 'droid': {
1101
+ const payload = decryptDroidAuthFile(path.join(configDir, 'auth.v2.file'), path.join(configDir, 'auth.v2.key'));
1102
+ const claims = typeof payload?.access_token === 'string' ? decodeJwtPayload(payload.access_token) : null;
1103
+ if (!claims)
1104
+ return null;
1105
+ return buildIdentityKey(agent, [
1106
+ ['email', normalizeIdentityPart(claims.email)],
1107
+ ['org', normalizeIdentityPart(claims.org_id ?? payload?.active_organization_id)],
1108
+ ['sub', normalizeIdentityPart(claims.sub)],
1109
+ ]);
1110
+ }
1111
+ case 'kimi': {
1112
+ const data = JSON.parse(fs.readFileSync(path.join(configDir, 'credentials', 'kimi-code.json'), 'utf-8'));
1113
+ const accessToken = data?.access_token;
1114
+ const claims = typeof accessToken === 'string' ? decodeJwtPayload(accessToken) : null;
1115
+ return buildIdentityKey(agent, [
1116
+ ['user', normalizeIdentityPart(claims?.user_id ?? claims?.sub)],
1117
+ ]);
1118
+ }
1119
+ case 'antigravity': {
1120
+ const data = JSON.parse(fs.readFileSync(path.join(configDir, 'antigravity-oauth-token'), 'utf-8'));
1121
+ const refreshToken = data?.token?.refresh_token;
1122
+ if (typeof refreshToken !== 'string' || !refreshToken)
1123
+ return null;
1124
+ const claims = decodeJwtPayload(refreshToken);
1125
+ const sub = normalizeIdentityPart(claims?.sub ?? claims?.user_id);
1126
+ return buildIdentityKey(agent, [['sub', sub ?? refreshToken]]);
1127
+ }
1128
+ default:
1129
+ return null;
1130
+ }
1131
+ }
1132
+ catch {
1133
+ return null;
1134
+ }
1135
+ }
1060
1136
  /**
1061
1137
  * Derive Droid account identity from the decrypted credential. The
1062
1138
  * `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
+ }
@@ -52,6 +52,21 @@ export declare function headlessPlanStallCommand(args: {
52
52
  * explicitly asked to bypass permissions; pretending we did is unsafe.
53
53
  */
54
54
  export declare function resolveMode(agent: AgentId, requested: Mode): Mode;
55
+ /**
56
+ * Resolve a requested mode for a run, honoring whether the run is HEADLESS.
57
+ *
58
+ * Wraps resolveMode with one extra rule: an agent may list `plan` in its modes
59
+ * (so interactive plan works) yet declare `capabilities.headlessPlan === false`
60
+ * because plan is broken in a headless `--prompt`/`-p` run — kimi refuses
61
+ * `--prompt` + `--plan`, and grok's `--permission-mode plan` silently stalls at
62
+ * its ExitPlanMode gate. For those agents, a headless plan request degrades to
63
+ * `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
64
+ * one-line stderr warning, mirroring the graceful plan→edit degrade cursor and
65
+ * antigravity get for having no plan flag at all. Interactive runs are never
66
+ * downgraded. This is the single source of truth shared by buildExecCommand
67
+ * (agents run / teams) and the routine runner.
68
+ */
69
+ export declare function resolveHeadlessMode(agent: AgentId, requested: Mode, interactive: boolean): Mode;
55
70
  /**
56
71
  * The mode an agent should run in when the caller has no preference.
57
72
  *
package/dist/lib/exec.js CHANGED
@@ -104,6 +104,28 @@ export function resolveMode(agent, requested) {
104
104
  }
105
105
  throw new Error(`${agent} does not support '${requested}' mode. Supported modes: ${supported.join(', ')}.`);
106
106
  }
107
+ /**
108
+ * Resolve a requested mode for a run, honoring whether the run is HEADLESS.
109
+ *
110
+ * Wraps resolveMode with one extra rule: an agent may list `plan` in its modes
111
+ * (so interactive plan works) yet declare `capabilities.headlessPlan === false`
112
+ * because plan is broken in a headless `--prompt`/`-p` run — kimi refuses
113
+ * `--prompt` + `--plan`, and grok's `--permission-mode plan` silently stalls at
114
+ * its ExitPlanMode gate. For those agents, a headless plan request degrades to
115
+ * `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
116
+ * one-line stderr warning, mirroring the graceful plan→edit degrade cursor and
117
+ * antigravity get for having no plan flag at all. Interactive runs are never
118
+ * downgraded. This is the single source of truth shared by buildExecCommand
119
+ * (agents run / teams) and the routine runner.
120
+ */
121
+ export function resolveHeadlessMode(agent, requested, interactive) {
122
+ const mode = resolveMode(agent, requested);
123
+ if (!interactive && mode === 'plan' && AGENTS[agent].capabilities.headlessPlan === false) {
124
+ process.stderr.write(`warning: ${agent} has no headless plan mode; running --mode auto instead\n`);
125
+ return resolveMode(agent, 'auto');
126
+ }
127
+ return mode;
128
+ }
107
129
  /**
108
130
  * The mode an agent should run in when the caller has no preference.
109
131
  *
@@ -573,9 +595,11 @@ export function buildExecCommand(options) {
573
595
  // Resolve the requested mode against the agent's capability table.
574
596
  // - `auto` on an agent without auto support → silently degrades to `edit`
575
597
  // - `plan` on an agent without a read-only mode → degrades to modes[0]
598
+ // - headless `plan` on an agent with headlessPlan:false (kimi, grok) →
599
+ // degrades to `auto` with a stderr warning (see resolveHeadlessMode)
576
600
  // - `skip` on an unsupported agent → throws a clear error
577
- // After resolveMode, the chosen mode is guaranteed to be in template.modeFlags.
578
- const resolvedMode = resolveMode(options.agent, normalizeMode(options.mode));
601
+ // After resolution, the chosen mode is guaranteed to be in template.modeFlags.
602
+ const resolvedMode = resolveHeadlessMode(options.agent, normalizeMode(options.mode), interactive);
579
603
  const modeFlags = template.modeFlags[resolvedMode];
580
604
  if (!modeFlags) {
581
605
  // Defense in depth: would only fire if AGENTS.capabilities.modes and
@@ -609,11 +633,12 @@ export function buildExecCommand(options) {
609
633
  // all abort with "Cannot combine --prompt with --X" (verified against the live
610
634
  // kimi CLI). The write-capable modes (edit/auto/skip) all collapse to kimi's
611
635
  // default `-p` behavior, which already auto-approves tool calls, so we emit no
612
- // mode flag. Plan (read-only) has no headless equivalent, so fail closed rather
613
- // than silently letting a plan-mode run mutate the workspace.
636
+ // mode flag. Plan can't reach here headless resolveHeadlessMode already
637
+ // downgraded it to auto (kimi's headlessPlan:false); this asserts that
638
+ // invariant so a plan-mode run can never silently mutate the workspace.
614
639
  if (resolvedMode === 'plan') {
615
- throw new Error('kimi has no headless read-only mode: `--prompt` cannot be combined with `--plan`. ' +
616
- 'Run kimi in plan mode interactively (omit the prompt), or use --mode edit, auto, or skip.');
640
+ throw new Error(`Internal error: kimi reached headless command build with resolved mode 'plan'; ` +
641
+ `resolveHeadlessMode should have downgraded it to auto (capabilities.headlessPlan is false).`);
617
642
  }
618
643
  // edit/auto/skip: emit no mode flag — `kimi -p` auto-runs.
619
644
  }
@@ -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}`,