@phnx-labs/agents-cli 1.22.26 → 1.22.28
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 +293 -0
- package/README.md +80 -27
- package/dist/bin/agents +0 -0
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/doctor.js +24 -10
- package/dist/commands/exec.js +71 -26
- package/dist/commands/hosts.js +4 -3
- package/dist/commands/insights.js +39 -5
- package/dist/commands/output.js +100 -25
- package/dist/commands/perf.d.ts +10 -0
- package/dist/commands/perf.js +14 -6
- package/dist/commands/projects.js +24 -10
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/routines.js +31 -4
- package/dist/commands/run-account-picker.d.ts +37 -0
- package/dist/commands/run-account-picker.js +101 -17
- package/dist/commands/secrets.js +86 -2
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +73 -7
- package/dist/commands/sessions.js +391 -49
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.js +22 -11
- package/dist/index.js +16 -20
- package/dist/lib/activity.js +45 -99
- package/dist/lib/auth-health.d.ts +47 -3
- package/dist/lib/auth-health.js +74 -15
- package/dist/lib/bench/index.d.ts +4 -0
- package/dist/lib/bench/index.js +4 -0
- package/dist/lib/bench/runner.d.ts +16 -0
- package/dist/lib/bench/runner.js +111 -0
- package/dist/lib/bench/schema.d.ts +5 -0
- package/dist/lib/bench/schema.js +91 -0
- package/dist/lib/bench/storage.d.ts +5 -0
- package/dist/lib/bench/storage.js +32 -0
- package/dist/lib/bench/types.d.ts +40 -0
- package/dist/lib/bench/types.js +1 -0
- package/dist/lib/claude-account-token.d.ts +2 -0
- package/dist/lib/claude-account-token.js +56 -3
- package/dist/lib/cloud/host.js +1 -0
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +2 -0
- package/dist/lib/crabbox/lease.js +7 -1
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/devices/connect.d.ts +2 -0
- package/dist/lib/devices/connect.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +4 -1
- package/dist/lib/devices/doctor-findings.js +15 -1
- package/dist/lib/devices/harness-inventory.d.ts +97 -0
- package/dist/lib/devices/harness-inventory.js +0 -0
- package/dist/lib/devices/registry.d.ts +2 -0
- package/dist/lib/devices/resolve-target.d.ts +1 -0
- package/dist/lib/devices/resolve-target.js +9 -2
- package/dist/lib/devices/ssh-config.js +3 -0
- package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
- package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +2 -2
- package/dist/lib/exec.js +18 -8
- package/dist/lib/fleet/apply.js +9 -7
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/git.d.ts +9 -3
- package/dist/lib/git.js +39 -22
- package/dist/lib/hooks/profile.d.ts +12 -1
- package/dist/lib/hooks/profile.js +6 -1
- package/dist/lib/hooks.d.ts +8 -5
- package/dist/lib/hooks.js +14 -7
- package/dist/lib/hosts/dispatch.d.ts +10 -0
- package/dist/lib/hosts/dispatch.js +85 -25
- package/dist/lib/hosts/logs.js +11 -1
- package/dist/lib/hosts/passthrough.d.ts +27 -0
- package/dist/lib/hosts/passthrough.js +19 -10
- package/dist/lib/hosts/progress.d.ts +12 -1
- package/dist/lib/hosts/progress.js +41 -8
- package/dist/lib/hosts/providers/devices.js +1 -0
- package/dist/lib/hosts/ready.d.ts +45 -4
- package/dist/lib/hosts/ready.js +124 -20
- package/dist/lib/hosts/reconcile.d.ts +2 -1
- package/dist/lib/hosts/reconcile.js +24 -7
- package/dist/lib/hosts/reconnect.d.ts +45 -12
- package/dist/lib/hosts/reconnect.js +94 -36
- package/dist/lib/hosts/registry.d.ts +2 -2
- package/dist/lib/hosts/registry.js +4 -5
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +29 -0
- package/dist/lib/hosts/tasks.d.ts +3 -0
- package/dist/lib/hosts/types.d.ts +1 -0
- package/dist/lib/hosts/types.js +3 -0
- 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/install-menubar.d.ts +9 -6
- package/dist/lib/menubar/install-menubar.js +20 -9
- package/dist/lib/menubar/snapshot.js +15 -2
- package/dist/lib/models.d.ts +42 -6
- package/dist/lib/models.js +55 -153
- package/dist/lib/perf/db.js +16 -3
- package/dist/lib/perf/types.d.ts +12 -1
- package/dist/lib/pricing/cost.d.ts +9 -0
- package/dist/lib/pricing/cost.js +24 -0
- package/dist/lib/pricing/index.d.ts +1 -1
- package/dist/lib/pricing/index.js +1 -1
- package/dist/lib/project-probe.d.ts +22 -3
- package/dist/lib/project-probe.js +105 -17
- package/dist/lib/project-status.d.ts +9 -0
- package/dist/lib/project-status.js +15 -0
- package/dist/lib/redact.js +8 -3
- package/dist/lib/refresh.js +22 -11
- package/dist/lib/remote-agents-json.d.ts +32 -0
- package/dist/lib/remote-agents-json.js +47 -16
- package/dist/lib/resource-profiles.js +1 -2
- package/dist/lib/rotate.d.ts +22 -0
- package/dist/lib/rotate.js +26 -0
- package/dist/lib/routine-notify-owner.d.ts +102 -0
- package/dist/lib/routine-notify-owner.js +232 -0
- package/dist/lib/routines.d.ts +10 -0
- package/dist/lib/runner.d.ts +9 -1
- package/dist/lib/runner.js +182 -17
- package/dist/lib/sandbox.d.ts +0 -2
- package/dist/lib/sandbox.js +2 -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/agent.d.ts +19 -3
- package/dist/lib/secrets/agent.js +61 -22
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +2 -0
- package/dist/lib/secrets/bundles.d.ts +3 -3
- package/dist/lib/secrets/bundles.js +15 -5
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +4 -0
- package/dist/lib/secrets/lease.d.ts +25 -0
- package/dist/lib/secrets/lease.js +44 -0
- package/dist/lib/secrets/session-store.d.ts +7 -0
- package/dist/lib/secrets/session-store.js +21 -0
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/active.d.ts +49 -0
- package/dist/lib/session/active.js +175 -11
- package/dist/lib/session/bash-command.d.ts +16 -0
- package/dist/lib/session/bash-command.js +65 -0
- package/dist/lib/session/db.d.ts +23 -2
- package/dist/lib/session/db.js +86 -20
- package/dist/lib/session/discover.d.ts +21 -1
- package/dist/lib/session/discover.js +124 -32
- package/dist/lib/session/insights.d.ts +19 -0
- package/dist/lib/session/insights.js +78 -10
- package/dist/lib/session/recovery.d.ts +17 -1
- package/dist/lib/session/recovery.js +111 -4
- package/dist/lib/session/remote-bundle.js +1 -1
- package/dist/lib/session/remote-list.d.ts +10 -9
- package/dist/lib/session/remote-list.js +25 -23
- package/dist/lib/session/resume-owner.d.ts +55 -0
- package/dist/lib/session/resume-owner.js +69 -0
- package/dist/lib/session/team-filter.d.ts +65 -0
- package/dist/lib/session/team-filter.js +98 -3
- package/dist/lib/session/types.d.ts +33 -0
- package/dist/lib/smart-launch.d.ts +31 -5
- package/dist/lib/smart-launch.js +43 -7
- package/dist/lib/ssh-exec.d.ts +27 -0
- package/dist/lib/ssh-exec.js +34 -1
- package/dist/lib/ssh-tunnel.d.ts +3 -2
- package/dist/lib/ssh-tunnel.js +25 -16
- package/dist/lib/staleness/detectors/skills.d.ts +2 -0
- package/dist/lib/staleness/detectors/skills.js +17 -2
- package/dist/lib/staleness/index.d.ts +10 -1
- package/dist/lib/staleness/index.js +28 -3
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/teams/agents.d.ts +1 -0
- package/dist/lib/teams/agents.js +38 -11
- package/dist/lib/teams/placement-probe.d.ts +1 -1
- package/dist/lib/teams/placement-probe.js +24 -24
- package/dist/lib/teams/remoteWorktree.d.ts +11 -7
- package/dist/lib/teams/remoteWorktree.js +29 -27
- package/dist/lib/tmux/session.d.ts +8 -0
- package/dist/lib/tmux/session.js +22 -0
- package/dist/lib/types.d.ts +2 -0
- package/dist/lib/versions.d.ts +1 -0
- package/dist/lib/versions.js +40 -22
- package/package.json +3 -1
|
@@ -24,6 +24,28 @@ interface RemoteSpec {
|
|
|
24
24
|
/** Flags appended when running non-interactively (no local TTY / `--no-tty`). */
|
|
25
25
|
nonInteractive?: string[];
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* First-class groups that run transparently on a remote via SSH when
|
|
29
|
+
* `--host`/`--device` is present. Keep both canonical names and aliases
|
|
30
|
+
* (`repo`/`repos`, `exec`/`run`) so either argv form routes the same way.
|
|
31
|
+
*
|
|
32
|
+
* Prefer adding here over per-command SSH code — this is the single choke point.
|
|
33
|
+
*
|
|
34
|
+
* Every key MUST be a real top-level command (a `KNOWN_TOP_LEVEL_COMMANDS`
|
|
35
|
+
* member) — `passthrough.test.ts` asserts it. A key that is not one is dead:
|
|
36
|
+
* the gate in {@link maybeRunOnHost} rejects the name as unknown before this
|
|
37
|
+
* table is consulted, and before that gate existed it SSH'd a command the peer
|
|
38
|
+
* would also reject. `cli`/`packages`/`versions`/`daemon` were exactly that
|
|
39
|
+
* (the commands are `clis`, `registry`/`search`/`install`/`publish`,
|
|
40
|
+
* `add`/`use`/`list`, and none) and were removed.
|
|
41
|
+
*/
|
|
42
|
+
export declare const REMOTE_PASSTHROUGH: Record<string, RemoteSpec>;
|
|
43
|
+
/**
|
|
44
|
+
* Commands that register and interpret `--host`/`--device` themselves — must
|
|
45
|
+
* fall through to local commander even when the flag is present. Do not add
|
|
46
|
+
* these to {@link REMOTE_PASSTHROUGH}.
|
|
47
|
+
*/
|
|
48
|
+
export declare const OWN_HOST_COMMANDS: Set<string>;
|
|
27
49
|
/** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
|
|
28
50
|
export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
|
|
29
51
|
/** Injectable dependencies for {@link runFleetPassthrough} — used by tests. */
|
|
@@ -104,4 +126,9 @@ export declare function streamAgentsOnHost(host: Host, forwardedArgs: string[],
|
|
|
104
126
|
remoteOs?: string;
|
|
105
127
|
target?: string;
|
|
106
128
|
}): number;
|
|
129
|
+
export declare function passthroughSshOptions(host: Host, interactive: boolean): {
|
|
130
|
+
tty: boolean;
|
|
131
|
+
multiplex: true;
|
|
132
|
+
extraSshArgs: string[];
|
|
133
|
+
};
|
|
107
134
|
export {};
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
import chalk from 'chalk';
|
|
20
20
|
import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
|
|
21
21
|
import { resolveHost, resolveHostByCap } from './registry.js';
|
|
22
|
-
import { sshTargetFor } from './types.js';
|
|
22
|
+
import { sshTargetFor, hostIdentityArgs } from './types.js';
|
|
23
23
|
import { dispatchAgentsCommand, withActorEnv } from './dispatch.js';
|
|
24
|
-
import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
|
|
24
|
+
import { stripRoutingFlags, buildRemoteAgentsInvocation, stripClixml, HOST_ROUTING_SPECS, } from './remote-cmd.js';
|
|
25
25
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
26
26
|
import { machineId } from '../session/sync/config.js';
|
|
27
27
|
import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
|
|
@@ -36,8 +36,16 @@ import { isKnownTopLevelCommand } from '../startup/command-registry.js';
|
|
|
36
36
|
* (`repo`/`repos`, `exec`/`run`) so either argv form routes the same way.
|
|
37
37
|
*
|
|
38
38
|
* Prefer adding here over per-command SSH code — this is the single choke point.
|
|
39
|
+
*
|
|
40
|
+
* Every key MUST be a real top-level command (a `KNOWN_TOP_LEVEL_COMMANDS`
|
|
41
|
+
* member) — `passthrough.test.ts` asserts it. A key that is not one is dead:
|
|
42
|
+
* the gate in {@link maybeRunOnHost} rejects the name as unknown before this
|
|
43
|
+
* table is consulted, and before that gate existed it SSH'd a command the peer
|
|
44
|
+
* would also reject. `cli`/`packages`/`versions`/`daemon` were exactly that
|
|
45
|
+
* (the commands are `clis`, `registry`/`search`/`install`/`publish`,
|
|
46
|
+
* `add`/`use`/`list`, and none) and were removed.
|
|
39
47
|
*/
|
|
40
|
-
const REMOTE_PASSTHROUGH = {
|
|
48
|
+
export const REMOTE_PASSTHROUGH = {
|
|
41
49
|
// status / inspect
|
|
42
50
|
view: {},
|
|
43
51
|
inspect: {},
|
|
@@ -65,10 +73,8 @@ const REMOTE_PASSTHROUGH = {
|
|
|
65
73
|
permissions: {},
|
|
66
74
|
perms: {},
|
|
67
75
|
mcp: {},
|
|
68
|
-
cli: {},
|
|
69
76
|
subagents: {},
|
|
70
77
|
workflows: {},
|
|
71
|
-
packages: {},
|
|
72
78
|
models: {},
|
|
73
79
|
profiles: {},
|
|
74
80
|
defaults: {},
|
|
@@ -89,20 +95,18 @@ const REMOTE_PASSTHROUGH = {
|
|
|
89
95
|
lock: {},
|
|
90
96
|
feedback: {},
|
|
91
97
|
wallet: {},
|
|
92
|
-
daemon: {},
|
|
93
98
|
pty: {},
|
|
94
99
|
tmux: {},
|
|
95
100
|
watchdog: {},
|
|
96
101
|
factory: {},
|
|
97
102
|
browser: {},
|
|
98
|
-
versions: {},
|
|
99
103
|
};
|
|
100
104
|
/**
|
|
101
105
|
* Commands that register and interpret `--host`/`--device` themselves — must
|
|
102
106
|
* fall through to local commander even when the flag is present. Do not add
|
|
103
107
|
* these to {@link REMOTE_PASSTHROUGH}.
|
|
104
108
|
*/
|
|
105
|
-
const OWN_HOST_COMMANDS = new Set([
|
|
109
|
+
export const OWN_HOST_COMMANDS = new Set([
|
|
106
110
|
'run',
|
|
107
111
|
'exec', // deprecated alias of run
|
|
108
112
|
'harness', // `--host <agent>` names the host CLI to run under, not a remote device
|
|
@@ -186,7 +190,9 @@ function buildFleetForwardedArgs(allArgs) {
|
|
|
186
190
|
/** Parse stdout as JSON; on failure return an object describing the error. */
|
|
187
191
|
function safeJsonParse(stdout) {
|
|
188
192
|
try {
|
|
189
|
-
|
|
193
|
+
// A Windows device relays its `--json` through PowerShell, which can prefix a
|
|
194
|
+
// CLIXML banner ahead of the payload — strip it before parsing (RUSH-2286).
|
|
195
|
+
return JSON.parse(stripClixml(stdout));
|
|
190
196
|
}
|
|
191
197
|
catch {
|
|
192
198
|
return { parseError: 'invalid JSON', snippet: stdout.trim().slice(0, 200) };
|
|
@@ -614,10 +620,13 @@ export function streamAgentsOnHost(host, forwardedArgs, opts = {}) {
|
|
|
614
620
|
const remoteOs = opts.remoteOs ?? resolveRemoteOsSync(host.name);
|
|
615
621
|
const env = withActorEnv({ ...opts.extraEnv, AGENTS_FLEET_REMOTE: '1' });
|
|
616
622
|
const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, opts.remoteCwd, remoteOs, env);
|
|
617
|
-
const code = sshStream(target, remoteCmd,
|
|
623
|
+
const code = sshStream(target, remoteCmd, passthroughSshOptions(host, !!opts.interactive));
|
|
618
624
|
if (code === 255) {
|
|
619
625
|
console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
|
|
620
626
|
chalk.gray(' Check: agents hosts check ' + host.name));
|
|
621
627
|
}
|
|
622
628
|
return code;
|
|
623
629
|
}
|
|
630
|
+
export function passthroughSshOptions(host, interactive) {
|
|
631
|
+
return { tty: interactive, multiplex: true, extraSshArgs: hostIdentityArgs(host) };
|
|
632
|
+
}
|
|
@@ -38,6 +38,7 @@ export declare const REMOTE_MIRROR_MAX_BYTES: number;
|
|
|
38
38
|
export declare function pullRemoteLogDelta(target: string, opts: {
|
|
39
39
|
remoteLog: string;
|
|
40
40
|
offset: number;
|
|
41
|
+
extraSshArgs?: string[];
|
|
41
42
|
}): {
|
|
42
43
|
bytes: Buffer;
|
|
43
44
|
newOffset: number;
|
|
@@ -55,6 +56,8 @@ export interface FollowOptions {
|
|
|
55
56
|
pollMs?: number;
|
|
56
57
|
/** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
|
|
57
58
|
maxPollMs?: number;
|
|
59
|
+
extraSshArgs?: string[];
|
|
60
|
+
remoteShell?: 'posix' | 'powershell';
|
|
58
61
|
}
|
|
59
62
|
/**
|
|
60
63
|
* Build the per-task sentinel that separates the log tail from the exit-file
|
|
@@ -96,10 +99,18 @@ export declare function fetchProgress(target: string, opts: {
|
|
|
96
99
|
remoteExit: string;
|
|
97
100
|
taskId: string;
|
|
98
101
|
offset: number;
|
|
102
|
+
extraSshArgs?: string[];
|
|
103
|
+
remoteShell?: 'posix' | 'powershell';
|
|
99
104
|
}): {
|
|
100
105
|
logChunk: Buffer;
|
|
101
106
|
exit: string;
|
|
102
107
|
} | null;
|
|
108
|
+
export declare function buildWindowsProgressCommand(opts: {
|
|
109
|
+
remoteLog: string;
|
|
110
|
+
remoteExit: string;
|
|
111
|
+
taskId: string;
|
|
112
|
+
offset: number;
|
|
113
|
+
}): string;
|
|
103
114
|
/**
|
|
104
115
|
* Remote shell for the persistent follow stream.
|
|
105
116
|
*
|
|
@@ -124,7 +135,7 @@ export declare function parseStreamingExitFrame(stderr: Buffer, taskId: string):
|
|
|
124
135
|
* File identity (`dev:ino`) of a path on the remote host, or null if it can't be
|
|
125
136
|
* stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
|
|
126
137
|
*/
|
|
127
|
-
export declare function readRemoteFileId(target: string, remotePath: string): string | null;
|
|
138
|
+
export declare function readRemoteFileId(target: string, remotePath: string, extraSshArgs?: string[]): string | null;
|
|
128
139
|
/**
|
|
129
140
|
* True when the local mirror file IS the very file we're tailing — the
|
|
130
141
|
* localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from 'fs';
|
|
15
15
|
import { sshExec, sshExecRaw, sshExecRawStream } from '../ssh-exec.js';
|
|
16
|
+
import { encodePowershell } from './remote-cmd.js';
|
|
16
17
|
import { localLogPath } from './tasks.js';
|
|
17
18
|
function sleep(ms) {
|
|
18
19
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -47,7 +48,7 @@ export function pullRemoteLogDelta(target, opts) {
|
|
|
47
48
|
// single-quote it into a literal `$HOME`, and the tail would find nothing). The
|
|
48
49
|
// hex basename is injection-safe, matching fetchProgress below.
|
|
49
50
|
const remote = `tail -c +${opts.offset + 1} ${opts.remoteLog} 2>/dev/null`;
|
|
50
|
-
const res = sshExecRaw(target, remote, { timeoutMs: 20000, multiplex: true });
|
|
51
|
+
const res = sshExecRaw(target, remote, { timeoutMs: 20000, multiplex: true, extraSshArgs: opts.extraSshArgs });
|
|
51
52
|
if (res.code === null)
|
|
52
53
|
return null; // ssh itself failed / timed out
|
|
53
54
|
return { bytes: res.stdout, newOffset: opts.offset + res.stdout.length };
|
|
@@ -102,19 +103,35 @@ export function fetchProgress(target, opts) {
|
|
|
102
103
|
// only escape-sensitive bytes are its newlines (→ `\n`); it carries no `%`,
|
|
103
104
|
// single-quote, or other printf/shell-special chars (task id is hex).
|
|
104
105
|
const printfArg = exitMarker(opts.taskId).replace(/\n/g, '\\n');
|
|
105
|
-
const remote =
|
|
106
|
-
|
|
107
|
-
`cat ${opts.remoteExit} 2>/dev/null`;
|
|
106
|
+
const remote = opts.remoteShell === 'powershell'
|
|
107
|
+
? buildWindowsProgressCommand(opts)
|
|
108
|
+
: `tail -c +${opts.offset + 1} ${opts.remoteLog} 2>/dev/null; printf '${printfArg}'; cat ${opts.remoteExit} 2>/dev/null`;
|
|
108
109
|
// Raw bytes (no UTF-8 decode): the log tail must be counted and re-emitted
|
|
109
110
|
// byte-for-byte so a multibyte char split at the `tail -c` boundary neither
|
|
110
111
|
// drifts the offset nor renders as U+FFFD. The exit code is pure ASCII → safe
|
|
111
112
|
// to decode to a string for the caller's `.trim()`.
|
|
112
|
-
const res = sshExecRaw(target, remote, { timeoutMs: 20000 });
|
|
113
|
+
const res = sshExecRaw(target, remote, { timeoutMs: 20000, extraSshArgs: opts.extraSshArgs });
|
|
113
114
|
const parts = splitProgressBytes(res.stdout, opts.taskId);
|
|
114
115
|
if (!parts)
|
|
115
116
|
return null;
|
|
116
117
|
return { logChunk: parts.logChunk, exit: parts.exit.toString('utf8') };
|
|
117
118
|
}
|
|
119
|
+
export function buildWindowsProgressCommand(opts) {
|
|
120
|
+
const windowsPath = (value) => value.startsWith('$HOME/')
|
|
121
|
+
? `(Join-Path $HOME '${value.slice('$HOME/'.length).replace(/'/g, "''")}')`
|
|
122
|
+
: `'${value.replace(/'/g, "''")}'`;
|
|
123
|
+
const markerBase64 = Buffer.from(exitMarker(opts.taskId), 'utf8').toString('base64');
|
|
124
|
+
const script = [
|
|
125
|
+
`$out = [Console]::OpenStandardOutput()`,
|
|
126
|
+
`$log = ${windowsPath(opts.remoteLog)}`,
|
|
127
|
+
`$exit = ${windowsPath(opts.remoteExit)}`,
|
|
128
|
+
`if (Test-Path -LiteralPath $log) { $bytes = [IO.File]::ReadAllBytes($log); if ($bytes.Length -gt ${opts.offset}) { $out.Write($bytes, ${opts.offset}, $bytes.Length - ${opts.offset}) } }`,
|
|
129
|
+
`$marker = [Convert]::FromBase64String('${markerBase64}')`,
|
|
130
|
+
`$out.Write($marker, 0, $marker.Length)`,
|
|
131
|
+
`if (Test-Path -LiteralPath $exit) { $bytes = [IO.File]::ReadAllBytes($exit); $out.Write($bytes, 0, $bytes.Length) }`,
|
|
132
|
+
].join('; ');
|
|
133
|
+
return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
134
|
+
}
|
|
118
135
|
/**
|
|
119
136
|
* Remote shell for the persistent follow stream.
|
|
120
137
|
*
|
|
@@ -160,8 +177,8 @@ export function parseStreamingExitFrame(stderr, taskId) {
|
|
|
160
177
|
* File identity (`dev:ino`) of a path on the remote host, or null if it can't be
|
|
161
178
|
* stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
|
|
162
179
|
*/
|
|
163
|
-
export function readRemoteFileId(target, remotePath) {
|
|
164
|
-
const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000 });
|
|
180
|
+
export function readRemoteFileId(target, remotePath, extraSshArgs) {
|
|
181
|
+
const res = sshExec(target, `stat -c '%d:%i' ${remotePath} 2>/dev/null || stat -f '%d:%i' ${remotePath} 2>/dev/null`, { timeoutMs: 8000, extraSshArgs });
|
|
165
182
|
const id = res.stdout.trim();
|
|
166
183
|
return id || null;
|
|
167
184
|
}
|
|
@@ -189,7 +206,7 @@ export async function followHostTask(target, opts) {
|
|
|
189
206
|
let mirror = true;
|
|
190
207
|
try {
|
|
191
208
|
const s = fs.statSync(local);
|
|
192
|
-
if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog))) {
|
|
209
|
+
if (mirrorAliasesSource(`${s.dev}:${s.ino}`, readRemoteFileId(target, opts.remoteLog, opts.extraSshArgs))) {
|
|
193
210
|
mirror = false;
|
|
194
211
|
}
|
|
195
212
|
}
|
|
@@ -208,6 +225,21 @@ export async function followHostTask(target, opts) {
|
|
|
208
225
|
offset += logChunk.length; // exact wire bytes — no re-encode drift
|
|
209
226
|
return true;
|
|
210
227
|
};
|
|
228
|
+
if (opts.remoteShell === 'powershell') {
|
|
229
|
+
for (;;) {
|
|
230
|
+
if (Date.now() >= deadline)
|
|
231
|
+
return -1;
|
|
232
|
+
const fetched = fetchProgress(target, { ...opts, offset, remoteShell: 'powershell' });
|
|
233
|
+
if (fetched) {
|
|
234
|
+
flush(fetched.logChunk);
|
|
235
|
+
const code = parseInt(fetched.exit.trim(), 10);
|
|
236
|
+
if (Number.isFinite(code))
|
|
237
|
+
return code;
|
|
238
|
+
}
|
|
239
|
+
await sleep(waitMs);
|
|
240
|
+
waitMs = Math.min(maxMs, Math.round(waitMs * 1.5));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
211
243
|
for (;;) {
|
|
212
244
|
const remaining = deadline - Date.now();
|
|
213
245
|
if (remaining <= 0) {
|
|
@@ -223,6 +255,7 @@ export async function followHostTask(target, opts) {
|
|
|
223
255
|
timeoutMs: remaining,
|
|
224
256
|
signal: abort.signal,
|
|
225
257
|
multiplex: true,
|
|
258
|
+
extraSshArgs: opts.extraSshArgs,
|
|
226
259
|
onStdout: (chunk) => { gotOutput = flush(chunk) || gotOutput; },
|
|
227
260
|
});
|
|
228
261
|
exitFrame = parseStreamingExitFrame(stream.stderr, opts.taskId);
|
|
@@ -18,20 +18,20 @@ export declare function buildProbeCommand(os?: string): string;
|
|
|
18
18
|
* platform / enrolled `HostEntry.os`); when it marks the host Windows we take
|
|
19
19
|
* the PowerShell path and report that known platform, otherwise POSIX + uname.
|
|
20
20
|
*/
|
|
21
|
-
export declare function probeHost(target: string, os?: string): {
|
|
21
|
+
export declare function probeHost(target: string, os?: string, extraSshArgs?: string[]): {
|
|
22
22
|
reachable: boolean;
|
|
23
23
|
os?: string;
|
|
24
24
|
};
|
|
25
25
|
/** ssh command that prints the remote agents-cli version. Pure/exported. */
|
|
26
26
|
export declare function buildRemoteVersionCommand(os?: string): string;
|
|
27
27
|
/** Remote agents-cli version (PATH-resolved on the remote), or null if not installed. */
|
|
28
|
-
export declare function remoteAgentsVersion(target: string, os?: string): string | null;
|
|
28
|
+
export declare function remoteAgentsVersion(target: string, os?: string, extraSshArgs?: string[]): string | null;
|
|
29
29
|
/** ssh command that installs/upgrades agents-cli to `spec` then `agents setup`.
|
|
30
30
|
* PowerShell has no `tail`/`[ -d ]`/`||`, so the Windows branch uses native
|
|
31
31
|
* equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
|
|
32
32
|
export declare function buildBootstrapCommand(spec: string, os?: string): string;
|
|
33
33
|
/** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
|
|
34
|
-
export declare function bootstrapAgentsCli(target: string, version: string | null, os?: string): {
|
|
34
|
+
export declare function bootstrapAgentsCli(target: string, version: string | null, os?: string, extraSshArgs?: string[]): {
|
|
35
35
|
ok: boolean;
|
|
36
36
|
output: string;
|
|
37
37
|
};
|
|
@@ -61,20 +61,61 @@ export interface ReadyProbe {
|
|
|
61
61
|
* absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
|
|
62
62
|
*/
|
|
63
63
|
export declare function buildReadyProbeCommand(os?: string): string;
|
|
64
|
-
export declare function readyProbe(target: string, os?: string): ReadyProbe;
|
|
64
|
+
export declare function readyProbe(target: string, os?: string, extraSshArgs?: string[]): ReadyProbe;
|
|
65
65
|
/** Pure parser for `readyProbe` output (unit-tested without ssh). */
|
|
66
66
|
export declare function parseReadyProbe(stdout: string): ReadyProbe;
|
|
67
67
|
/** True if `view` output lists the named agent (word-boundary, case-insensitive). */
|
|
68
68
|
export declare function viewHasAgent(view: string, agent: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* True when `version` is a concrete pin the remote must already have installed
|
|
71
|
+
* (e.g. `0.145.0`, `2.1.207`). Aliases (`latest` / `oldest` / `pinned` /
|
|
72
|
+
* `default` / `all` / `any`) resolve against the remote's own install set and
|
|
73
|
+
* are not preflight-checked here.
|
|
74
|
+
*/
|
|
75
|
+
export declare function isConcreteVersionPin(version?: string | null): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Installed version strings for `agent` from `agents view --json` output.
|
|
78
|
+
* Returns `[]` when the agent row is absent, `undefined` when `view` is not
|
|
79
|
+
* JSON (text `agents list` fallback) so callers can degrade.
|
|
80
|
+
*/
|
|
81
|
+
export declare function viewAgentVersions(view: string, agent: string): string[] | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Whether a concrete `agent@version` is installed according to remote view
|
|
84
|
+
* output. JSON path is authoritative; text listing falls back to a whole-token
|
|
85
|
+
* version match. `undefined` only when the text path has the agent but no
|
|
86
|
+
* version token to confirm against (callers treat that as unverified).
|
|
87
|
+
*/
|
|
88
|
+
export declare function viewHasAgentVersion(view: string, agent: string, version: string): boolean | undefined;
|
|
89
|
+
/** Actionable fail-loud message for a missing remote pin (RUSH-2313). */
|
|
90
|
+
export declare function missingPinnedVersionMessage(hostName: string, agent: string, version: string, installed?: string[]): string;
|
|
91
|
+
/** Read the requested harness's sign-in verdict from `agents view --json`. */
|
|
92
|
+
export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
|
|
69
93
|
export interface EnsureReadyOptions {
|
|
70
94
|
agent: string;
|
|
95
|
+
/**
|
|
96
|
+
* Explicit version pin (e.g. `"0.145.0"`). Concrete pins fail loud when the
|
|
97
|
+
* remote does not have that version installed so a detached `--no-follow`
|
|
98
|
+
* dispatch never reports "Dispatched" for a pin the box cannot run
|
|
99
|
+
* (RUSH-2313). Aliases (`latest` / …) are left for the remote CLI to resolve.
|
|
100
|
+
*/
|
|
101
|
+
version?: string;
|
|
71
102
|
/** Throw instead of warn when the agent isn't installed remotely. */
|
|
72
103
|
requireAgent?: boolean;
|
|
73
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Pure readiness verdict for the agent/version half of {@link ensureHostReady}
|
|
107
|
+
* (unit-tested without SSH). Returns warnings for soft agent-missing cases, or
|
|
108
|
+
* throws (via the caller's `throw new Error`) when a concrete pin is missing.
|
|
109
|
+
*/
|
|
110
|
+
export declare function evaluateHostAgentInstall(view: string, opts: EnsureReadyOptions, hostName: string): {
|
|
111
|
+
warnings: string[];
|
|
112
|
+
};
|
|
74
113
|
/**
|
|
75
114
|
* Verify a host can run the agent: reachable + agents-cli present. Throws with an
|
|
76
115
|
* actionable message otherwise. Agent-not-installed is a warning by default (the
|
|
77
116
|
* remote `agents run` will surface it); pass requireAgent to make it fatal.
|
|
117
|
+
* A concrete `version` pin always fails loud when that version is absent
|
|
118
|
+
* (RUSH-2313) — never a silent "Dispatched" for a pin the box cannot run.
|
|
78
119
|
*
|
|
79
120
|
* One ssh round-trip (`readyProbe`) covers all three checks.
|
|
80
121
|
*/
|
package/dist/lib/hosts/ready.js
CHANGED
|
@@ -9,7 +9,7 @@ import * as fs from 'fs';
|
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
12
|
-
import { sshTargetFor } from './types.js';
|
|
12
|
+
import { hostIdentityArgs, sshTargetFor } from './types.js';
|
|
13
13
|
import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './remote-cmd.js';
|
|
14
14
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
15
15
|
/** Resolve this CLI's own version by walking up to the nearest package.json. */
|
|
@@ -47,8 +47,8 @@ export function buildProbeCommand(os) {
|
|
|
47
47
|
* platform / enrolled `HostEntry.os`); when it marks the host Windows we take
|
|
48
48
|
* the PowerShell path and report that known platform, otherwise POSIX + uname.
|
|
49
49
|
*/
|
|
50
|
-
export function probeHost(target, os) {
|
|
51
|
-
const r = sshExec(target, buildProbeCommand(os), { timeoutMs: 12000 });
|
|
50
|
+
export function probeHost(target, os, extraSshArgs = []) {
|
|
51
|
+
const r = sshExec(target, buildProbeCommand(os), { timeoutMs: 12000, extraSshArgs });
|
|
52
52
|
if (r.code !== 0)
|
|
53
53
|
return { reachable: false };
|
|
54
54
|
if (remoteShellFor(os) === 'powershell')
|
|
@@ -63,8 +63,8 @@ export function buildRemoteVersionCommand(os) {
|
|
|
63
63
|
: 'bash -lc "agents --version 2>/dev/null"';
|
|
64
64
|
}
|
|
65
65
|
/** Remote agents-cli version (PATH-resolved on the remote), or null if not installed. */
|
|
66
|
-
export function remoteAgentsVersion(target, os) {
|
|
67
|
-
const r = sshExec(target, buildRemoteVersionCommand(os), { timeoutMs: 20000 });
|
|
66
|
+
export function remoteAgentsVersion(target, os, extraSshArgs = []) {
|
|
67
|
+
const r = sshExec(target, buildRemoteVersionCommand(os), { timeoutMs: 20000, extraSshArgs });
|
|
68
68
|
if (r.code !== 0)
|
|
69
69
|
return null;
|
|
70
70
|
const v = r.stdout.trim();
|
|
@@ -87,9 +87,9 @@ export function buildBootstrapCommand(spec, os) {
|
|
|
87
87
|
return `bash -lc ${shellQuote(script)}`;
|
|
88
88
|
}
|
|
89
89
|
/** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
|
|
90
|
-
export function bootstrapAgentsCli(target, version, os) {
|
|
90
|
+
export function bootstrapAgentsCli(target, version, os, extraSshArgs) {
|
|
91
91
|
const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
|
|
92
|
-
const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000 });
|
|
92
|
+
const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000, extraSshArgs });
|
|
93
93
|
return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() };
|
|
94
94
|
}
|
|
95
95
|
/** Sentinel splitting the version output from the agent listing in one probe. */
|
|
@@ -113,17 +113,17 @@ export function buildReadyProbeCommand(os) {
|
|
|
113
113
|
if (remoteShellFor(os) === 'powershell') {
|
|
114
114
|
const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
|
|
115
115
|
`agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
|
|
116
|
-
`agents view 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
|
|
116
|
+
`agents view --json 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
|
|
117
117
|
return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
118
118
|
}
|
|
119
119
|
const script = `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
|
|
120
|
-
`agents view 2>/dev/null || agents list 2>/dev/null`;
|
|
120
|
+
`agents view --json 2>/dev/null || agents list 2>/dev/null`;
|
|
121
121
|
return `bash -lc ${shellQuote(script)}`;
|
|
122
122
|
}
|
|
123
|
-
export function readyProbe(target, os) {
|
|
123
|
+
export function readyProbe(target, os, extraSshArgs) {
|
|
124
124
|
// Disable multiplexing: a stale control socket can hang the local ssh client
|
|
125
125
|
// until the timeout fires, just like sshExecAsync does for the same reason.
|
|
126
|
-
const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000, multiplex: false });
|
|
126
|
+
const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000, multiplex: false, extraSshArgs });
|
|
127
127
|
if (r.timedOut)
|
|
128
128
|
return { reachable: false, version: null, view: '', timedOut: true };
|
|
129
129
|
return parseReadyProbe(r.stdout);
|
|
@@ -140,16 +140,127 @@ export function parseReadyProbe(stdout) {
|
|
|
140
140
|
export function viewHasAgent(view, agent) {
|
|
141
141
|
return new RegExp(`\\b${agent}\\b`, 'i').test(view);
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* True when `version` is a concrete pin the remote must already have installed
|
|
145
|
+
* (e.g. `0.145.0`, `2.1.207`). Aliases (`latest` / `oldest` / `pinned` /
|
|
146
|
+
* `default` / `all` / `any`) resolve against the remote's own install set and
|
|
147
|
+
* are not preflight-checked here.
|
|
148
|
+
*/
|
|
149
|
+
export function isConcreteVersionPin(version) {
|
|
150
|
+
if (!version)
|
|
151
|
+
return false;
|
|
152
|
+
const v = version.trim();
|
|
153
|
+
if (!v)
|
|
154
|
+
return false;
|
|
155
|
+
// Keep in lockstep with agent-spec AGENT_QUALIFIERS (+ `any` filter token).
|
|
156
|
+
if (['latest', 'oldest', 'pinned', 'default', 'all', 'any'].includes(v.toLowerCase()))
|
|
157
|
+
return false;
|
|
158
|
+
// Same shape agent-spec accepts for an exact pin (no path traversal / spaces).
|
|
159
|
+
return /^(?!.*\.\.)[A-Za-z0-9._+-]{1,64}$/.test(v);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Installed version strings for `agent` from `agents view --json` output.
|
|
163
|
+
* Returns `[]` when the agent row is absent, `undefined` when `view` is not
|
|
164
|
+
* JSON (text `agents list` fallback) so callers can degrade.
|
|
165
|
+
*/
|
|
166
|
+
export function viewAgentVersions(view, agent) {
|
|
167
|
+
try {
|
|
168
|
+
const rows = JSON.parse(view);
|
|
169
|
+
if (!Array.isArray(rows))
|
|
170
|
+
return undefined;
|
|
171
|
+
const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
|
|
172
|
+
if (!row)
|
|
173
|
+
return [];
|
|
174
|
+
return (row.versions ?? [])
|
|
175
|
+
.map((entry) => entry.version)
|
|
176
|
+
.filter((v) => typeof v === 'string' && v.length > 0);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Whether a concrete `agent@version` is installed according to remote view
|
|
184
|
+
* output. JSON path is authoritative; text listing falls back to a whole-token
|
|
185
|
+
* version match. `undefined` only when the text path has the agent but no
|
|
186
|
+
* version token to confirm against (callers treat that as unverified).
|
|
187
|
+
*/
|
|
188
|
+
export function viewHasAgentVersion(view, agent, version) {
|
|
189
|
+
const versions = viewAgentVersions(view, agent);
|
|
190
|
+
if (versions !== undefined)
|
|
191
|
+
return versions.includes(version);
|
|
192
|
+
if (!viewHasAgent(view, agent))
|
|
193
|
+
return false;
|
|
194
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
195
|
+
if (new RegExp(`\\b${escaped}\\b`).test(view))
|
|
196
|
+
return true;
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
/** Actionable fail-loud message for a missing remote pin (RUSH-2313). */
|
|
200
|
+
export function missingPinnedVersionMessage(hostName, agent, version, installed) {
|
|
201
|
+
let installedHint = '';
|
|
202
|
+
if (installed) {
|
|
203
|
+
installedHint = installed.length > 0
|
|
204
|
+
? ` Installed on that box: ${installed.join(', ')}.`
|
|
205
|
+
: ` Agent "${agent}" is not installed there.`;
|
|
206
|
+
}
|
|
207
|
+
return (`Pinned ${agent}@${version} is not installed on "${hostName}".${installedHint} ` +
|
|
208
|
+
`Install it on that box: agents ssh ${hostName} -- agents add ${agent}@${version}`);
|
|
209
|
+
}
|
|
210
|
+
/** Read the requested harness's sign-in verdict from `agents view --json`. */
|
|
211
|
+
export function viewAgentSignedIn(view, agent) {
|
|
212
|
+
try {
|
|
213
|
+
const rows = JSON.parse(view);
|
|
214
|
+
const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
|
|
215
|
+
if (!row)
|
|
216
|
+
return undefined;
|
|
217
|
+
const verdicts = (row.versions ?? [])
|
|
218
|
+
.map((version) => version.signedIn)
|
|
219
|
+
.filter((value) => typeof value === 'boolean');
|
|
220
|
+
return verdicts.length === 0 ? undefined : verdicts.some(Boolean);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Pure readiness verdict for the agent/version half of {@link ensureHostReady}
|
|
228
|
+
* (unit-tested without SSH). Returns warnings for soft agent-missing cases, or
|
|
229
|
+
* throws (via the caller's `throw new Error`) when a concrete pin is missing.
|
|
230
|
+
*/
|
|
231
|
+
export function evaluateHostAgentInstall(view, opts, hostName) {
|
|
232
|
+
const warnings = [];
|
|
233
|
+
if (isConcreteVersionPin(opts.version)) {
|
|
234
|
+
const version = opts.version.trim();
|
|
235
|
+
const installed = viewAgentVersions(view, opts.agent);
|
|
236
|
+
const has = viewHasAgentVersion(view, opts.agent, version);
|
|
237
|
+
if (has === true)
|
|
238
|
+
return { warnings };
|
|
239
|
+
// Missing or unverifiable pin: fail loud. Detached host dispatch used to
|
|
240
|
+
// print "Dispatched" and only die in the remote log ("codex@0.145.0 is not
|
|
241
|
+
// installed") — whole fleet drains no-op'd under that silent success.
|
|
242
|
+
throw new Error(missingPinnedVersionMessage(hostName, opts.agent, version, installed));
|
|
243
|
+
}
|
|
244
|
+
if (!viewHasAgent(view, opts.agent)) {
|
|
245
|
+
const msg = `Agent "${opts.agent}" may not be installed on "${hostName}" (remote \`agents add ${opts.agent}\` to install).`;
|
|
246
|
+
if (opts.requireAgent)
|
|
247
|
+
throw new Error(msg);
|
|
248
|
+
warnings.push(msg);
|
|
249
|
+
}
|
|
250
|
+
return { warnings };
|
|
251
|
+
}
|
|
143
252
|
/**
|
|
144
253
|
* Verify a host can run the agent: reachable + agents-cli present. Throws with an
|
|
145
254
|
* actionable message otherwise. Agent-not-installed is a warning by default (the
|
|
146
255
|
* remote `agents run` will surface it); pass requireAgent to make it fatal.
|
|
256
|
+
* A concrete `version` pin always fails loud when that version is absent
|
|
257
|
+
* (RUSH-2313) — never a silent "Dispatched" for a pin the box cannot run.
|
|
147
258
|
*
|
|
148
259
|
* One ssh round-trip (`readyProbe`) covers all three checks.
|
|
149
260
|
*/
|
|
150
261
|
export function ensureHostReady(host, opts) {
|
|
151
262
|
const target = sshTargetFor(host);
|
|
152
|
-
const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name));
|
|
263
|
+
const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name), hostIdentityArgs(host));
|
|
153
264
|
if (probe.timedOut) {
|
|
154
265
|
throw new Error(`Host "${host.name}" (${target}) did not respond in time — the SSH probe timed out after 20 seconds. ` +
|
|
155
266
|
`The host may be slow to start a login shell (nvm/sdkman init, cold node startup). ` +
|
|
@@ -161,12 +272,5 @@ export function ensureHostReady(host, opts) {
|
|
|
161
272
|
if (!probe.version) {
|
|
162
273
|
throw new Error(`agents-cli is not installed on "${host.name}". Enroll it first: agents hosts add ${host.name} (bootstraps agents-cli).`);
|
|
163
274
|
}
|
|
164
|
-
|
|
165
|
-
if (!viewHasAgent(probe.view, opts.agent)) {
|
|
166
|
-
const msg = `Agent "${opts.agent}" may not be installed on "${host.name}" (remote \`agents add ${opts.agent}\` to install).`;
|
|
167
|
-
if (opts.requireAgent)
|
|
168
|
-
throw new Error(msg);
|
|
169
|
-
warnings.push(msg);
|
|
170
|
-
}
|
|
171
|
-
return { warnings };
|
|
275
|
+
return evaluateHostAgentInstall(probe.view, opts, host.name);
|
|
172
276
|
}
|
|
@@ -35,7 +35,7 @@ export declare function classifyExit(res: Pick<SshExecResult, 'code' | 'stdout'
|
|
|
35
35
|
* $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
|
|
36
36
|
* remote shell expands $HOME (same contract as progress.ts's fetch).
|
|
37
37
|
*/
|
|
38
|
-
export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number): RemoteExitState;
|
|
38
|
+
export declare function readRemoteExit(target: string, remoteExit: string, timeoutMs?: number, identityFile?: string, remoteShell?: 'posix' | 'powershell'): RemoteExitState;
|
|
39
39
|
/**
|
|
40
40
|
* Heal one record. Terminal records are immutable (and never re-probed); a
|
|
41
41
|
* `running` record is resolved to completed/failed only when the remote `.exit`
|
|
@@ -51,3 +51,4 @@ export declare function reconcileTask(task: HostTask): HostTask;
|
|
|
51
51
|
* (async) ssh path is deliberately out of scope.
|
|
52
52
|
*/
|
|
53
53
|
export declare function reconcileRunningTasks(tasks: HostTask[]): HostTask[];
|
|
54
|
+
export declare function reachabilityProbeCommand(remoteShell?: 'posix' | 'powershell'): string;
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* record. We only ever CONFIRM completion; an unreachable host or an absent
|
|
11
11
|
* `.exit` leaves the record `running` (we never guess failure).
|
|
12
12
|
*/
|
|
13
|
-
import { sshExec
|
|
13
|
+
import { sshExec } from '../ssh-exec.js';
|
|
14
14
|
import { updateTask, terminalPatch } from './tasks.js';
|
|
15
|
+
import { encodePowershell } from './remote-cmd.js';
|
|
15
16
|
/**
|
|
16
17
|
* Classify a `cat <remoteExit>` result into a remote run state. Pure: all the
|
|
17
18
|
* bug-prone branching (ssh-failure vs absent vs empty vs coded) lives here so it
|
|
@@ -35,8 +36,15 @@ export function classifyExit(res) {
|
|
|
35
36
|
* $HOME-prefixed path with a safe (hex) basename — intentionally unquoted so the
|
|
36
37
|
* remote shell expands $HOME (same contract as progress.ts's fetch).
|
|
37
38
|
*/
|
|
38
|
-
export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
|
|
39
|
-
|
|
39
|
+
export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFile, remoteShell = 'posix') {
|
|
40
|
+
const command = remoteShell === 'powershell'
|
|
41
|
+
? `powershell -NoProfile -EncodedCommand ${encodePowershell(`$path = Join-Path $HOME '${remoteExit.replace(/^\$HOME\//, '').replace(/'/g, "''")}'; if (Test-Path -LiteralPath $path) { Get-Content -LiteralPath $path -Raw }`)}`
|
|
42
|
+
: `cat ${remoteExit} 2>/dev/null`;
|
|
43
|
+
return classifyExit(sshExec(target, command, {
|
|
44
|
+
timeoutMs,
|
|
45
|
+
multiplex: true,
|
|
46
|
+
extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
|
|
47
|
+
}));
|
|
40
48
|
}
|
|
41
49
|
/**
|
|
42
50
|
* Heal one record. Terminal records are immutable (and never re-probed); a
|
|
@@ -46,7 +54,7 @@ export function readRemoteExit(target, remoteExit, timeoutMs = 6000) {
|
|
|
46
54
|
export function reconcileTask(task) {
|
|
47
55
|
if (task.status !== 'running')
|
|
48
56
|
return task;
|
|
49
|
-
const st = readRemoteExit(task.target, task.remoteExit);
|
|
57
|
+
const st = readRemoteExit(task.target, task.remoteExit, 6000, task.identityFile, task.remoteShell);
|
|
50
58
|
if (st.state !== 'done')
|
|
51
59
|
return task;
|
|
52
60
|
return updateTask(task.id, terminalPatch(st.code)) ?? task;
|
|
@@ -66,11 +74,17 @@ export function reconcileRunningTasks(tasks) {
|
|
|
66
74
|
const reachable = new Map();
|
|
67
75
|
const patched = new Map();
|
|
68
76
|
for (const t of running) {
|
|
69
|
-
if (!reachable.has(t.target))
|
|
70
|
-
|
|
77
|
+
if (!reachable.has(t.target)) {
|
|
78
|
+
const probeCommand = reachabilityProbeCommand(t.remoteShell);
|
|
79
|
+
const probe = sshExec(t.target, probeCommand, {
|
|
80
|
+
timeoutMs: 6000,
|
|
81
|
+
extraSshArgs: t.identityFile ? ['-i', t.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
|
|
82
|
+
});
|
|
83
|
+
reachable.set(t.target, probe.code === 0);
|
|
84
|
+
}
|
|
71
85
|
if (!reachable.get(t.target))
|
|
72
86
|
continue; // host down → leave running
|
|
73
|
-
const st = readRemoteExit(t.target, t.remoteExit);
|
|
87
|
+
const st = readRemoteExit(t.target, t.remoteExit, 6000, t.identityFile, t.remoteShell);
|
|
74
88
|
if (st.state === 'done') {
|
|
75
89
|
const updated = updateTask(t.id, terminalPatch(st.code));
|
|
76
90
|
if (updated)
|
|
@@ -79,3 +93,6 @@ export function reconcileRunningTasks(tasks) {
|
|
|
79
93
|
}
|
|
80
94
|
return tasks.map((t) => patched.get(t.id) ?? t);
|
|
81
95
|
}
|
|
96
|
+
export function reachabilityProbeCommand(remoteShell) {
|
|
97
|
+
return remoteShell === 'powershell' ? 'powershell -NoProfile -Command "exit 0"' : 'true';
|
|
98
|
+
}
|