@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.
- package/CHANGELOG.md +12 -0
- package/README.md +119 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.d.ts +48 -0
- package/dist/commands/exec.js +65 -0
- package/dist/commands/monitors.js +8 -0
- package/dist/commands/plugins.js +28 -7
- package/dist/commands/sessions-browser.d.ts +82 -0
- package/dist/commands/sessions-browser.js +320 -0
- package/dist/commands/sessions.d.ts +1 -0
- package/dist/commands/sessions.js +121 -4
- package/dist/commands/share.d.ts +2 -0
- package/dist/commands/share.js +150 -0
- package/dist/commands/ssh.js +9 -1
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +28 -0
- package/dist/lib/agents.js +68 -0
- package/dist/lib/devices/connect.d.ts +18 -1
- package/dist/lib/devices/connect.js +10 -2
- package/dist/lib/devices/known-hosts.d.ts +62 -0
- package/dist/lib/devices/known-hosts.js +137 -0
- package/dist/lib/hosts/dispatch.js +20 -2
- package/dist/lib/hosts/remote-cmd.js +8 -4
- package/dist/lib/monitors/engine.js +4 -0
- package/dist/lib/monitors/sources/device.js +13 -3
- package/dist/lib/picker.d.ts +53 -0
- package/dist/lib/picker.js +214 -1
- package/dist/lib/plugins.d.ts +31 -1
- package/dist/lib/plugins.js +74 -13
- package/dist/lib/secrets/agent.d.ts +35 -0
- package/dist/lib/secrets/agent.js +114 -55
- package/dist/lib/secrets/filestore.d.ts +9 -0
- package/dist/lib/secrets/filestore.js +21 -8
- package/dist/lib/secrets/index.d.ts +7 -0
- package/dist/lib/secrets/index.js +26 -6
- package/dist/lib/share/capture.d.ts +29 -0
- package/dist/lib/share/capture.js +140 -0
- package/dist/lib/share/config.d.ts +35 -0
- package/dist/lib/share/config.js +100 -0
- package/dist/lib/share/og.d.ts +25 -0
- package/dist/lib/share/og.js +84 -0
- package/dist/lib/share/provision.d.ts +10 -0
- package/dist/lib/share/provision.js +91 -0
- package/dist/lib/share/publish.d.ts +57 -0
- package/dist/lib/share/publish.js +145 -0
- package/dist/lib/share/worker-template.d.ts +2 -0
- package/dist/lib/share/worker-template.js +82 -0
- package/dist/lib/shims.d.ts +13 -0
- package/dist/lib/shims.js +42 -2
- package/dist/lib/ssh-exec.d.ts +24 -0
- package/dist/lib/ssh-exec.js +15 -3
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/commands/ssh.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -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:
|
package/dist/lib/agents.js
CHANGED
|
@@ -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 = [
|
|
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, {
|
|
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
|
-
|
|
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 =
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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}`,
|
package/dist/lib/picker.d.ts
CHANGED
|
@@ -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>;
|