@phnx-labs/agents-cli 1.22.24 → 1.22.26
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 +350 -1
- package/README.md +31 -6
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/browser.js +35 -0
- package/dist/commands/cloud.js +9 -5
- package/dist/commands/doctor.d.ts +24 -0
- package/dist/commands/doctor.js +108 -9
- package/dist/commands/exec.js +19 -16
- package/dist/commands/feed.d.ts +5 -0
- package/dist/commands/feed.js +21 -1
- package/dist/commands/focus.js +2 -2
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +107 -33
- package/dist/commands/menubar.js +8 -0
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/routines.d.ts +3 -0
- package/dist/commands/routines.js +72 -49
- package/dist/commands/run-cloud.js +1 -1
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions-browser.d.ts +1 -1
- package/dist/commands/sessions-browser.js +27 -7
- package/dist/commands/sessions-resume.js +3 -2
- package/dist/commands/sessions.d.ts +13 -1
- package/dist/commands/sessions.js +28 -2
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup-watchdog.js +5 -10
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +106 -47
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +46 -2
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/commands/watchdog.d.ts +3 -4
- package/dist/commands/watchdog.js +26 -66
- package/dist/index.js +37 -2
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +155 -23
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/cloud/cursor.d.ts +79 -0
- package/dist/lib/cloud/cursor.js +228 -0
- package/dist/lib/cloud/registry.js +2 -0
- package/dist/lib/cloud/types.d.ts +7 -2
- package/dist/lib/cloud/types.js +14 -0
- package/dist/lib/crabbox/cli.d.ts +2 -2
- package/dist/lib/crabbox/config.d.ts +7 -8
- package/dist/lib/crabbox/config.js +14 -14
- package/dist/lib/crabbox/lease.d.ts +11 -4
- package/dist/lib/crabbox/lease.js +40 -8
- package/dist/lib/crabbox/setup-copy.d.ts +5 -0
- package/dist/lib/crabbox/setup-copy.js +17 -1
- package/dist/lib/daemon.js +27 -1
- package/dist/lib/device-config.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +13 -1
- package/dist/lib/devices/doctor-findings.js +73 -2
- package/dist/lib/events.d.ts +9 -0
- package/dist/lib/events.js +58 -0
- package/dist/lib/exec.d.ts +3 -3
- package/dist/lib/exec.js +24 -10
- package/dist/lib/feed-outcome.d.ts +3 -0
- package/dist/lib/feed-outcome.js +18 -10
- package/dist/lib/feed.d.ts +4 -0
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +183 -6
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hosts/passthrough.d.ts +44 -0
- package/dist/lib/hosts/passthrough.js +84 -12
- package/dist/lib/hosts/ready.d.ts +2 -0
- package/dist/lib/hosts/ready.js +10 -1
- package/dist/lib/hosts/reconnect.d.ts +14 -12
- package/dist/lib/hosts/reconnect.js +41 -40
- package/dist/lib/mcp.js +5 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/snapshot.d.ts +15 -0
- package/dist/lib/menubar/snapshot.js +40 -0
- package/dist/lib/plugins.js +13 -1
- package/dist/lib/resources/mcp.js +3 -0
- package/dist/lib/routine-process-cleanup.d.ts +9 -0
- package/dist/lib/routine-process-cleanup.js +73 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +0 -3
- package/dist/lib/runner.js +6 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +1 -20
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +13 -0
- package/dist/lib/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
- package/dist/lib/secrets/rc-hygiene.js +14 -1
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/session/active.d.ts +4 -0
- package/dist/lib/session/db.d.ts +21 -3
- package/dist/lib/session/db.js +229 -15
- package/dist/lib/session/discover.d.ts +1 -0
- package/dist/lib/session/discover.js +115 -19
- package/dist/lib/session/insights.d.ts +18 -0
- package/dist/lib/session/insights.js +143 -1
- package/dist/lib/session/remote-list.d.ts +2 -0
- package/dist/lib/session/remote-list.js +1 -0
- package/dist/lib/session/session-cache.d.ts +4 -4
- package/dist/lib/session/session-cache.js +4 -4
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/shims.js +21 -1
- package/dist/lib/signin-badge.js +2 -0
- package/dist/lib/ssh-exec.js +8 -2
- package/dist/lib/startup/command-registry.d.ts +16 -0
- package/dist/lib/startup/command-registry.js +46 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +76 -8
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/versions.js +16 -1
- package/dist/lib/watchdog/service.d.ts +17 -0
- package/dist/lib/watchdog/service.js +39 -0
- package/package.json +1 -1
package/dist/lib/feed-outcome.js
CHANGED
|
@@ -152,21 +152,22 @@ export function groupBlocksByOutcome(blocks) {
|
|
|
152
152
|
const groups = [];
|
|
153
153
|
for (const { outcome, blocks: members } of byKey.values()) {
|
|
154
154
|
const mailboxes = new Set(members.map((b) => b.mailboxId));
|
|
155
|
-
|
|
156
|
-
let answered = 0;
|
|
157
|
-
let parked = 0;
|
|
155
|
+
const states = new Map();
|
|
158
156
|
for (const b of members) {
|
|
159
|
-
|
|
160
|
-
parked
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
157
|
+
const next = b.parkedAt
|
|
158
|
+
? 'parked'
|
|
159
|
+
: b.answer || b.defaultedAt || b.continuedAt ? 'answered' : 'open';
|
|
160
|
+
const current = states.get(b.mailboxId);
|
|
161
|
+
// One agent occupies one state. Its most actionable block wins.
|
|
162
|
+
if (!current || next === 'open' || (next === 'parked' && current === 'answered')) {
|
|
163
|
+
states.set(b.mailboxId, next);
|
|
164
|
+
}
|
|
165
165
|
}
|
|
166
|
+
const count = (state) => [...states.values()].filter((value) => value === state).length;
|
|
166
167
|
groups.push({
|
|
167
168
|
outcome,
|
|
168
169
|
blocks: members,
|
|
169
|
-
counts: { agents: mailboxes.size, open, answered, parked },
|
|
170
|
+
counts: { agents: mailboxes.size, open: count('open'), answered: count('answered'), parked: count('parked') },
|
|
170
171
|
});
|
|
171
172
|
}
|
|
172
173
|
groups.sort((a, b) => {
|
|
@@ -222,6 +223,13 @@ export function enrichBlockFromSession(block, hint) {
|
|
|
222
223
|
next.worktreeSlug = hint.worktreeSlug;
|
|
223
224
|
if (!next.project && hint.project)
|
|
224
225
|
next.project = hint.project;
|
|
226
|
+
if (hint.host && hint.host !== 'terminal' && next.runtime === 'terminal')
|
|
227
|
+
next.runtime = hint.host;
|
|
228
|
+
if (hint.origin === 'routine') {
|
|
229
|
+
next.origin = 'routine';
|
|
230
|
+
if (hint.routineName)
|
|
231
|
+
next.routineName = hint.routineName;
|
|
232
|
+
}
|
|
225
233
|
return next;
|
|
226
234
|
}
|
|
227
235
|
/**
|
package/dist/lib/feed.d.ts
CHANGED
|
@@ -36,6 +36,10 @@ export interface OpenBlock {
|
|
|
36
36
|
mailboxId: string;
|
|
37
37
|
host: string;
|
|
38
38
|
runtime: string;
|
|
39
|
+
/** Indexed launch origin, added at read time when the live session is known. */
|
|
40
|
+
origin?: 'cli' | 'routine';
|
|
41
|
+
/** Routine definition name when origin is `routine`. */
|
|
42
|
+
routineName?: string;
|
|
39
43
|
/** Project/repo name this block belongs to (derived from cwd, worktree-aware). */
|
|
40
44
|
project?: string;
|
|
41
45
|
ts: string;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
|
|
9
9
|
*/
|
|
10
10
|
import type { DeviceProfile } from '../devices/registry.js';
|
|
11
|
+
import { type RemoteBackend } from '../secrets/push.js';
|
|
11
12
|
import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
|
|
12
13
|
/** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
|
|
13
14
|
export declare function agentIdOf(spec: string): string;
|
|
@@ -54,21 +55,76 @@ export interface DiffContext {
|
|
|
54
55
|
/** agents-cli version the source is on — the fleet target version. */
|
|
55
56
|
targetCliVersion: string;
|
|
56
57
|
sourceAuth: SourceAuth;
|
|
57
|
-
/** Secrets-bundle names the profile declares.
|
|
58
|
-
* can't be pushed, so each reachable device surfaces them as a manual recreate
|
|
59
|
-
* (`needs-secret`) — informational, never an executed mutation. */
|
|
58
|
+
/** Secrets-bundle names the profile declares. */
|
|
60
59
|
secretsBundles?: string[];
|
|
60
|
+
/** `--provision-secrets`. OFF by default: pushing a bundle moves credential
|
|
61
|
+
* VALUES to another machine, so it is opted into per invocation and never
|
|
62
|
+
* defaulted from the shared `agents.yaml` (RUSH-1968). */
|
|
63
|
+
provisionSecrets?: boolean;
|
|
64
|
+
/** Is this device's host key pinned? Injected so `decideSecretPush` stays pure
|
|
65
|
+
* and its refusals are testable against real known_hosts fixtures with no
|
|
66
|
+
* network. Absent = treated as unpinned, i.e. refuse. */
|
|
67
|
+
isHostPinned?: (device: string) => boolean;
|
|
68
|
+
/** `--force`: push a declared bundle even when the device already has it. */
|
|
69
|
+
forceSecrets?: boolean;
|
|
61
70
|
}
|
|
62
71
|
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
63
72
|
export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
|
|
73
|
+
/** Why a declared bundle is or is not pushed to one device. */
|
|
74
|
+
export interface SecretPushDecision {
|
|
75
|
+
push: boolean;
|
|
76
|
+
/** Where it would land on the remote. Only meaningful when `push`. */
|
|
77
|
+
backend: RemoteBackend;
|
|
78
|
+
/** Set when `push` is false — rendered as the `needs-secret` reminder. */
|
|
79
|
+
reason: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether `fleet apply` may push one declared bundle to one device.
|
|
83
|
+
*
|
|
84
|
+
* PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
|
|
85
|
+
* every branch is unit-testable with no live fleet. Three gates, in order, and
|
|
86
|
+
* each REFUSAL still yields a `needs-secret` reminder rather than silence:
|
|
87
|
+
*
|
|
88
|
+
* 1. `--provision-secrets` must be set. Off by default, and deliberately a
|
|
89
|
+
* flag rather than an `agents.yaml` field: the manifest is shared, so a
|
|
90
|
+
* file-level default means someone else's `apply -y` ships credential
|
|
91
|
+
* values without deciding to (RUSH-1968's shape of accident).
|
|
92
|
+
* 2. The device must be reachable — nothing to push to otherwise.
|
|
93
|
+
* 3. The host key must be PINNED. This moves credential values to another
|
|
94
|
+
* machine, so it reuses the same bar `agents exec --copy-creds` already
|
|
95
|
+
* sets (EXEC-34): an unpinned device earns its pin through a normal
|
|
96
|
+
* `agents ssh <device>` first.
|
|
97
|
+
*
|
|
98
|
+
* Backend follows the platform, and this is the load-bearing default of the
|
|
99
|
+
* whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
|
|
100
|
+
* box has no keychain (`lib/secrets/linux.ts`), and the file store there
|
|
101
|
+
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
102
|
+
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
103
|
+
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
104
|
+
*/
|
|
105
|
+
export declare function decideSecretPush(bundle: string, desired: DeviceDesired, probe: DeviceProbe, ctx: DiffContext): SecretPushDecision;
|
|
64
106
|
export interface ProbeOptions {
|
|
65
107
|
/** Also fetch per-agent installed versions (one extra `agents view --json`
|
|
66
108
|
* round-trip). Enable only when the plan has a version-pinned spec. */
|
|
67
109
|
withVersions?: boolean;
|
|
110
|
+
/** Also fetch which secrets bundles the device already has (one extra
|
|
111
|
+
* `agents secrets list --json`). Enable only when the manifest declares
|
|
112
|
+
* bundles and provisioning is on — same cost discipline as `withVersions`. */
|
|
113
|
+
withSecrets?: boolean;
|
|
68
114
|
}
|
|
69
115
|
/** Probe one device: reachability + agents-cli version + installed agent ids
|
|
70
116
|
* (and, when `withVersions`, the installed version strings per agent). */
|
|
71
117
|
export declare function probeDevice(device: DeviceProfile, opts?: ProbeOptions): DeviceProbe;
|
|
118
|
+
/**
|
|
119
|
+
* Narrow a remote `secrets list --json` payload to `name -> updated_at`.
|
|
120
|
+
*
|
|
121
|
+
* Exported and pure so the parse is unit-tested against real payload shapes with
|
|
122
|
+
* no live fleet. Returns `{}` rather than throwing on anything unexpected: the
|
|
123
|
+
* remote runs its own agents-cli version, and a parse failure must degrade to
|
|
124
|
+
* "unknown, so push" — never to "present, so skip", which would silently leave a
|
|
125
|
+
* device unprovisioned.
|
|
126
|
+
*/
|
|
127
|
+
export declare function parseRemoteBundles(stdout: string): Record<string, string>;
|
|
72
128
|
export interface ApplyStep {
|
|
73
129
|
kind: FleetAction['kind'];
|
|
74
130
|
ok: boolean;
|
package/dist/lib/fleet/apply.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
|
|
9
9
|
*/
|
|
10
10
|
import * as os from 'os';
|
|
11
|
+
import { pushBundleToHost } from '../secrets/push.js';
|
|
11
12
|
import { sshTargetFor } from '../devices/connect.js';
|
|
12
13
|
import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
|
|
13
14
|
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
@@ -167,14 +168,33 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
|
-
// secrets
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
171
|
+
// secrets. Declared once at the manifest level, so every reachable device
|
|
172
|
+
// is considered. Historically this was ALWAYS a manual reminder — "surfaced,
|
|
173
|
+
// never pushed" — and that gap is a direct cause of RUSH-1968: an operator
|
|
174
|
+
// who needed secrets on a worker box had no supported path, so they
|
|
175
|
+
// hand-exported the file store's master key across the fleet instead.
|
|
176
|
+
//
|
|
177
|
+
// It is now pushable, but only deliberately. `--provision-secrets` is off by
|
|
178
|
+
// default and is a FLAG, not a manifest field: `agents.yaml` is shared, and
|
|
179
|
+
// a file-level default would mean someone else's `apply -y` silently ships
|
|
180
|
+
// credential values — the same shape of accident this ticket is about.
|
|
181
|
+
// Everything the gate refuses stays a `needs-secret` reminder, so nothing
|
|
182
|
+
// is ever silently skipped.
|
|
174
183
|
if (ctx.secretsBundles && ctx.secretsBundles.length > 0) {
|
|
175
184
|
for (const bundle of ctx.secretsBundles) {
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
const decision = decideSecretPush(bundle, d, probe, ctx);
|
|
186
|
+
if (decision.push) {
|
|
187
|
+
rowActions.push({
|
|
188
|
+
device: d.device,
|
|
189
|
+
kind: 'push-secret',
|
|
190
|
+
bundle,
|
|
191
|
+
detail: `push secrets bundle '${bundle}' (${decision.backend} backend)`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
secretsNeeded.push(bundle);
|
|
196
|
+
rowActions.push({ device: d.device, kind: 'needs-secret', bundle, detail: decision.reason });
|
|
197
|
+
}
|
|
178
198
|
}
|
|
179
199
|
}
|
|
180
200
|
}
|
|
@@ -183,6 +203,66 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
183
203
|
}
|
|
184
204
|
return { devices, actions };
|
|
185
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Decide whether `fleet apply` may push one declared bundle to one device.
|
|
208
|
+
*
|
|
209
|
+
* PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
|
|
210
|
+
* every branch is unit-testable with no live fleet. Three gates, in order, and
|
|
211
|
+
* each REFUSAL still yields a `needs-secret` reminder rather than silence:
|
|
212
|
+
*
|
|
213
|
+
* 1. `--provision-secrets` must be set. Off by default, and deliberately a
|
|
214
|
+
* flag rather than an `agents.yaml` field: the manifest is shared, so a
|
|
215
|
+
* file-level default means someone else's `apply -y` ships credential
|
|
216
|
+
* values without deciding to (RUSH-1968's shape of accident).
|
|
217
|
+
* 2. The device must be reachable — nothing to push to otherwise.
|
|
218
|
+
* 3. The host key must be PINNED. This moves credential values to another
|
|
219
|
+
* machine, so it reuses the same bar `agents exec --copy-creds` already
|
|
220
|
+
* sets (EXEC-34): an unpinned device earns its pin through a normal
|
|
221
|
+
* `agents ssh <device>` first.
|
|
222
|
+
*
|
|
223
|
+
* Backend follows the platform, and this is the load-bearing default of the
|
|
224
|
+
* whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
|
|
225
|
+
* box has no keychain (`lib/secrets/linux.ts`), and the file store there
|
|
226
|
+
* auto-provisions its OWN machine-local key — so each box ends up with an
|
|
227
|
+
* unshared at-rest key and NO passphrase is forwarded. That is the direct
|
|
228
|
+
* alternative to the fleet-wide shared secret this ticket exists to remove.
|
|
229
|
+
*/
|
|
230
|
+
export function decideSecretPush(bundle, desired, probe, ctx) {
|
|
231
|
+
const device = desired.device;
|
|
232
|
+
const backend = probe.platform === 'linux' ? 'file' : 'keychain';
|
|
233
|
+
const manual = `recreate secrets bundle '${bundle}' (\`agents ssh ${device} -- secrets create ${bundle}\`)`;
|
|
234
|
+
if (!ctx.provisionSecrets) {
|
|
235
|
+
return { push: false, backend, reason: `${manual} — or re-run with --provision-secrets to push it` };
|
|
236
|
+
}
|
|
237
|
+
if (!probe.reachable) {
|
|
238
|
+
return { push: false, backend, reason: manual };
|
|
239
|
+
}
|
|
240
|
+
// Already there? Skip — otherwise every `apply` re-resolves the bundle, and a
|
|
241
|
+
// resolve can prompt for Touch ID, so a converged fleet would nag on every run.
|
|
242
|
+
//
|
|
243
|
+
// Known limitation, stated rather than hidden: this compares PRESENCE (and
|
|
244
|
+
// carries `updated_at` for a future content check). It is a timestamp
|
|
245
|
+
// heuristic, not a content hash — a bundle whose VALUES changed locally still
|
|
246
|
+
// reads as present. `--force` is the way to overwrite regardless.
|
|
247
|
+
// hasOwnProperty, NOT `in`: `in` walks the prototype chain, so a bundle named
|
|
248
|
+
// `toString` / `constructor` / `valueOf` would read as present on an EMPTY map
|
|
249
|
+
// and be silently skipped — leaving that device unprovisioned, the worse of the
|
|
250
|
+
// two errors this gate can make.
|
|
251
|
+
if (!ctx.forceSecrets && probe.remoteBundles
|
|
252
|
+
&& Object.prototype.hasOwnProperty.call(probe.remoteBundles, bundle)) {
|
|
253
|
+
return { push: false, backend, reason: `secrets bundle '${bundle}' already present on ${device} — pass --force to overwrite` };
|
|
254
|
+
}
|
|
255
|
+
if (!ctx.isHostPinned?.(device)) {
|
|
256
|
+
// Same bar as `exec --copy-creds`: never ship credential values to a host
|
|
257
|
+
// whose key we have not pinned.
|
|
258
|
+
return {
|
|
259
|
+
push: false,
|
|
260
|
+
backend,
|
|
261
|
+
reason: `${manual} — host key not pinned; run \`agents ssh ${device}\` once to pin it, then re-apply`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return { push: true, backend, reason: '' };
|
|
265
|
+
}
|
|
186
266
|
// ---- execution (real SSH; verified end-to-end, not unit-mocked) ----
|
|
187
267
|
function osHint(platform) {
|
|
188
268
|
return platform === 'windows' ? 'windows' : undefined;
|
|
@@ -225,6 +305,15 @@ export function probeDevice(device, opts) {
|
|
|
225
305
|
if (vres.code === 0)
|
|
226
306
|
installedVersions = parseInstalledVersions(vres.stdout);
|
|
227
307
|
}
|
|
308
|
+
let remoteBundles;
|
|
309
|
+
if (opts?.withSecrets) {
|
|
310
|
+
// Metadata only — `secrets list --json` returns names + timestamps and never
|
|
311
|
+
// values, which is why this is safe to run across the fleet.
|
|
312
|
+
const listCmd = buildRemoteAgentsInvocation(['secrets', 'list', '--json'], undefined, hint, remoteEnv(device.platform));
|
|
313
|
+
const lres = sshExec(target, listCmd, { timeoutMs: 30000, multiplex: true });
|
|
314
|
+
if (lres.code === 0)
|
|
315
|
+
remoteBundles = parseRemoteBundles(lres.stdout);
|
|
316
|
+
}
|
|
228
317
|
return {
|
|
229
318
|
device: device.name,
|
|
230
319
|
reachable: true,
|
|
@@ -232,8 +321,52 @@ export function probeDevice(device, opts) {
|
|
|
232
321
|
cliVersion: ready.version ?? undefined,
|
|
233
322
|
installedAgents: installed,
|
|
234
323
|
installedVersions,
|
|
324
|
+
remoteBundles,
|
|
235
325
|
};
|
|
236
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Narrow a remote `secrets list --json` payload to `name -> updated_at`.
|
|
329
|
+
*
|
|
330
|
+
* Exported and pure so the parse is unit-tested against real payload shapes with
|
|
331
|
+
* no live fleet. Returns `{}` rather than throwing on anything unexpected: the
|
|
332
|
+
* remote runs its own agents-cli version, and a parse failure must degrade to
|
|
333
|
+
* "unknown, so push" — never to "present, so skip", which would silently leave a
|
|
334
|
+
* device unprovisioned.
|
|
335
|
+
*/
|
|
336
|
+
export function parseRemoteBundles(stdout) {
|
|
337
|
+
try {
|
|
338
|
+
const parsed = JSON.parse(stdout);
|
|
339
|
+
const rows = Array.isArray(parsed)
|
|
340
|
+
? parsed
|
|
341
|
+
: Array.isArray(parsed?.bundles)
|
|
342
|
+
? parsed.bundles
|
|
343
|
+
: [];
|
|
344
|
+
// Null-prototype: a remote-supplied name is used as a KEY here, so `{}` would
|
|
345
|
+
// let `__proto__` hit the prototype setter instead of becoming an own
|
|
346
|
+
// property (and then read back as absent). It also means the presence check
|
|
347
|
+
// cannot see inherited names.
|
|
348
|
+
const out = Object.create(null);
|
|
349
|
+
for (const row of rows) {
|
|
350
|
+
if (!row || typeof row !== 'object')
|
|
351
|
+
continue;
|
|
352
|
+
const r = row;
|
|
353
|
+
const name = typeof r.name === 'string' ? r.name : undefined;
|
|
354
|
+
if (!name)
|
|
355
|
+
continue;
|
|
356
|
+
// `updatedAt` is the real field name in `secrets list --json` — verified
|
|
357
|
+
// against a live payload, not assumed. `updated_at` is accepted too so an
|
|
358
|
+
// older remote is not silently recorded with an empty timestamp.
|
|
359
|
+
const ts = typeof r.updatedAt === 'string' ? r.updatedAt
|
|
360
|
+
: typeof r.updated_at === 'string' ? r.updated_at
|
|
361
|
+
: '';
|
|
362
|
+
out[name] = ts;
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return {};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
237
370
|
/** Execute one device's planned actions in order. Real SSH — no mocks. */
|
|
238
371
|
export function reconcileDevice(row, device, ctx) {
|
|
239
372
|
if (!row.probe.reachable) {
|
|
@@ -290,6 +423,50 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
290
423
|
steps.push({ kind: 'push-login', ok: r.code === 0, detail: `propagate login: ${pushAgents.join(', ')}` });
|
|
291
424
|
ok = ok && r.code === 0;
|
|
292
425
|
}
|
|
426
|
+
// 5. secrets provisioning — LAST, and deliberately so. It is the most
|
|
427
|
+
// sensitive mutation apply performs (credential VALUES crossing to another
|
|
428
|
+
// machine), so every lower-risk step above is already recorded before we
|
|
429
|
+
// touch it: a failure here never obscures what did land.
|
|
430
|
+
//
|
|
431
|
+
// Resolve ONCE per device even for several bundles is not possible (a resolve
|
|
432
|
+
// is per bundle), but each bundle resolves once and pushes once — the read can
|
|
433
|
+
// prompt, so it must not repeat.
|
|
434
|
+
const pushSecrets = row.actions.filter((a) => a.kind === 'push-secret');
|
|
435
|
+
for (const action of pushSecrets) {
|
|
436
|
+
const bundle = action.bundle;
|
|
437
|
+
if (!bundle) {
|
|
438
|
+
// A push-secret action without a bundle name is a planner bug, not a
|
|
439
|
+
// recoverable state — fail loud rather than push nothing and report ok.
|
|
440
|
+
steps.push({ kind: 'push-secret', ok: false, detail: 'push-secret action carried no bundle name' });
|
|
441
|
+
ok = false;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const backend = device.platform === 'linux' ? 'file' : 'keychain';
|
|
445
|
+
try {
|
|
446
|
+
const out = pushBundleToHost(bundle, target, {
|
|
447
|
+
remoteBackend: backend,
|
|
448
|
+
operation: `fleet apply ${row.device}`,
|
|
449
|
+
// No passphrase, ever, from this path. On the file backend the remote
|
|
450
|
+
// auto-provisions its OWN machine-local key, which is the entire point:
|
|
451
|
+
// each box gets an unshared at-rest key instead of the fleet-wide shared
|
|
452
|
+
// secret RUSH-1968 is about.
|
|
453
|
+
});
|
|
454
|
+
steps.push({
|
|
455
|
+
kind: 'push-secret',
|
|
456
|
+
ok: out.ok,
|
|
457
|
+
detail: out.ok
|
|
458
|
+
? `secrets '${bundle}' -> ${row.device} (${backend}): ${out.message}`
|
|
459
|
+
: `secrets '${bundle}' -> ${row.device}: ${out.message}`,
|
|
460
|
+
});
|
|
461
|
+
ok = ok && out.ok;
|
|
462
|
+
}
|
|
463
|
+
catch (e) {
|
|
464
|
+
// A local resolve failure (locked store, missing bundle, multi-line value)
|
|
465
|
+
// is reported per bundle rather than aborting the whole device.
|
|
466
|
+
steps.push({ kind: 'push-secret', ok: false, detail: `secrets '${bundle}': ${e.message}` });
|
|
467
|
+
ok = false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
293
470
|
// Surface blocked logins as (non-fatal) informational steps.
|
|
294
471
|
for (const blocked of row.loginBlocked) {
|
|
295
472
|
steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
|
|
@@ -95,13 +95,28 @@ export interface DeviceProbe {
|
|
|
95
95
|
* undefined, version-pinned specs fall back to id-level presence.
|
|
96
96
|
*/
|
|
97
97
|
installedVersions?: Record<string, string[]>;
|
|
98
|
+
/**
|
|
99
|
+
* Secrets bundles already present on the device: name -> `updated_at` (or ''
|
|
100
|
+
* when the remote reports none). Only populated when the manifest declares
|
|
101
|
+
* bundles AND `--provision-secrets` is set — a fleet that uses no bundles
|
|
102
|
+
* never pays for the extra round trip.
|
|
103
|
+
*
|
|
104
|
+
* METADATA ONLY. `agents secrets list --json` returns names and timestamps and
|
|
105
|
+
* explicitly never values, which is what makes this probe safe to run.
|
|
106
|
+
*/
|
|
107
|
+
remoteBundles?: Record<string, string>;
|
|
98
108
|
/** Reason string when `reachable` is false or the probe partially failed. */
|
|
99
109
|
note?: string;
|
|
100
110
|
}
|
|
101
111
|
/** One planned action against a device, in a single reconcile dimension. */
|
|
102
112
|
export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
|
|
103
|
-
/**
|
|
104
|
-
*
|
|
113
|
+
/** Push a declared secrets bundle to the device over SSH. Opt-in only
|
|
114
|
+
* (`--provision-secrets`) and gated on a pinned host key, because this moves
|
|
115
|
+
* credential VALUES to another machine (RUSH-1968). */
|
|
116
|
+
| 'push-secret'
|
|
117
|
+
/** A declared secrets bundle that could NOT be pushed — the flag is off, the
|
|
118
|
+
* host key isn't pinned, or the bundle is already current. Surfaced as a manual
|
|
119
|
+
* recreate, like `needs-login`. */
|
|
105
120
|
| 'needs-secret';
|
|
106
121
|
export interface FleetAction {
|
|
107
122
|
device: string;
|
|
@@ -111,6 +126,10 @@ export interface FleetAction {
|
|
|
111
126
|
/** Full agent spec for `add-agent` (e.g. `claude@2.1.170`) so the plan can show
|
|
112
127
|
* the exact version being installed; equals the id for a bare/latest spec. */
|
|
113
128
|
spec?: string;
|
|
129
|
+
/** Bundle name for `push-secret` / `needs-secret`, so the executor pushes the
|
|
130
|
+
* bundle the planner decided on rather than re-deriving it from the detail
|
|
131
|
+
* string. */
|
|
132
|
+
bundle?: string;
|
|
114
133
|
/** Human, one-line description of the action. */
|
|
115
134
|
detail: string;
|
|
116
135
|
}
|
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -505,6 +505,21 @@ if [ "$CACHE_STATUS" = miss ]; then
|
|
|
505
505
|
if [ "$_in_backoff" -eq 0 ]; then
|
|
506
506
|
# Fix 1: lockdir — only one background refresh runs at a time.
|
|
507
507
|
LOCK_DIR="$CACHE_FILE.bg.lck"
|
|
508
|
+
# Fix 4 (RUSH-2259): reclaim a stale lock. The lock is released by the bg
|
|
509
|
+
# subshell's EXIT trap, but a hard kill (SIGKILL, OOM, reboot) skips the
|
|
510
|
+
# trap and orphans the dir — after which every future mkdir fails and bg
|
|
511
|
+
# refresh stops FOREVER while stale cache is served indefinitely. The dir
|
|
512
|
+
# mtime is fixed at mkdir time (the refresh writes elsewhere), so its age
|
|
513
|
+
# is the time the lock has been held; treat a lock older than LOCK_TTL_SEC
|
|
514
|
+
# as abandoned and remove it before locking. LOCK_TTL_SEC is set well above
|
|
515
|
+
# any real hook runtime so a live refresh is never reclaimed out from under.
|
|
516
|
+
LOCK_TTL_SEC=300
|
|
517
|
+
if [ -d "$LOCK_DIR" ]; then
|
|
518
|
+
_lock_mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$LOCK_DIR" 2>/dev/null || echo 0)
|
|
519
|
+
_lock_now_s=$(date +%s)
|
|
520
|
+
_lock_age=$((_lock_now_s - \${_lock_mtime:-0}))
|
|
521
|
+
[ "$_lock_age" -ge "$LOCK_TTL_SEC" ] && rm -rf "$LOCK_DIR" 2>/dev/null
|
|
522
|
+
fi
|
|
508
523
|
if mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
509
524
|
tmp="$CACHE_FILE.new.$$"
|
|
510
525
|
# Fix 3: background subshell captures and logs its own real exit code.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* table or, when `--host`/`--device` is present, exits with a clear
|
|
17
17
|
* "not supported" message — never commander's raw `unknown option`.
|
|
18
18
|
*/
|
|
19
|
+
import { type Host } from './types.js';
|
|
19
20
|
import { type DeviceProfile, type DeviceRegistry } from '../devices/registry.js';
|
|
20
21
|
import { runLocalCommand, runOnDevice } from '../devices/fleet.js';
|
|
21
22
|
/** Per-command remote behaviour. Absence from this map = not host-routable here. */
|
|
@@ -60,4 +61,47 @@ export declare function runFleetPassthrough(command: string, allArgs: string[],
|
|
|
60
61
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
61
62
|
*/
|
|
62
63
|
export declare function maybeRunOnHost(command: string, allArgs: string[], opts?: FleetPassthroughOptions): Promise<boolean>;
|
|
64
|
+
/**
|
|
65
|
+
* `--host` passthrough for a **standalone binary** whose command name is fixed by
|
|
66
|
+
* the binary itself (the `browser`/`computer` bins, `dist/browser.js` etc.) rather
|
|
67
|
+
* than being the first argv token.
|
|
68
|
+
*
|
|
69
|
+
* `agents browser … --host <box>` routes through {@link maybeRunOnHost} in
|
|
70
|
+
* index.ts, but the standalone `browser` binary never enters index.ts — so without
|
|
71
|
+
* this it dropped `--host` entirely (commander errored with `unknown option
|
|
72
|
+
* '--host'`). This wires the identical routing: it synthesizes the implicit command
|
|
73
|
+
* token (`browser`) at the front of the argv so the remote invocation builds as
|
|
74
|
+
* `agents browser …`, delegates to {@link maybeRunOnHost}, and returns `true` when
|
|
75
|
+
* it dispatched the run to a remote (the local program must then NOT parse).
|
|
76
|
+
*
|
|
77
|
+
* On a local / self-host fall-through it rewrites `process.argv` to the ORIGINAL
|
|
78
|
+
* args minus the routing flags — never the synthetic command token — so the
|
|
79
|
+
* standalone commander program parses cleanly and never sees an unknown `--host`.
|
|
80
|
+
* When no routing flag is present it leaves `process.argv` untouched (the common
|
|
81
|
+
* case), so an unrelated flag like `--no-tty` is never stripped from a purely local
|
|
82
|
+
* run.
|
|
83
|
+
*
|
|
84
|
+
* @param command the fixed command name the binary stands for (`'browser'`).
|
|
85
|
+
*/
|
|
86
|
+
export declare function maybeRunStandaloneOnHost(command: string, opts?: FleetPassthroughOptions): Promise<boolean>;
|
|
87
|
+
/**
|
|
88
|
+
* Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
|
|
89
|
+
* return the exit code. The single place the SSH hop is built, so every remote
|
|
90
|
+
* `agents` invocation carries identical env semantics.
|
|
91
|
+
*
|
|
92
|
+
* Forwards actor provenance (`AGENTS_ACTOR`/`GIT_` vars) across the hop, merged UNDER
|
|
93
|
+
* `extraEnv` so a caller-supplied PATH still wins — without this the remote
|
|
94
|
+
* re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
|
|
95
|
+
* (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
|
|
96
|
+
* AGENTS_FLEET_REMOTE marks this as a fleet-dispatched run so the far side can
|
|
97
|
+
* gate consent-sensitive actions — the browser consent gate
|
|
98
|
+
* (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
|
|
99
|
+
*/
|
|
100
|
+
export declare function streamAgentsOnHost(host: Host, forwardedArgs: string[], opts?: {
|
|
101
|
+
remoteCwd?: string;
|
|
102
|
+
interactive?: boolean;
|
|
103
|
+
extraEnv?: Record<string, string>;
|
|
104
|
+
remoteOs?: string;
|
|
105
|
+
target?: string;
|
|
106
|
+
}): number;
|
|
63
107
|
export {};
|
|
@@ -29,6 +29,7 @@ import { loadDevices } from '../devices/registry.js';
|
|
|
29
29
|
import { isSelfHost } from '../devices/self-host.js';
|
|
30
30
|
import { fanOutDevices, planFleetTargets, runLocalCommand, runOnDevice, } from '../devices/fleet.js';
|
|
31
31
|
import { platformGroupLabel } from '../devices/health-report.js';
|
|
32
|
+
import { isKnownTopLevelCommand } from '../startup/command-registry.js';
|
|
32
33
|
/**
|
|
33
34
|
* First-class groups that run transparently on a remote via SSH when
|
|
34
35
|
* `--host`/`--device` is present. Keep both canonical names and aliases
|
|
@@ -436,6 +437,15 @@ export async function maybeRunOnHost(command, allArgs, opts) {
|
|
|
436
437
|
if (!isAll && command !== 'routines')
|
|
437
438
|
return false;
|
|
438
439
|
}
|
|
440
|
+
// A command that does not exist is an unknown-command error, not a routing
|
|
441
|
+
// error. The router runs BEFORE commander parses, so without this gate a typo
|
|
442
|
+
// (`agents session resume --host box`) was answered with "does not support
|
|
443
|
+
// --host/--device" — a true statement about a command the user never typed,
|
|
444
|
+
// and the exact opposite of the truth for the `sessions` they meant, which
|
|
445
|
+
// does support it. Fall through so commander reports `unknown command` (and
|
|
446
|
+
// its did-you-mean). RUSH-2022.
|
|
447
|
+
if (!isKnownTopLevelCommand(command))
|
|
448
|
+
return false;
|
|
439
449
|
const spec = REMOTE_PASSTHROUGH[command];
|
|
440
450
|
if (!spec) {
|
|
441
451
|
// Flag was accepted (no raw commander "unknown option") but this group has
|
|
@@ -532,20 +542,82 @@ export async function maybeRunOnHost(command, allArgs, opts) {
|
|
|
532
542
|
const doctorPath = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
|
|
533
543
|
? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
|
|
534
544
|
: undefined;
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
+
process.exitCode = streamAgentsOnHost(host, forwarded, {
|
|
546
|
+
remoteCwd,
|
|
547
|
+
interactive,
|
|
548
|
+
extraEnv: doctorPath,
|
|
549
|
+
remoteOs,
|
|
550
|
+
target,
|
|
551
|
+
});
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* `--host` passthrough for a **standalone binary** whose command name is fixed by
|
|
556
|
+
* the binary itself (the `browser`/`computer` bins, `dist/browser.js` etc.) rather
|
|
557
|
+
* than being the first argv token.
|
|
558
|
+
*
|
|
559
|
+
* `agents browser … --host <box>` routes through {@link maybeRunOnHost} in
|
|
560
|
+
* index.ts, but the standalone `browser` binary never enters index.ts — so without
|
|
561
|
+
* this it dropped `--host` entirely (commander errored with `unknown option
|
|
562
|
+
* '--host'`). This wires the identical routing: it synthesizes the implicit command
|
|
563
|
+
* token (`browser`) at the front of the argv so the remote invocation builds as
|
|
564
|
+
* `agents browser …`, delegates to {@link maybeRunOnHost}, and returns `true` when
|
|
565
|
+
* it dispatched the run to a remote (the local program must then NOT parse).
|
|
566
|
+
*
|
|
567
|
+
* On a local / self-host fall-through it rewrites `process.argv` to the ORIGINAL
|
|
568
|
+
* args minus the routing flags — never the synthetic command token — so the
|
|
569
|
+
* standalone commander program parses cleanly and never sees an unknown `--host`.
|
|
570
|
+
* When no routing flag is present it leaves `process.argv` untouched (the common
|
|
571
|
+
* case), so an unrelated flag like `--no-tty` is never stripped from a purely local
|
|
572
|
+
* run.
|
|
573
|
+
*
|
|
574
|
+
* @param command the fixed command name the binary stands for (`'browser'`).
|
|
575
|
+
*/
|
|
576
|
+
export async function maybeRunStandaloneOnHost(command, opts) {
|
|
577
|
+
const rawArgs = process.argv.slice(2);
|
|
578
|
+
const hasRoutingFlag = flagValue(rawArgs, 'host', 'H') !== undefined ||
|
|
579
|
+
flagValue(rawArgs, 'device') !== undefined ||
|
|
580
|
+
flagValue(rawArgs, 'hosts') !== undefined ||
|
|
581
|
+
flagValue(rawArgs, 'devices') !== undefined;
|
|
582
|
+
// No routing flag → nothing to route or strip. Leave argv alone so a purely
|
|
583
|
+
// local run keeps every flag it passed.
|
|
584
|
+
if (!hasRoutingFlag)
|
|
585
|
+
return false;
|
|
586
|
+
// Keep --help/--version local (docs must work without a reachable host), mirroring
|
|
587
|
+
// index.ts's `helpOrVersionRequested` guard, but still strip the routing flags
|
|
588
|
+
// below so commander doesn't choke on them.
|
|
589
|
+
const helpOrVersion = rawArgs.some((a) => a === '--help' || a === '-h' || a === '--version' || a === '-V');
|
|
590
|
+
if (!helpOrVersion && (await maybeRunOnHost(command, [command, ...rawArgs], opts))) {
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
// Local / self-host fall-through (maybeRunOnHost may have rewritten process.argv
|
|
594
|
+
// with the synthetic command token). Rebuild argv from the original args minus
|
|
595
|
+
// the routing flags so the standalone program parses cleanly.
|
|
596
|
+
process.argv = [process.argv[0], process.argv[1], ...stripRoutingFlags(rawArgs, STRIP_SPECS)];
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
|
|
601
|
+
* return the exit code. The single place the SSH hop is built, so every remote
|
|
602
|
+
* `agents` invocation carries identical env semantics.
|
|
603
|
+
*
|
|
604
|
+
* Forwards actor provenance (`AGENTS_ACTOR`/`GIT_` vars) across the hop, merged UNDER
|
|
605
|
+
* `extraEnv` so a caller-supplied PATH still wins — without this the remote
|
|
606
|
+
* re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
|
|
607
|
+
* (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
|
|
608
|
+
* AGENTS_FLEET_REMOTE marks this as a fleet-dispatched run so the far side can
|
|
609
|
+
* gate consent-sensitive actions — the browser consent gate
|
|
610
|
+
* (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
|
|
611
|
+
*/
|
|
612
|
+
export function streamAgentsOnHost(host, forwardedArgs, opts = {}) {
|
|
613
|
+
const target = opts.target ?? sshTargetFor(host);
|
|
614
|
+
const remoteOs = opts.remoteOs ?? resolveRemoteOsSync(host.name);
|
|
615
|
+
const env = withActorEnv({ ...opts.extraEnv, AGENTS_FLEET_REMOTE: '1' });
|
|
616
|
+
const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, opts.remoteCwd, remoteOs, env);
|
|
617
|
+
const code = sshStream(target, remoteCmd, { tty: !!opts.interactive, multiplex: true });
|
|
545
618
|
if (code === 255) {
|
|
546
619
|
console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
|
|
547
620
|
chalk.gray(' Check: agents hosts check ' + host.name));
|
|
548
621
|
}
|
|
549
|
-
|
|
550
|
-
return true;
|
|
622
|
+
return code;
|
|
551
623
|
}
|
|
@@ -42,6 +42,8 @@ export interface ReadyProbe {
|
|
|
42
42
|
version: string | null;
|
|
43
43
|
/** Raw `agents view`/`list` output, for installed-agent checks. */
|
|
44
44
|
view: string;
|
|
45
|
+
/** True when the ssh probe timed out before the sentinel arrived. */
|
|
46
|
+
timedOut?: boolean;
|
|
45
47
|
}
|
|
46
48
|
/**
|
|
47
49
|
* Answer every readiness question in ONE ssh round-trip: reachable? (the login
|