@phnx-labs/agents-cli 1.22.27 → 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 +49 -0
- package/README.md +62 -26
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +54 -10
- package/dist/commands/hosts.js +4 -3
- 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/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.d.ts +26 -6
- package/dist/commands/sessions.js +46 -12
- package/dist/commands/teams.js +19 -8
- package/dist/lib/activity.js +45 -99
- package/dist/lib/claude-account-token.js +38 -11
- package/dist/lib/cloud/host.js +1 -0
- package/dist/lib/devices/resolve-target.d.ts +1 -0
- package/dist/lib/devices/resolve-target.js +9 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +2 -2
- 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.js +4 -1
- package/dist/lib/hosts/dispatch.d.ts +10 -0
- package/dist/lib/hosts/dispatch.js +78 -22
- package/dist/lib/hosts/logs.js +11 -1
- package/dist/lib/hosts/passthrough.d.ts +5 -0
- package/dist/lib/hosts/passthrough.js +5 -2
- package/dist/lib/hosts/progress.d.ts +10 -0
- package/dist/lib/hosts/progress.js +37 -5
- package/dist/lib/hosts/ready.d.ts +41 -2
- package/dist/lib/hosts/ready.js +100 -12
- package/dist/lib/hosts/reconcile.d.ts +2 -1
- package/dist/lib/hosts/reconcile.js +13 -5
- package/dist/lib/hosts/reconnect.js +5 -3
- package/dist/lib/hosts/registry.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +1 -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/snapshot.js +15 -2
- package/dist/lib/models.d.ts +18 -11
- package/dist/lib/models.js +19 -15
- package/dist/lib/perf/db.js +16 -3
- package/dist/lib/perf/types.d.ts +12 -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/refresh.js +22 -11
- package/dist/lib/rotate.d.ts +22 -0
- package/dist/lib/rotate.js +26 -0
- 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 +58 -20
- 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/session-store.d.ts +7 -0
- package/dist/lib/secrets/session-store.js +21 -0
- 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 +4 -0
- package/dist/lib/session/db.js +32 -15
- package/dist/lib/session/remote-bundle.js +1 -1
- 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/teams/agents.d.ts +1 -0
- package/dist/lib/teams/agents.js +38 -11
- package/dist/lib/teams/remoteWorktree.d.ts +11 -7
- package/dist/lib/teams/remoteWorktree.js +29 -27
- package/dist/lib/versions.d.ts +1 -0
- package/dist/lib/versions.js +40 -22
- package/package.json +3 -1
- package/dist/lib/exec.bench.d.ts +0 -1
- package/dist/lib/exec.bench.js +0 -186
|
@@ -12,7 +12,7 @@ import { randomUUID } from 'crypto';
|
|
|
12
12
|
import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
|
|
13
13
|
import { hostIdentityArgs, sshTargetFor } from './types.js';
|
|
14
14
|
import { ensureHostReady } from './ready.js';
|
|
15
|
-
import { remoteShellFor, posixEnvExports } from './remote-cmd.js';
|
|
15
|
+
import { encodePowershell, powershellQuote, remoteShellFor, posixEnvExports } from './remote-cmd.js';
|
|
16
16
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
17
17
|
import { resolveActor, actorEnv } from '../actor.js';
|
|
18
18
|
import { saveTask, updateTask, terminalPatch } from './tasks.js';
|
|
@@ -148,14 +148,52 @@ export function buildDetachedLaunchCommand(inner) {
|
|
|
148
148
|
].join(' ');
|
|
149
149
|
return `bash -lc ${shellQuote(`node -e ${shellQuote(nodeScript)}`)}`;
|
|
150
150
|
}
|
|
151
|
+
function windowsRemotePath(path) {
|
|
152
|
+
return path.startsWith('$HOME/')
|
|
153
|
+
? `(Join-Path $HOME ${powershellQuote(path.slice('$HOME/'.length))})`
|
|
154
|
+
: powershellQuote(path);
|
|
155
|
+
}
|
|
156
|
+
/** Build the detached PowerShell launch protocol used by Windows SSH hosts. */
|
|
157
|
+
export function buildWindowsDetachedLaunchCommand(opts) {
|
|
158
|
+
const log = windowsRemotePath(opts.remoteLog);
|
|
159
|
+
const exit = windowsRemotePath(opts.remoteExit);
|
|
160
|
+
const env = Object.entries(opts.env).map(([key, value]) => `$env:${key} = ${powershellQuote(value)}`);
|
|
161
|
+
const cwd = opts.remoteCwd
|
|
162
|
+
? opts.mirrorCwd
|
|
163
|
+
? `try { Set-Location -LiteralPath ${powershellQuote(opts.remoteCwd)} } catch { Set-Location -LiteralPath $HOME }`
|
|
164
|
+
: `Set-Location -LiteralPath ${powershellQuote(opts.remoteCwd)}`
|
|
165
|
+
: '';
|
|
166
|
+
const inner = [
|
|
167
|
+
`$ProgressPreference = 'SilentlyContinue'`,
|
|
168
|
+
`$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'`,
|
|
169
|
+
...env,
|
|
170
|
+
cwd,
|
|
171
|
+
`$log = ${log}`,
|
|
172
|
+
`$exit = ${exit}`,
|
|
173
|
+
`$code = 1`,
|
|
174
|
+
`try { & ${['agents', ...opts.forwardedArgs].map(powershellQuote).join(' ')} *> $log; if ($null -ne $LASTEXITCODE) { $code = $LASTEXITCODE } } catch { $_ | Out-File -LiteralPath $log -Append; $code = 1 } finally { Set-Content -LiteralPath $exit -Value $code -NoNewline -Encoding ascii }`,
|
|
175
|
+
].filter(Boolean).join('; ');
|
|
176
|
+
const encodedInner = encodePowershell(inner);
|
|
177
|
+
const outer = [
|
|
178
|
+
`$dir = Join-Path $HOME '.agents/.cache/hosts'`,
|
|
179
|
+
`New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
|
|
180
|
+
`Remove-Item -LiteralPath ${exit} -Force -ErrorAction SilentlyContinue`,
|
|
181
|
+
`$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powershellQuote(`powershell.exe -NoProfile -EncodedCommand ${encodedInner}`)} }`,
|
|
182
|
+
`if ($result.ReturnValue -ne 0) { throw \"Win32_Process.Create failed with code $($result.ReturnValue)\" }`,
|
|
183
|
+
`Write-Output $result.ProcessId`,
|
|
184
|
+
].join('; ');
|
|
185
|
+
return `powershell -NoProfile -EncodedCommand ${encodePowershell(outer)}`;
|
|
186
|
+
}
|
|
151
187
|
function terminateRemoteLaunch(task) {
|
|
152
188
|
if (!task.pid)
|
|
153
189
|
throw new Error(`Cannot terminate remote task ${task.id}: launch returned no PID.`);
|
|
154
190
|
const pid = task.pid;
|
|
155
|
-
const command =
|
|
156
|
-
`
|
|
157
|
-
`
|
|
158
|
-
|
|
191
|
+
const command = task.remoteShell === 'powershell'
|
|
192
|
+
? `powershell -NoProfile -EncodedCommand ${encodePowershell(`Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath ${windowsRemotePath(task.remoteLog)}, ${windowsRemotePath(task.remoteExit)} -Force -ErrorAction SilentlyContinue`)}`
|
|
193
|
+
: `if kill -TERM -- -${pid} 2>/dev/null; then ` +
|
|
194
|
+
`sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
|
|
195
|
+
`elif kill -0 -- -${pid} 2>/dev/null; then exit 1; fi; ` +
|
|
196
|
+
`rm -f ${task.remoteLog} ${task.remoteExit}`;
|
|
159
197
|
const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true, extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined });
|
|
160
198
|
if (result.code !== 0) {
|
|
161
199
|
throw new Error(`Failed to terminate remote task ${task.id} on ${task.host}: ` +
|
|
@@ -194,6 +232,17 @@ export function buildStopRemoteCommand(pid, remoteExit) {
|
|
|
194
232
|
`else echo 143 > ${remoteExit}; echo GONE; fi; ` +
|
|
195
233
|
`fi`);
|
|
196
234
|
}
|
|
235
|
+
export function buildWindowsStopRemoteCommand(pid, remoteExit) {
|
|
236
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
237
|
+
throw new Error(`Invalid remote task pid: ${pid}`);
|
|
238
|
+
const exit = windowsRemotePath(remoteExit);
|
|
239
|
+
const script = `$process = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; ` +
|
|
240
|
+
`function Get-DescendantProcessIds([int]$ParentId) { $children = Get-CimInstance Win32_Process -Filter \"ParentProcessId = $ParentId\"; foreach ($child in $children) { Get-DescendantProcessIds $child.ProcessId; $child.ProcessId } }; ` +
|
|
241
|
+
`if ($process) { $descendants = @(Get-DescendantProcessIds ${pid}); foreach ($childId in $descendants) { Stop-Process -Id $childId -Force -ErrorAction SilentlyContinue }; Stop-Process -Id ${pid} -Force; Set-Content -LiteralPath ${exit} -Value 143 -NoNewline -Encoding ascii; Write-Output 'SIGNALED' } ` +
|
|
242
|
+
`elseif (Test-Path -LiteralPath ${exit}) { $code = (Get-Content -LiteralPath ${exit} -Raw).Trim(); if ($code) { Write-Output (\"ALREADY $code\") } else { Set-Content -LiteralPath ${exit} -Value 143 -NoNewline -Encoding ascii; Write-Output 'GONE' } } ` +
|
|
243
|
+
`else { Set-Content -LiteralPath ${exit} -Value 143 -NoNewline -Encoding ascii; Write-Output 'GONE' }`;
|
|
244
|
+
return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
245
|
+
}
|
|
197
246
|
/**
|
|
198
247
|
* Stop a running host task from the origin machine (`agents hosts stop <id>`).
|
|
199
248
|
*
|
|
@@ -209,7 +258,9 @@ export function stopDispatchedTask(task) {
|
|
|
209
258
|
if (!task.pid) {
|
|
210
259
|
throw new Error(`Cannot stop remote task ${task.id}: launch returned no PID.`);
|
|
211
260
|
}
|
|
212
|
-
const command =
|
|
261
|
+
const command = task.remoteShell === 'powershell'
|
|
262
|
+
? buildWindowsStopRemoteCommand(task.pid, task.remoteExit)
|
|
263
|
+
: buildStopRemoteCommand(task.pid, task.remoteExit);
|
|
213
264
|
const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true, extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined });
|
|
214
265
|
if (result.code !== 0) {
|
|
215
266
|
throw new Error(`Failed to stop remote task ${task.id} on ${task.host}: ` +
|
|
@@ -230,21 +281,11 @@ export function stopDispatchedTask(task) {
|
|
|
230
281
|
* and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
|
|
231
282
|
* so the nohup/exit-file/offset-tail machinery lives in exactly one place.
|
|
232
283
|
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* tails the log with POSIX `tail -c`/`printf`/`cat`/`stat`, which do not exist
|
|
236
|
-
* in cmd.exe/PowerShell. Shipping the launch alone would leave `run --host
|
|
237
|
-
* <windows>` dispatching but hanging on follow forever, so we fail fast with an
|
|
238
|
-
* actionable message instead. Read-only `--host` commands (view/sessions/…) run
|
|
239
|
-
* a single round-trip with no follow protocol and DO work against Windows.
|
|
284
|
+
* POSIX hosts use a detached bash process group; Windows hosts use a hidden
|
|
285
|
+
* detached PowerShell process and the same durable log/exit-file protocol.
|
|
240
286
|
*/
|
|
241
287
|
async function launchDetached(host, target, opts) {
|
|
242
|
-
|
|
243
|
-
throw new Error(`Detached dispatch to Windows host "${host.name}" is not supported yet — the run ` +
|
|
244
|
-
`follow/reconcile layer is POSIX-only (offset-tails the remote log with tail/cat/stat). ` +
|
|
245
|
-
`Read-only --host commands (view, sessions, usage, cost, doctor, list, teams) do work ` +
|
|
246
|
-
`against Windows.`);
|
|
247
|
-
}
|
|
288
|
+
const remoteShell = remoteShellFor(host.os ?? resolveRemoteOsSync(host.name));
|
|
248
289
|
const id = randomUUID().slice(0, 8);
|
|
249
290
|
const remoteLog = `${REMOTE_DIR}/${id}.log`;
|
|
250
291
|
const remoteExit = `${REMOTE_DIR}/${id}.exit`;
|
|
@@ -266,7 +307,16 @@ async function launchDetached(host, target, opts) {
|
|
|
266
307
|
const credHostKeyOpts = opts.copyCreds ? hostKeyCheckingOpts(true) : undefined;
|
|
267
308
|
// Outer: ensure dir, launch the login-shell wrapper as a new process-group
|
|
268
309
|
// leader, and print that leader PID.
|
|
269
|
-
const launch =
|
|
310
|
+
const launch = remoteShell === 'powershell'
|
|
311
|
+
? buildWindowsDetachedLaunchCommand({
|
|
312
|
+
forwardedArgs: opts.forwardedArgs,
|
|
313
|
+
remoteCwd: opts.remoteCwd,
|
|
314
|
+
mirrorCwd: opts.mirrorCwd,
|
|
315
|
+
remoteLog,
|
|
316
|
+
remoteExit,
|
|
317
|
+
env: withActorEnv(opts.agentLabel === RUN_AUTO_KEYWORD ? { [RUN_AUTO_HOST_RESOLVED_ENV]: '1' } : {}),
|
|
318
|
+
})
|
|
319
|
+
: `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
|
|
270
320
|
const res = sshExec(target, launch, {
|
|
271
321
|
timeoutMs: 30000,
|
|
272
322
|
multiplex: !opts.copyCreds,
|
|
@@ -282,6 +332,7 @@ async function launchDetached(host, target, opts) {
|
|
|
282
332
|
host: host.name,
|
|
283
333
|
target,
|
|
284
334
|
identityFile: host.identityFile,
|
|
335
|
+
remoteShell,
|
|
285
336
|
agent: opts.agentLabel,
|
|
286
337
|
prompt: opts.promptLabel,
|
|
287
338
|
pid: Number.isFinite(pid) ? pid : undefined,
|
|
@@ -314,6 +365,7 @@ async function launchDetached(host, target, opts) {
|
|
|
314
365
|
echo: true,
|
|
315
366
|
timeoutMs: opts.timeoutMs,
|
|
316
367
|
extraSshArgs: hostIdentityArgs(host),
|
|
368
|
+
remoteShell,
|
|
317
369
|
});
|
|
318
370
|
// -1 = the follow window closed while the run continues on the host. Leave the
|
|
319
371
|
// record 'running' (do NOT freeze it terminal) so a later `hosts ps`/`logs`
|
|
@@ -446,7 +498,9 @@ export function buildInteractiveRunForwardedArgs(opts) {
|
|
|
446
498
|
*/
|
|
447
499
|
export async function runInteractiveOnHost(host, opts) {
|
|
448
500
|
const target = sshTargetFor(host);
|
|
449
|
-
|
|
501
|
+
// Concrete version pins fail loud here (RUSH-2313) so we never open a TTY
|
|
502
|
+
// to a box that cannot run the pin.
|
|
503
|
+
const { warnings } = ensureHostReady(host, { agent: opts.agent, version: opts.version });
|
|
450
504
|
for (const w of warnings)
|
|
451
505
|
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
452
506
|
const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
|
|
@@ -473,7 +527,9 @@ export async function runInteractiveOnHost(host, opts) {
|
|
|
473
527
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
474
528
|
export async function dispatchToHost(host, opts) {
|
|
475
529
|
const target = sshTargetFor(host);
|
|
476
|
-
|
|
530
|
+
// Concrete agent@version pins fail loud before we print "Dispatched" and
|
|
531
|
+
// leave a dead remote log (RUSH-2313). Aliases stay remote-resolved.
|
|
532
|
+
const { warnings } = ensureHostReady(host, { agent: opts.agent, version: opts.version });
|
|
477
533
|
for (const w of warnings)
|
|
478
534
|
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
479
535
|
return launchDetached(host, target, {
|
package/dist/lib/hosts/logs.js
CHANGED
|
@@ -16,6 +16,7 @@ import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
|
|
|
16
16
|
import { followHostTask } from './progress.js';
|
|
17
17
|
import { reconcileTask } from './reconcile.js';
|
|
18
18
|
import { sshExecRaw } from '../ssh-exec.js';
|
|
19
|
+
import { encodePowershell } from './remote-cmd.js';
|
|
19
20
|
/** Lines of raw combined-stdout to show in the concise (non-`full`) view. */
|
|
20
21
|
const HOST_LOG_TAIL_LINES = 40;
|
|
21
22
|
/**
|
|
@@ -32,6 +33,8 @@ export async function showHostTaskLog(id, follow, full = false) {
|
|
|
32
33
|
remoteExit: task.remoteExit,
|
|
33
34
|
taskId: id,
|
|
34
35
|
echo: true,
|
|
36
|
+
remoteShell: task.remoteShell,
|
|
37
|
+
extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : [],
|
|
35
38
|
});
|
|
36
39
|
// -1 = follow window closed; the run continues on the host (not a failure,
|
|
37
40
|
// so exit 0). Any real code is the finished run — persist the terminal
|
|
@@ -106,7 +109,14 @@ export function tailLines(text, n) {
|
|
|
106
109
|
* and progress.ts.
|
|
107
110
|
*/
|
|
108
111
|
function fetchAndCacheRemoteLog(task) {
|
|
109
|
-
const
|
|
112
|
+
const command = task.remoteShell === 'powershell'
|
|
113
|
+
? `powershell -NoProfile -EncodedCommand ${encodePowershell(`$path = Join-Path $HOME '${task.remoteLog.replace(/^\$HOME\//, '').replace(/'/g, "''")}'; if (Test-Path -LiteralPath $path) { $bytes = [IO.File]::ReadAllBytes($path); [Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length) }`)}`
|
|
114
|
+
: `cat ${task.remoteLog} 2>/dev/null`;
|
|
115
|
+
const res = sshExecRaw(task.target, command, {
|
|
116
|
+
timeoutMs: 30000,
|
|
117
|
+
multiplex: true,
|
|
118
|
+
extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
|
|
119
|
+
});
|
|
110
120
|
if (res.code !== 0 || res.stdout.length === 0)
|
|
111
121
|
return null;
|
|
112
122
|
// The hosts cache dir already exists (saveTask created it) — write is best-effort.
|
|
@@ -126,4 +126,9 @@ export declare function streamAgentsOnHost(host: Host, forwardedArgs: string[],
|
|
|
126
126
|
remoteOs?: string;
|
|
127
127
|
target?: string;
|
|
128
128
|
}): number;
|
|
129
|
+
export declare function passthroughSshOptions(host: Host, interactive: boolean): {
|
|
130
|
+
tty: boolean;
|
|
131
|
+
multiplex: true;
|
|
132
|
+
extraSshArgs: string[];
|
|
133
|
+
};
|
|
129
134
|
export {};
|
|
@@ -19,7 +19,7 @@
|
|
|
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
24
|
import { stripRoutingFlags, buildRemoteAgentsInvocation, stripClixml, HOST_ROUTING_SPECS, } from './remote-cmd.js';
|
|
25
25
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
@@ -620,10 +620,13 @@ export function streamAgentsOnHost(host, forwardedArgs, opts = {}) {
|
|
|
620
620
|
const remoteOs = opts.remoteOs ?? resolveRemoteOsSync(host.name);
|
|
621
621
|
const env = withActorEnv({ ...opts.extraEnv, AGENTS_FLEET_REMOTE: '1' });
|
|
622
622
|
const remoteCmd = buildRemoteAgentsInvocation(forwardedArgs, opts.remoteCwd, remoteOs, env);
|
|
623
|
-
const code = sshStream(target, remoteCmd,
|
|
623
|
+
const code = sshStream(target, remoteCmd, passthroughSshOptions(host, !!opts.interactive));
|
|
624
624
|
if (code === 255) {
|
|
625
625
|
console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
|
|
626
626
|
chalk.gray(' Check: agents hosts check ' + host.name));
|
|
627
627
|
}
|
|
628
628
|
return code;
|
|
629
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;
|
|
@@ -56,6 +57,7 @@ export interface FollowOptions {
|
|
|
56
57
|
/** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
|
|
57
58
|
maxPollMs?: number;
|
|
58
59
|
extraSshArgs?: string[];
|
|
60
|
+
remoteShell?: 'posix' | 'powershell';
|
|
59
61
|
}
|
|
60
62
|
/**
|
|
61
63
|
* Build the per-task sentinel that separates the log tail from the exit-file
|
|
@@ -97,10 +99,18 @@ export declare function fetchProgress(target: string, opts: {
|
|
|
97
99
|
remoteExit: string;
|
|
98
100
|
taskId: string;
|
|
99
101
|
offset: number;
|
|
102
|
+
extraSshArgs?: string[];
|
|
103
|
+
remoteShell?: 'posix' | 'powershell';
|
|
100
104
|
}): {
|
|
101
105
|
logChunk: Buffer;
|
|
102
106
|
exit: string;
|
|
103
107
|
} | null;
|
|
108
|
+
export declare function buildWindowsProgressCommand(opts: {
|
|
109
|
+
remoteLog: string;
|
|
110
|
+
remoteExit: string;
|
|
111
|
+
taskId: string;
|
|
112
|
+
offset: number;
|
|
113
|
+
}): string;
|
|
104
114
|
/**
|
|
105
115
|
* Remote shell for the persistent follow stream.
|
|
106
116
|
*
|
|
@@ -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
|
*
|
|
@@ -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) {
|
|
@@ -18,14 +18,14 @@ 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. */
|
|
@@ -66,17 +66,56 @@ export declare function readyProbe(target: string, os?: string, extraSshArgs?: s
|
|
|
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;
|
|
69
91
|
/** Read the requested harness's sign-in verdict from `agents view --json`. */
|
|
70
92
|
export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
|
|
71
93
|
export interface EnsureReadyOptions {
|
|
72
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;
|
|
73
102
|
/** Throw instead of warn when the agent isn't installed remotely. */
|
|
74
103
|
requireAgent?: boolean;
|
|
75
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
|
+
};
|
|
76
113
|
/**
|
|
77
114
|
* Verify a host can run the agent: reachable + agents-cli present. Throws with an
|
|
78
115
|
* actionable message otherwise. Agent-not-installed is a warning by default (the
|
|
79
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.
|
|
80
119
|
*
|
|
81
120
|
* One ssh round-trip (`readyProbe`) covers all three checks.
|
|
82
121
|
*/
|
package/dist/lib/hosts/ready.js
CHANGED
|
@@ -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();
|
|
@@ -140,6 +140,73 @@ 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
|
+
}
|
|
143
210
|
/** Read the requested harness's sign-in verdict from `agents view --json`. */
|
|
144
211
|
export function viewAgentSignedIn(view, agent) {
|
|
145
212
|
try {
|
|
@@ -156,10 +223,38 @@ export function viewAgentSignedIn(view, agent) {
|
|
|
156
223
|
return undefined;
|
|
157
224
|
}
|
|
158
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
|
+
}
|
|
159
252
|
/**
|
|
160
253
|
* Verify a host can run the agent: reachable + agents-cli present. Throws with an
|
|
161
254
|
* actionable message otherwise. Agent-not-installed is a warning by default (the
|
|
162
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.
|
|
163
258
|
*
|
|
164
259
|
* One ssh round-trip (`readyProbe`) covers all three checks.
|
|
165
260
|
*/
|
|
@@ -177,12 +272,5 @@ export function ensureHostReady(host, opts) {
|
|
|
177
272
|
if (!probe.version) {
|
|
178
273
|
throw new Error(`agents-cli is not installed on "${host.name}". Enroll it first: agents hosts add ${host.name} (bootstraps agents-cli).`);
|
|
179
274
|
}
|
|
180
|
-
|
|
181
|
-
if (!viewHasAgent(probe.view, opts.agent)) {
|
|
182
|
-
const msg = `Agent "${opts.agent}" may not be installed on "${host.name}" (remote \`agents add ${opts.agent}\` to install).`;
|
|
183
|
-
if (opts.requireAgent)
|
|
184
|
-
throw new Error(msg);
|
|
185
|
-
warnings.push(msg);
|
|
186
|
-
}
|
|
187
|
-
return { warnings };
|
|
275
|
+
return evaluateHostAgentInstall(probe.view, opts, host.name);
|
|
188
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, identityFile?: string): 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;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
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,11 @@ 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, identityFile) {
|
|
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, {
|
|
40
44
|
timeoutMs,
|
|
41
45
|
multiplex: true,
|
|
42
46
|
extraSshArgs: identityFile ? ['-i', identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
|
|
@@ -50,7 +54,7 @@ export function readRemoteExit(target, remoteExit, timeoutMs = 6000, identityFil
|
|
|
50
54
|
export function reconcileTask(task) {
|
|
51
55
|
if (task.status !== 'running')
|
|
52
56
|
return task;
|
|
53
|
-
const st = readRemoteExit(task.target, task.remoteExit, 6000, task.identityFile);
|
|
57
|
+
const st = readRemoteExit(task.target, task.remoteExit, 6000, task.identityFile, task.remoteShell);
|
|
54
58
|
if (st.state !== 'done')
|
|
55
59
|
return task;
|
|
56
60
|
return updateTask(task.id, terminalPatch(st.code)) ?? task;
|
|
@@ -71,7 +75,8 @@ export function reconcileRunningTasks(tasks) {
|
|
|
71
75
|
const patched = new Map();
|
|
72
76
|
for (const t of running) {
|
|
73
77
|
if (!reachable.has(t.target)) {
|
|
74
|
-
const
|
|
78
|
+
const probeCommand = reachabilityProbeCommand(t.remoteShell);
|
|
79
|
+
const probe = sshExec(t.target, probeCommand, {
|
|
75
80
|
timeoutMs: 6000,
|
|
76
81
|
extraSshArgs: t.identityFile ? ['-i', t.identityFile, '-o', 'IdentitiesOnly=yes'] : undefined,
|
|
77
82
|
});
|
|
@@ -79,7 +84,7 @@ export function reconcileRunningTasks(tasks) {
|
|
|
79
84
|
}
|
|
80
85
|
if (!reachable.get(t.target))
|
|
81
86
|
continue; // host down → leave running
|
|
82
|
-
const st = readRemoteExit(t.target, t.remoteExit, 6000, t.identityFile);
|
|
87
|
+
const st = readRemoteExit(t.target, t.remoteExit, 6000, t.identityFile, t.remoteShell);
|
|
83
88
|
if (st.state === 'done') {
|
|
84
89
|
const updated = updateTask(t.id, terminalPatch(st.code));
|
|
85
90
|
if (updated)
|
|
@@ -88,3 +93,6 @@ export function reconcileRunningTasks(tasks) {
|
|
|
88
93
|
}
|
|
89
94
|
return tasks.map((t) => patched.get(t.id) ?? t);
|
|
90
95
|
}
|
|
96
|
+
export function reachabilityProbeCommand(remoteShell) {
|
|
97
|
+
return remoteShell === 'powershell' ? 'powershell -NoProfile -Command "exit 0"' : 'true';
|
|
98
|
+
}
|