@xynogen/pix-ssh 0.6.0 → 0.6.1
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/package.json +1 -1
- package/src/index.ts +83 -3
- package/src/lib.ts +23 -0
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";
|
|
@@ -63,6 +65,7 @@ import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse"
|
|
|
63
65
|
import { Type } from "typebox";
|
|
64
66
|
import {
|
|
65
67
|
APPROVAL_TTL_MS,
|
|
68
|
+
closeMaster,
|
|
66
69
|
commandEscalatesPrivilege,
|
|
67
70
|
controlPathFor,
|
|
68
71
|
detectSshFailure,
|
|
@@ -91,6 +94,9 @@ import {
|
|
|
91
94
|
const PROMPT_TIMEOUT_MS = 60_000;
|
|
92
95
|
const MAX_PASSWORD_ATTEMPTS = 3;
|
|
93
96
|
const SPINNER_INTERVAL_MS = 120;
|
|
97
|
+
/** Min gap between key re-checks on a host whose login password is cached.
|
|
98
|
+
* Each failed probe is one auth failure in the server log (fail2ban counts it). */
|
|
99
|
+
const KEY_RECHECK_MS = 60_000;
|
|
94
100
|
|
|
95
101
|
// In-memory per-host credential cache (session-scoped, never persisted).
|
|
96
102
|
// Key = canonical "user@host:port". Cleared on process exit.
|
|
@@ -99,6 +105,8 @@ interface HostCreds {
|
|
|
99
105
|
sudoPassword?: string;
|
|
100
106
|
}
|
|
101
107
|
const credCache = new Map<string, HostCreds>();
|
|
108
|
+
// Per-host epoch ms before which the cached-password key re-check is skipped.
|
|
109
|
+
const keyRecheckAt = new Map<string, number>();
|
|
102
110
|
|
|
103
111
|
// Per-host allow-memory (on by default): once the user approves any command on
|
|
104
112
|
// a host, later calls to that host skip the confirm overlay until the approval
|
|
@@ -112,6 +120,53 @@ function cacheKey(spec: HostSpec): string {
|
|
|
112
120
|
return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
113
121
|
}
|
|
114
122
|
|
|
123
|
+
/** Pre-run connection prep: honor `fresh`, re-check the key, and report
|
|
124
|
+
* whether the run will reuse a live master. Checked before the auth probes,
|
|
125
|
+
* which open a master themselves. */
|
|
126
|
+
async function prepareConnection(
|
|
127
|
+
spec: HostSpec,
|
|
128
|
+
controlPath: string,
|
|
129
|
+
key: string,
|
|
130
|
+
creds: HostCreds,
|
|
131
|
+
fresh: boolean | undefined,
|
|
132
|
+
sig?: AbortSignal,
|
|
133
|
+
): Promise<{ creds: HostCreds; connection: "new" | "reused" }> {
|
|
134
|
+
if (fresh === true) await closeMaster(spec, controlPath, sig);
|
|
135
|
+
const next = await recheckKey(spec, controlPath, key, creds, fresh === true, sig);
|
|
136
|
+
return { creds: next, connection: existsSync(controlPath) ? "reused" : "new" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function authVia(loginPassword: string | undefined, connection: "new" | "reused") {
|
|
140
|
+
return { auth: loginPassword ? "password" : "key", connection } as const;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A cached login password means the live master is likely password-authed, so a
|
|
145
|
+
* normal probe would ride it and never test the key. Re-check the key with
|
|
146
|
+
* multiplexing off (rate-limited by KEY_RECHECK_MS unless `force`); on success
|
|
147
|
+
* drop the password and its master so this call and later ones use the key.
|
|
148
|
+
*/
|
|
149
|
+
async function recheckKey(
|
|
150
|
+
spec: HostSpec,
|
|
151
|
+
controlPath: string,
|
|
152
|
+
key: string,
|
|
153
|
+
creds: HostCreds,
|
|
154
|
+
force: boolean,
|
|
155
|
+
sig?: AbortSignal,
|
|
156
|
+
): Promise<HostCreds> {
|
|
157
|
+
if (!creds.loginPassword) return creds;
|
|
158
|
+
if (!force && (keyRecheckAt.get(key) ?? 0) > Date.now()) return creds;
|
|
159
|
+
if ((await probeKeyAuth(spec, "none", sig)) !== "ok") {
|
|
160
|
+
keyRecheckAt.set(key, Date.now() + KEY_RECHECK_MS);
|
|
161
|
+
return creds;
|
|
162
|
+
}
|
|
163
|
+
const next = creds.sudoPassword ? { sudoPassword: creds.sudoPassword } : {};
|
|
164
|
+
credCache.set(key, next);
|
|
165
|
+
keyRecheckAt.delete(key);
|
|
166
|
+
await closeMaster(spec, controlPath, sig);
|
|
167
|
+
return next;
|
|
168
|
+
}
|
|
169
|
+
|
|
115
170
|
/**
|
|
116
171
|
* Overlay password validator for a prompt stage. The login stage tests the
|
|
117
172
|
* password against the host (wrong one re-prompts up to MAX_PASSWORD_ATTEMPTS);
|
|
@@ -153,6 +208,10 @@ export interface SshResultDetails {
|
|
|
153
208
|
truncated?: boolean;
|
|
154
209
|
cancellationKind?: SshCancellationKind;
|
|
155
210
|
errorKind?: SshErrorKind;
|
|
211
|
+
/** How this call authenticated: SSH key/agent, or a login password. */
|
|
212
|
+
auth?: "key" | "password";
|
|
213
|
+
/** Whether this call opened a new connection or reused a live master. */
|
|
214
|
+
connection?: "new" | "reused";
|
|
156
215
|
_render?: string;
|
|
157
216
|
}
|
|
158
217
|
|
|
@@ -168,6 +227,7 @@ type SshParams = {
|
|
|
168
227
|
destination?: string;
|
|
169
228
|
recursive?: boolean;
|
|
170
229
|
reason?: string;
|
|
230
|
+
fresh?: boolean;
|
|
171
231
|
};
|
|
172
232
|
|
|
173
233
|
interface SshOperation {
|
|
@@ -294,6 +354,8 @@ function terminalMeta(details: SshResultDetails): string {
|
|
|
294
354
|
const hasLines = typeof details.lineCount === "number" && details.lineCount > 0;
|
|
295
355
|
return dotJoin([
|
|
296
356
|
typeof details.exitCode === "number" && `exit ${details.exitCode}`,
|
|
357
|
+
details.auth && `${details.auth} auth`,
|
|
358
|
+
details.connection && `${details.connection} connection`,
|
|
297
359
|
hasLines && `${details.lineCount} ${details.lineCount === 1 ? "line" : "lines"}`,
|
|
298
360
|
details.truncated && "truncated",
|
|
299
361
|
]);
|
|
@@ -485,6 +547,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
485
547
|
description: "[file] Optional, default false. Copy a directory recursively.",
|
|
486
548
|
}),
|
|
487
549
|
),
|
|
550
|
+
fresh: Type.Optional(
|
|
551
|
+
Type.Boolean({
|
|
552
|
+
description:
|
|
553
|
+
"[command|file] Optional, default false. Close the reused connection first so this call re-authenticates (e.g. to prove key login).",
|
|
554
|
+
}),
|
|
555
|
+
),
|
|
488
556
|
reason: Type.String({
|
|
489
557
|
description: "Short plain-English intent, shown to the user as the approval title.",
|
|
490
558
|
}),
|
|
@@ -568,7 +636,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
568
636
|
// ProxyJump, and other SSH config options still apply.
|
|
569
637
|
const controlPath = controlPathFor(effectiveSpec);
|
|
570
638
|
const key = cacheKey(effectiveSpec);
|
|
571
|
-
|
|
639
|
+
let creds = credCache.get(key) ?? {};
|
|
572
640
|
|
|
573
641
|
const mode = getUnattendedMode(pi.events);
|
|
574
642
|
const yolo = mode === "yolo";
|
|
@@ -597,6 +665,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
597
665
|
|
|
598
666
|
updatePresentation(onUpdate, command, host, sudo, reason, "awaiting-approval");
|
|
599
667
|
|
|
668
|
+
const prep = await prepareConnection(spec, controlPath, key, creds, params.fresh, sig);
|
|
669
|
+
creds = prep.creds;
|
|
670
|
+
const connection = prep.connection;
|
|
671
|
+
|
|
600
672
|
// Probe key/agent/existing-master auth (no password needed on success).
|
|
601
673
|
// Only prompt for a login password when the host is reachable but rejects
|
|
602
674
|
// key auth ("auth"). If it's unreachable (timeout/DNS/refused) a password
|
|
@@ -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 }),
|
package/src/lib.ts
CHANGED
|
@@ -431,6 +431,8 @@ export type ProbeResult = "ok" | "auth" | "unreachable";
|
|
|
431
431
|
* clean connect (ok), auth rejected but host reachable (auth), and
|
|
432
432
|
* connection/DNS failure (unreachable) — the last must NOT trigger a password
|
|
433
433
|
* prompt, since a login password can't fix an unreachable host.
|
|
434
|
+
* Pass `controlPath = "none"` to bypass a live master: a password-authed master
|
|
435
|
+
* answers `ok` for any probe, which hides whether the key itself works.
|
|
434
436
|
*/
|
|
435
437
|
export function probeKeyAuth(
|
|
436
438
|
spec: HostSpec,
|
|
@@ -454,6 +456,27 @@ export function probeKeyAuth(
|
|
|
454
456
|
});
|
|
455
457
|
}
|
|
456
458
|
|
|
459
|
+
/** argv that asks the live master on `controlPath` to stop accepting new
|
|
460
|
+
* sessions. `stop` (not `exit`) lets in-flight sessions finish. */
|
|
461
|
+
export function closeMasterArgs(spec: HostSpec, controlPath: string): string[] {
|
|
462
|
+
return ["-o", `ControlPath=${controlPath}`, "-O", "stop", hostTarget(spec)];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Stop the multiplexed master so the next call opens (and authenticates) a
|
|
466
|
+
* fresh connection. No master = no-op; errors are ignored for the same reason. */
|
|
467
|
+
export function closeMaster(
|
|
468
|
+
spec: HostSpec,
|
|
469
|
+
controlPath: string,
|
|
470
|
+
signal?: AbortSignal,
|
|
471
|
+
): Promise<void> {
|
|
472
|
+
return new Promise((resolve) => {
|
|
473
|
+
const proc = spawnTool("ssh", closeMasterArgs(spec, controlPath), { stdio: "ignore" });
|
|
474
|
+
proc.on("error", () => resolve());
|
|
475
|
+
proc.on("close", () => resolve());
|
|
476
|
+
signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
457
480
|
/**
|
|
458
481
|
* Validate an SSH *login* password by attempting one real password auth. Forces
|
|
459
482
|
* password auth (no pubkey/agent) with a single prompt, fed via `sshpass -e`
|