@xynogen/pix-ssh 0.6.0 → 0.6.2
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/README.md +2 -2
- package/package.json +1 -1
- package/src/index.ts +134 -27
- package/src/lib.ts +23 -17
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ Pi tool — `ssh_run`: run a command through a remote host's configured SSH shel
|
|
|
9
9
|
|
|
10
10
|
Registers the `ssh_run` tool — run a command through the configured SSH shell on a remote machine, behind the same permission dialog pix-sudo uses (shared overlay from `@xynogen/pix-pretty`).
|
|
11
11
|
|
|
12
|
-
- **
|
|
13
|
-
- **Non-privileged calls** →
|
|
12
|
+
- **Every call** → Allow / Allow for this session / Deny approval, 60-second auto-deny timeout. Plain Allow covers that one call only.
|
|
13
|
+
- **Non-privileged calls** → auto-approve only after "Allow for this session" on that host (until `/new`), or in YOLO mode when no password is missing; each auto-approval emits a notification. Privileged calls always ask.
|
|
14
14
|
|
|
15
15
|
Output is truncated to 50 KB / 2000 lines. Non-interactive (RPC/JSON) mode blocks the tool immediately.
|
|
16
16
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Connection reuse: OpenSSH ControlMaster multiplexing keeps one authenticated
|
|
18
18
|
* connection per host alive (ControlPersist window), so repeat calls skip
|
|
19
|
-
* re-auth.
|
|
19
|
+
* re-auth. `fresh: true` stops that master first, and every result reports the
|
|
20
|
+
* auth method (key/password) and whether the connection was new or reused.
|
|
20
21
|
*
|
|
21
22
|
* Security notes:
|
|
22
23
|
* - Passwords never leave JS memory; never written to disk; never in argv.
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
* - Output truncated to 50 KB / 2000 lines.
|
|
26
27
|
*/
|
|
27
28
|
|
|
29
|
+
import { existsSync } from "node:fs";
|
|
28
30
|
import type { AgentToolUpdateCallback, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
31
|
import { Text } from "@earendil-works/pi-tui";
|
|
30
32
|
import { resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
|
|
@@ -51,6 +53,7 @@ import {
|
|
|
51
53
|
hideCollapsedToolCall,
|
|
52
54
|
moreLines,
|
|
53
55
|
normalizeLineEndings,
|
|
56
|
+
renderCollapsedToolRow,
|
|
54
57
|
renderToolError,
|
|
55
58
|
ruleFrame,
|
|
56
59
|
termW,
|
|
@@ -62,7 +65,7 @@ import { requireTool } from "@xynogen/pix-runtime/binaries";
|
|
|
62
65
|
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
63
66
|
import { Type } from "typebox";
|
|
64
67
|
import {
|
|
65
|
-
|
|
68
|
+
closeMaster,
|
|
66
69
|
commandEscalatesPrivilege,
|
|
67
70
|
controlPathFor,
|
|
68
71
|
detectSshFailure,
|
|
@@ -70,7 +73,6 @@ import {
|
|
|
70
73
|
type HostAlias,
|
|
71
74
|
type HostInfo,
|
|
72
75
|
type HostSpec,
|
|
73
|
-
hostApproved,
|
|
74
76
|
hostTarget,
|
|
75
77
|
MAX_OUTPUT_BYTES,
|
|
76
78
|
MAX_OUTPUT_LINES,
|
|
@@ -91,6 +93,9 @@ import {
|
|
|
91
93
|
const PROMPT_TIMEOUT_MS = 60_000;
|
|
92
94
|
const MAX_PASSWORD_ATTEMPTS = 3;
|
|
93
95
|
const SPINNER_INTERVAL_MS = 120;
|
|
96
|
+
/** Min gap between key re-checks on a host whose login password is cached.
|
|
97
|
+
* Each failed probe is one auth failure in the server log (fail2ban counts it). */
|
|
98
|
+
const KEY_RECHECK_MS = 60_000;
|
|
94
99
|
|
|
95
100
|
// In-memory per-host credential cache (session-scoped, never persisted).
|
|
96
101
|
// Key = canonical "user@host:port". Cleared on process exit.
|
|
@@ -99,19 +104,60 @@ interface HostCreds {
|
|
|
99
104
|
sudoPassword?: string;
|
|
100
105
|
}
|
|
101
106
|
const credCache = new Map<string, HostCreds>();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
// a host, later calls to that host skip the confirm overlay until the approval
|
|
105
|
-
// expires — mirroring sudo's PAM ticket window (~15 min), not the whole session.
|
|
106
|
-
// Password prompts are NOT skipped — a still-missing login or sudo password
|
|
107
|
-
// always re-prompts. Each auto-approve emits a visible notify so the decision is
|
|
108
|
-
// never silent. Map value = epoch ms when the approval lapses (see APPROVAL_TTL_MS).
|
|
109
|
-
const approvedHosts = new Map<string, number>();
|
|
107
|
+
// Per-host epoch ms before which the cached-password key re-check is skipped.
|
|
108
|
+
const keyRecheckAt = new Map<string, number>();
|
|
110
109
|
|
|
111
110
|
function cacheKey(spec: HostSpec): string {
|
|
112
111
|
return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
113
112
|
}
|
|
114
113
|
|
|
114
|
+
/** Pre-run connection prep: honor `fresh`, re-check the key, and report
|
|
115
|
+
* whether the run will reuse a live master. Checked before the auth probes,
|
|
116
|
+
* which open a master themselves. */
|
|
117
|
+
async function prepareConnection(
|
|
118
|
+
spec: HostSpec,
|
|
119
|
+
controlPath: string,
|
|
120
|
+
key: string,
|
|
121
|
+
creds: HostCreds,
|
|
122
|
+
fresh: boolean | undefined,
|
|
123
|
+
sig?: AbortSignal,
|
|
124
|
+
): Promise<{ creds: HostCreds; connection: "new" | "reused" }> {
|
|
125
|
+
if (fresh === true) await closeMaster(spec, controlPath, sig);
|
|
126
|
+
const next = await recheckKey(spec, controlPath, key, creds, fresh === true, sig);
|
|
127
|
+
return { creds: next, connection: existsSync(controlPath) ? "reused" : "new" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function authVia(loginPassword: string | undefined, connection: "new" | "reused") {
|
|
131
|
+
return { auth: loginPassword ? "password" : "key", connection } as const;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A cached login password means the live master is likely password-authed, so a
|
|
136
|
+
* normal probe would ride it and never test the key. Re-check the key with
|
|
137
|
+
* multiplexing off (rate-limited by KEY_RECHECK_MS unless `force`); on success
|
|
138
|
+
* drop the password and its master so this call and later ones use the key.
|
|
139
|
+
*/
|
|
140
|
+
async function recheckKey(
|
|
141
|
+
spec: HostSpec,
|
|
142
|
+
controlPath: string,
|
|
143
|
+
key: string,
|
|
144
|
+
creds: HostCreds,
|
|
145
|
+
force: boolean,
|
|
146
|
+
sig?: AbortSignal,
|
|
147
|
+
): Promise<HostCreds> {
|
|
148
|
+
if (!creds.loginPassword) return creds;
|
|
149
|
+
if (!force && (keyRecheckAt.get(key) ?? 0) > Date.now()) return creds;
|
|
150
|
+
if ((await probeKeyAuth(spec, "none", sig)) !== "ok") {
|
|
151
|
+
keyRecheckAt.set(key, Date.now() + KEY_RECHECK_MS);
|
|
152
|
+
return creds;
|
|
153
|
+
}
|
|
154
|
+
const next = creds.sudoPassword ? { sudoPassword: creds.sudoPassword } : {};
|
|
155
|
+
credCache.set(key, next);
|
|
156
|
+
keyRecheckAt.delete(key);
|
|
157
|
+
await closeMaster(spec, controlPath, sig);
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
|
|
115
161
|
/**
|
|
116
162
|
* Overlay password validator for a prompt stage. The login stage tests the
|
|
117
163
|
* password against the host (wrong one re-prompts up to MAX_PASSWORD_ATTEMPTS);
|
|
@@ -153,6 +199,10 @@ export interface SshResultDetails {
|
|
|
153
199
|
truncated?: boolean;
|
|
154
200
|
cancellationKind?: SshCancellationKind;
|
|
155
201
|
errorKind?: SshErrorKind;
|
|
202
|
+
/** How this call authenticated: SSH key/agent, or a login password. */
|
|
203
|
+
auth?: "key" | "password";
|
|
204
|
+
/** Whether this call opened a new connection or reused a live master. */
|
|
205
|
+
connection?: "new" | "reused";
|
|
156
206
|
_render?: string;
|
|
157
207
|
}
|
|
158
208
|
|
|
@@ -168,6 +218,7 @@ type SshParams = {
|
|
|
168
218
|
destination?: string;
|
|
169
219
|
recursive?: boolean;
|
|
170
220
|
reason?: string;
|
|
221
|
+
fresh?: boolean;
|
|
171
222
|
};
|
|
172
223
|
|
|
173
224
|
interface SshOperation {
|
|
@@ -294,6 +345,8 @@ function terminalMeta(details: SshResultDetails): string {
|
|
|
294
345
|
const hasLines = typeof details.lineCount === "number" && details.lineCount > 0;
|
|
295
346
|
return dotJoin([
|
|
296
347
|
typeof details.exitCode === "number" && `exit ${details.exitCode}`,
|
|
348
|
+
details.auth && `${details.auth} auth`,
|
|
349
|
+
details.connection && `${details.connection} connection`,
|
|
297
350
|
hasLines && `${details.lineCount} ${details.lineCount === 1 ? "line" : "lines"}`,
|
|
298
351
|
details.truncated && "truncated",
|
|
299
352
|
]);
|
|
@@ -339,7 +392,7 @@ function formatHostInfo(host: string, info: HostInfo): string {
|
|
|
339
392
|
["IdentityFile", info.identityFile],
|
|
340
393
|
].filter((r): r is [string, string] => Boolean(r[1]));
|
|
341
394
|
if (rows.length === 0) return `No SSH config found for ${host}.`;
|
|
342
|
-
return [`Effective SSH config for ${host}:`, ...rows.map(([k, v]) =>
|
|
395
|
+
return [`Effective SSH config for ${host}:`, ...rows.map(([k, v]) => `${k} ${v}`)].join("\n");
|
|
343
396
|
}
|
|
344
397
|
|
|
345
398
|
function formatAliasList(aliases: HostAlias[]): string {
|
|
@@ -359,15 +412,14 @@ function formatAliasList(aliases: HostAlias[]): string {
|
|
|
359
412
|
target = `${userPart}${a.hostname}${portPart}`;
|
|
360
413
|
}
|
|
361
414
|
const via = a.proxyJump && a.proxyJump !== "none" ? ` via ${a.proxyJump}` : "";
|
|
362
|
-
lines.push(
|
|
415
|
+
lines.push(`${a.alias}${target ? ` → ${target}` : ""}${via}`);
|
|
363
416
|
}
|
|
364
417
|
return [`SSH host aliases (${lines.length}):`, ...lines].join("\n");
|
|
365
418
|
}
|
|
366
419
|
|
|
367
420
|
/** Build the info-action result: per-host effective config when `host` is
|
|
368
421
|
* given, otherwise the alias inventory from ~/.ssh/config. Read-only.
|
|
369
|
-
* Details carry `_type: "sshInfo"`
|
|
370
|
-
* plain-text branch (no collapse, no exit-code framing). */
|
|
422
|
+
* Details carry `_type: "sshInfo"` + `host` for the collapsed row. */
|
|
371
423
|
/**
|
|
372
424
|
* The pix-runtime lookup message for the first missing binary this action
|
|
373
425
|
* needs (ssh, plus scp for file transfers), or undefined when all resolve.
|
|
@@ -385,7 +437,7 @@ export function missingSshTools(action: "command" | "file"): string | undefined
|
|
|
385
437
|
}
|
|
386
438
|
|
|
387
439
|
async function infoResult(host: string | undefined, sig?: AbortSignal) {
|
|
388
|
-
const details = { _type: "sshInfo" as const };
|
|
440
|
+
const details = { _type: "sshInfo" as const, host: host?.trim() || "aliases" };
|
|
389
441
|
if (host?.trim()) {
|
|
390
442
|
let spec: HostSpec;
|
|
391
443
|
try {
|
|
@@ -418,7 +470,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
418
470
|
// "Allow for this session" hosts. Lives in the factory: Pi hands each new session a fresh `pi`,
|
|
419
471
|
// so this resets on /new. Privileged commands never use it (see `privileged` below).
|
|
420
472
|
const sessionHosts = new Set<string>();
|
|
421
|
-
const approved = (key: string) => sessionHosts.has(key)
|
|
473
|
+
const approved = (key: string) => sessionHosts.has(key);
|
|
422
474
|
pi.registerTool({
|
|
423
475
|
name: "ssh_run",
|
|
424
476
|
...({ exposure: "deferred" } as const),
|
|
@@ -485,6 +537,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
485
537
|
description: "[file] Optional, default false. Copy a directory recursively.",
|
|
486
538
|
}),
|
|
487
539
|
),
|
|
540
|
+
fresh: Type.Optional(
|
|
541
|
+
Type.Boolean({
|
|
542
|
+
description:
|
|
543
|
+
"[command|file] Optional, default false. Close the reused connection first so this call re-authenticates (e.g. to prove key login).",
|
|
544
|
+
}),
|
|
545
|
+
),
|
|
488
546
|
reason: Type.String({
|
|
489
547
|
description: "Short plain-English intent, shown to the user as the approval title.",
|
|
490
548
|
}),
|
|
@@ -568,7 +626,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
568
626
|
// ProxyJump, and other SSH config options still apply.
|
|
569
627
|
const controlPath = controlPathFor(effectiveSpec);
|
|
570
628
|
const key = cacheKey(effectiveSpec);
|
|
571
|
-
|
|
629
|
+
let creds = credCache.get(key) ?? {};
|
|
572
630
|
|
|
573
631
|
const mode = getUnattendedMode(pi.events);
|
|
574
632
|
const yolo = mode === "yolo";
|
|
@@ -597,6 +655,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
597
655
|
|
|
598
656
|
updatePresentation(onUpdate, command, host, sudo, reason, "awaiting-approval");
|
|
599
657
|
|
|
658
|
+
const prep = await prepareConnection(spec, controlPath, key, creds, params.fresh, sig);
|
|
659
|
+
creds = prep.creds;
|
|
660
|
+
const connection = prep.connection;
|
|
661
|
+
|
|
600
662
|
// Probe key/agent/existing-master auth (no password needed on success).
|
|
601
663
|
// Only prompt for a login password when the host is reachable but rejects
|
|
602
664
|
// key auth ("auth"). If it's unreachable (timeout/DNS/refused) a password
|
|
@@ -735,6 +797,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
735
797
|
});
|
|
736
798
|
|
|
737
799
|
const overlayResult = await runOverlay();
|
|
800
|
+
if (overlayResult.passwordAttemptsExhausted) {
|
|
801
|
+
const text = `SSH authentication failed after ${MAX_PASSWORD_ATTEMPTS} attempts — wrong password.`;
|
|
802
|
+
ctx.ui.notify(`🔐 ${text}`, "error");
|
|
803
|
+
return {
|
|
804
|
+
content: [{ type: "text", text }],
|
|
805
|
+
details: makeDetails(command, host, sudo, reason, {
|
|
806
|
+
outcome: "error",
|
|
807
|
+
errorKind: "auth-ssh",
|
|
808
|
+
}),
|
|
809
|
+
isError: true,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
738
812
|
const missing =
|
|
739
813
|
overlayResult.action === "approved" &&
|
|
740
814
|
((needLogin && !collected.login) || (needSudo && !collected.sudo));
|
|
@@ -744,10 +818,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
744
818
|
return r;
|
|
745
819
|
}
|
|
746
820
|
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
if (action === "command") approvedHosts.set(key, Date.now() + APPROVAL_TTL_MS);
|
|
750
|
-
// "session" is only offered for non-privileged commands (sessionChoices).
|
|
821
|
+
// Plain "Allow" approves this call only. "session" (offered only for
|
|
822
|
+
// non-privileged commands) is the sole way to skip later prompts.
|
|
751
823
|
if (overlayResult.choice === "session") {
|
|
752
824
|
sessionHosts.add(key);
|
|
753
825
|
ctx.ui.notify(`ssh_run: ${host} allowed for this session (sudo still asks)`, "info");
|
|
@@ -755,6 +827,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
755
827
|
|
|
756
828
|
// Persist newly-entered passwords in the session cache.
|
|
757
829
|
const loginPassword = creds.loginPassword ?? collected.login;
|
|
830
|
+
const via = authVia(loginPassword, connection);
|
|
758
831
|
const sudoPassword = creds.sudoPassword ?? collected.sudo;
|
|
759
832
|
credCache.set(key, {
|
|
760
833
|
...(loginPassword ? { loginPassword } : {}),
|
|
@@ -859,6 +932,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
859
932
|
details: makeDetails(command, host, sudo, reason, {
|
|
860
933
|
outcome: "error",
|
|
861
934
|
exitCode: result.code,
|
|
935
|
+
...via,
|
|
862
936
|
lineCount: outputLineCount(result.stderr),
|
|
863
937
|
errorKind: "auth-sudo",
|
|
864
938
|
_render: normalizeLineEndings(result.stderr),
|
|
@@ -877,10 +951,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
877
951
|
.replace(/^\n+|\n+$/g, "");
|
|
878
952
|
|
|
879
953
|
return {
|
|
880
|
-
content: [
|
|
954
|
+
content: [
|
|
955
|
+
{
|
|
956
|
+
type: "text",
|
|
957
|
+
text: `Exit code: ${result.code} (auth: ${via.auth}, ${via.connection} connection)\n\n${truncatedText}${suffix}`,
|
|
958
|
+
},
|
|
959
|
+
],
|
|
881
960
|
details: makeDetails(command, host, sudo, reason, {
|
|
882
961
|
outcome: result.code === 0 ? "success" : "error",
|
|
883
962
|
exitCode: result.code,
|
|
963
|
+
...via,
|
|
884
964
|
lineCount: outputLineCount(rendered),
|
|
885
965
|
truncated,
|
|
886
966
|
...(result.code === 0 ? {} : { errorKind: "exit-code" as const }),
|
|
@@ -936,13 +1016,40 @@ export default function (pi: ExtensionAPI): void {
|
|
|
936
1016
|
const completed = (isError: boolean) => frameToolResult(text, theme, isError);
|
|
937
1017
|
|
|
938
1018
|
if (details?._type !== "sshResult") {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1019
|
+
const info = (result.details as { _type?: string; host?: string } | undefined) ?? {};
|
|
1020
|
+
if (
|
|
1021
|
+
!isPartial &&
|
|
1022
|
+
info._type === "sshInfo" &&
|
|
1023
|
+
tickCollapse(
|
|
1024
|
+
"ssh",
|
|
1025
|
+
renderCtx.state as CollapseState,
|
|
1026
|
+
renderCtx.invalidate,
|
|
1027
|
+
renderCtx.expanded,
|
|
1028
|
+
)
|
|
1029
|
+
) {
|
|
1030
|
+
const status = renderCtx.isError ? "error" : "success";
|
|
942
1031
|
text.setText(
|
|
943
|
-
|
|
1032
|
+
renderCollapsedToolRow(
|
|
1033
|
+
theme,
|
|
1034
|
+
"ssh info",
|
|
1035
|
+
info.host ?? "",
|
|
1036
|
+
renderCtx.isError ? "failed" : "",
|
|
1037
|
+
status,
|
|
1038
|
+
),
|
|
944
1039
|
);
|
|
1040
|
+
return text;
|
|
945
1041
|
}
|
|
1042
|
+
const body = getTextContent(result) || "done";
|
|
1043
|
+
text.setText(
|
|
1044
|
+
renderCtx.isError
|
|
1045
|
+
? renderToolError(body, theme)
|
|
1046
|
+
: fillToolBackground(
|
|
1047
|
+
body
|
|
1048
|
+
.split("\n")
|
|
1049
|
+
.map((line) => `${BODY_PAD}${theme.fg("muted", line)}`)
|
|
1050
|
+
.join("\n"),
|
|
1051
|
+
),
|
|
1052
|
+
);
|
|
946
1053
|
return isPartial ? text : completed(renderCtx.isError);
|
|
947
1054
|
}
|
|
948
1055
|
|
package/src/lib.ts
CHANGED
|
@@ -29,23 +29,6 @@ import { expandHome, homeDir, tempDir } from "@xynogen/pix-runtime/paths";
|
|
|
29
29
|
export const MAX_OUTPUT_BYTES = DEFAULT_MAX_BYTES;
|
|
30
30
|
export const MAX_OUTPUT_LINES = DEFAULT_MAX_LINES;
|
|
31
31
|
|
|
32
|
-
/** Per-host approval TTL (ms) — mirrors sudo's PAM ticket window (~15 min). */
|
|
33
|
-
export const APPROVAL_TTL_MS = 15 * 60_000;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* True when `key` has a live (non-expired) approval in `map`; deletes the entry
|
|
37
|
-
* on expiry so the map self-prunes. `now` is injectable for tests.
|
|
38
|
-
*/
|
|
39
|
-
export function hostApproved(map: Map<string, number>, key: string, now = Date.now()): boolean {
|
|
40
|
-
const expiry = map.get(key);
|
|
41
|
-
if (expiry === undefined) return false;
|
|
42
|
-
if (now >= expiry) {
|
|
43
|
-
map.delete(key);
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
32
|
/**
|
|
50
33
|
* True when the command text itself escalates privilege (sudo/su/doas/pkexec),
|
|
51
34
|
* so per-host allow-memory must NOT auto-approve it even when `sudo:true` was
|
|
@@ -431,6 +414,8 @@ export type ProbeResult = "ok" | "auth" | "unreachable";
|
|
|
431
414
|
* clean connect (ok), auth rejected but host reachable (auth), and
|
|
432
415
|
* connection/DNS failure (unreachable) — the last must NOT trigger a password
|
|
433
416
|
* prompt, since a login password can't fix an unreachable host.
|
|
417
|
+
* Pass `controlPath = "none"` to bypass a live master: a password-authed master
|
|
418
|
+
* answers `ok` for any probe, which hides whether the key itself works.
|
|
434
419
|
*/
|
|
435
420
|
export function probeKeyAuth(
|
|
436
421
|
spec: HostSpec,
|
|
@@ -454,6 +439,27 @@ export function probeKeyAuth(
|
|
|
454
439
|
});
|
|
455
440
|
}
|
|
456
441
|
|
|
442
|
+
/** argv that asks the live master on `controlPath` to stop accepting new
|
|
443
|
+
* sessions. `stop` (not `exit`) lets in-flight sessions finish. */
|
|
444
|
+
export function closeMasterArgs(spec: HostSpec, controlPath: string): string[] {
|
|
445
|
+
return ["-o", `ControlPath=${controlPath}`, "-O", "stop", hostTarget(spec)];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Stop the multiplexed master so the next call opens (and authenticates) a
|
|
449
|
+
* fresh connection. No master = no-op; errors are ignored for the same reason. */
|
|
450
|
+
export function closeMaster(
|
|
451
|
+
spec: HostSpec,
|
|
452
|
+
controlPath: string,
|
|
453
|
+
signal?: AbortSignal,
|
|
454
|
+
): Promise<void> {
|
|
455
|
+
return new Promise((resolve) => {
|
|
456
|
+
const proc = spawnTool("ssh", closeMasterArgs(spec, controlPath), { stdio: "ignore" });
|
|
457
|
+
proc.on("error", () => resolve());
|
|
458
|
+
proc.on("close", () => resolve());
|
|
459
|
+
signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
457
463
|
/**
|
|
458
464
|
* Validate an SSH *login* password by attempting one real password auth. Forces
|
|
459
465
|
* password auth (no pubkey/agent) with a single prompt, fed via `sshpass -e`
|