@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.
- package/CHANGELOG.md +45 -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 +71 -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/commands/teams.js +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +28 -0
- package/dist/lib/agents.js +76 -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/exec.d.ts +15 -0
- package/dist/lib/exec.js +31 -6
- 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/runner.js +6 -7
- 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 +20 -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/commands/teams.js
CHANGED
|
@@ -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);
|
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
|
@@ -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 = [
|
|
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
|
+
}
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
|
578
|
-
const resolvedMode =
|
|
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
|
|
613
|
-
//
|
|
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(
|
|
616
|
-
|
|
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, {
|
|
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}`,
|