@xynogen/pix-ssh 0.5.9 → 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 +121 -20
- 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,12 +26,17 @@
|
|
|
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";
|
|
31
33
|
import { collapsedCommandRow, commandPreview } from "@xynogen/pix-pretty/command-preview";
|
|
32
34
|
import { MAX_PREVIEW_LINES } from "@xynogen/pix-pretty/config";
|
|
33
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
type OverlayChoice,
|
|
37
|
+
type OverlayResult,
|
|
38
|
+
showOverlay,
|
|
39
|
+
} from "@xynogen/pix-pretty/gate-overlay";
|
|
34
40
|
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
35
41
|
import { renderBashOutput } from "@xynogen/pix-pretty/renderers";
|
|
36
42
|
import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-pretty/types";
|
|
@@ -59,6 +65,7 @@ import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse"
|
|
|
59
65
|
import { Type } from "typebox";
|
|
60
66
|
import {
|
|
61
67
|
APPROVAL_TTL_MS,
|
|
68
|
+
closeMaster,
|
|
62
69
|
commandEscalatesPrivilege,
|
|
63
70
|
controlPathFor,
|
|
64
71
|
detectSshFailure,
|
|
@@ -87,6 +94,9 @@ import {
|
|
|
87
94
|
const PROMPT_TIMEOUT_MS = 60_000;
|
|
88
95
|
const MAX_PASSWORD_ATTEMPTS = 3;
|
|
89
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;
|
|
90
100
|
|
|
91
101
|
// In-memory per-host credential cache (session-scoped, never persisted).
|
|
92
102
|
// Key = canonical "user@host:port". Cleared on process exit.
|
|
@@ -95,6 +105,8 @@ interface HostCreds {
|
|
|
95
105
|
sudoPassword?: string;
|
|
96
106
|
}
|
|
97
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>();
|
|
98
110
|
|
|
99
111
|
// Per-host allow-memory (on by default): once the user approves any command on
|
|
100
112
|
// a host, later calls to that host skip the confirm overlay until the approval
|
|
@@ -108,6 +120,53 @@ function cacheKey(spec: HostSpec): string {
|
|
|
108
120
|
return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
109
121
|
}
|
|
110
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
|
+
|
|
111
170
|
/**
|
|
112
171
|
* Overlay password validator for a prompt stage. The login stage tests the
|
|
113
172
|
* password against the host (wrong one re-prompts up to MAX_PASSWORD_ATTEMPTS);
|
|
@@ -149,6 +208,10 @@ export interface SshResultDetails {
|
|
|
149
208
|
truncated?: boolean;
|
|
150
209
|
cancellationKind?: SshCancellationKind;
|
|
151
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";
|
|
152
215
|
_render?: string;
|
|
153
216
|
}
|
|
154
217
|
|
|
@@ -164,6 +227,7 @@ type SshParams = {
|
|
|
164
227
|
destination?: string;
|
|
165
228
|
recursive?: boolean;
|
|
166
229
|
reason?: string;
|
|
230
|
+
fresh?: boolean;
|
|
167
231
|
};
|
|
168
232
|
|
|
169
233
|
interface SshOperation {
|
|
@@ -203,10 +267,17 @@ function normalizeOperation(params: SshParams): SshOperation {
|
|
|
203
267
|
};
|
|
204
268
|
}
|
|
205
269
|
|
|
270
|
+
/** The model's reason heads the modal; the action name is only the fallback. */
|
|
271
|
+
function approvalTitle({ action, direction, reason }: SshOperation): string {
|
|
272
|
+
if (reason?.trim()) return reason.trim();
|
|
273
|
+
if (action !== "file") return "SSH Command Request";
|
|
274
|
+
return `SSH ${direction === "download" ? "Download" : "Upload"}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
206
277
|
function approvalBody(operation: SshOperation, host: string, port?: number): string[] {
|
|
207
278
|
const { action, command, destination, direction, reason, recursive, source, sudo } = operation;
|
|
208
279
|
return [
|
|
209
|
-
reason?.trim() ?
|
|
280
|
+
...(reason?.trim() ? [] : ["No reason provided by AI"]),
|
|
210
281
|
`Host: ${host}${port ? ` (port ${port})` : ""}`,
|
|
211
282
|
...(action === "command"
|
|
212
283
|
? [`Command: ${sudo ? "sudo " : ""}${command}`]
|
|
@@ -283,6 +354,8 @@ function terminalMeta(details: SshResultDetails): string {
|
|
|
283
354
|
const hasLines = typeof details.lineCount === "number" && details.lineCount > 0;
|
|
284
355
|
return dotJoin([
|
|
285
356
|
typeof details.exitCode === "number" && `exit ${details.exitCode}`,
|
|
357
|
+
details.auth && `${details.auth} auth`,
|
|
358
|
+
details.connection && `${details.connection} connection`,
|
|
286
359
|
hasLines && `${details.lineCount} ${details.lineCount === 1 ? "line" : "lines"}`,
|
|
287
360
|
details.truncated && "truncated",
|
|
288
361
|
]);
|
|
@@ -398,7 +471,16 @@ async function infoResult(host: string | undefined, sig?: AbortSignal) {
|
|
|
398
471
|
|
|
399
472
|
// ── Extension entry point ─────────────────────────────────────────────────────
|
|
400
473
|
|
|
474
|
+
/** "Allow for this session" choice. Offered only for non-privileged commands. */
|
|
475
|
+
const SESSION_CHOICE: OverlayChoice[] = [
|
|
476
|
+
{ value: "session", label: "Allow for this session", description: "Host, until /new; sudo asks" },
|
|
477
|
+
];
|
|
478
|
+
|
|
401
479
|
export default function (pi: ExtensionAPI): void {
|
|
480
|
+
// "Allow for this session" hosts. Lives in the factory: Pi hands each new session a fresh `pi`,
|
|
481
|
+
// so this resets on /new. Privileged commands never use it (see `privileged` below).
|
|
482
|
+
const sessionHosts = new Set<string>();
|
|
483
|
+
const approved = (key: string) => sessionHosts.has(key) || hostApproved(approvedHosts, key);
|
|
402
484
|
pi.registerTool({
|
|
403
485
|
name: "ssh_run",
|
|
404
486
|
...({ exposure: "deferred" } as const),
|
|
@@ -465,11 +547,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
465
547
|
description: "[file] Optional, default false. Copy a directory recursively.",
|
|
466
548
|
}),
|
|
467
549
|
),
|
|
468
|
-
|
|
469
|
-
Type.
|
|
470
|
-
description:
|
|
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).",
|
|
471
554
|
}),
|
|
472
555
|
),
|
|
556
|
+
reason: Type.String({
|
|
557
|
+
description: "Short plain-English intent, shown to the user as the approval title.",
|
|
558
|
+
}),
|
|
473
559
|
}),
|
|
474
560
|
|
|
475
561
|
async execute(_toolCallId, params, sig, onUpdate, ctx) {
|
|
@@ -550,7 +636,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
550
636
|
// ProxyJump, and other SSH config options still apply.
|
|
551
637
|
const controlPath = controlPathFor(effectiveSpec);
|
|
552
638
|
const key = cacheKey(effectiveSpec);
|
|
553
|
-
|
|
639
|
+
let creds = credCache.get(key) ?? {};
|
|
554
640
|
|
|
555
641
|
const mode = getUnattendedMode(pi.events);
|
|
556
642
|
const yolo = mode === "yolo";
|
|
@@ -579,6 +665,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
579
665
|
|
|
580
666
|
updatePresentation(onUpdate, command, host, sudo, reason, "awaiting-approval");
|
|
581
667
|
|
|
668
|
+
const prep = await prepareConnection(spec, controlPath, key, creds, params.fresh, sig);
|
|
669
|
+
creds = prep.creds;
|
|
670
|
+
const connection = prep.connection;
|
|
671
|
+
|
|
582
672
|
// Probe key/agent/existing-master auth (no password needed on success).
|
|
583
673
|
// Only prompt for a login password when the host is reachable but rejects
|
|
584
674
|
// key auth ("auth"). If it's unreachable (timeout/DNS/refused) a password
|
|
@@ -619,6 +709,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
619
709
|
};
|
|
620
710
|
}
|
|
621
711
|
|
|
712
|
+
const title = approvalTitle(operation);
|
|
622
713
|
const body = [
|
|
623
714
|
...approvalBody(operation, host, spec.port),
|
|
624
715
|
...(keyOk && !creds.loginPassword ? ["Auth: SSH key (no password)"] : []),
|
|
@@ -636,10 +727,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
636
727
|
// auto-approved — they always re-confirm.
|
|
637
728
|
const privileged = action === "command" && (sudo || commandEscalatesPrivilege(command));
|
|
638
729
|
const alreadyApproved =
|
|
639
|
-
action === "command" &&
|
|
640
|
-
!privileged &&
|
|
641
|
-
hostApproved(approvedHosts, key) &&
|
|
642
|
-
promptFor.length === 0;
|
|
730
|
+
action === "command" && !privileged && approved(key) && promptFor.length === 0;
|
|
643
731
|
if (alreadyApproved) {
|
|
644
732
|
ctx.ui.notify(`ssh_run: reused session approval for ${host}`, "info");
|
|
645
733
|
} else if (transferDecision === "allow") {
|
|
@@ -649,6 +737,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
649
737
|
);
|
|
650
738
|
}
|
|
651
739
|
|
|
740
|
+
const sessionChoices = action === "command" && !privileged ? SESSION_CHOICE : [];
|
|
741
|
+
|
|
652
742
|
const runOverlay = (): Promise<OverlayResult> =>
|
|
653
743
|
withAgentBlock(pi.events, "ssh_run", "SSH approval required", async () => {
|
|
654
744
|
if ((transferDecision === "allow" || yolo || alreadyApproved) && promptFor.length === 0) {
|
|
@@ -659,13 +749,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
659
749
|
return showOverlay(ctx.ui, {
|
|
660
750
|
mode: "confirm",
|
|
661
751
|
icon: action === "file" ? icon("warn") : icon("lock"),
|
|
662
|
-
title
|
|
663
|
-
action === "file"
|
|
664
|
-
? `SSH ${direction === "download" ? "Download" : "Upload"}`
|
|
665
|
-
: "SSH Command Request",
|
|
752
|
+
title,
|
|
666
753
|
body,
|
|
667
754
|
accent: sudo ? "error" : action === "file" ? "warning" : "accent",
|
|
668
755
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
756
|
+
approveValue: ["yes", "session"],
|
|
669
757
|
choices: [
|
|
670
758
|
{
|
|
671
759
|
value: "yes",
|
|
@@ -673,6 +761,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
673
761
|
description:
|
|
674
762
|
action === "file" ? "Copy to destination (may overwrite)" : "Run the command",
|
|
675
763
|
},
|
|
764
|
+
...sessionChoices,
|
|
676
765
|
{
|
|
677
766
|
value: "no",
|
|
678
767
|
label: "Deny",
|
|
@@ -688,14 +777,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
688
777
|
last = await showOverlay(ctx.ui, {
|
|
689
778
|
mode: "sudo",
|
|
690
779
|
icon: action === "file" ? icon("warn") : icon("lock"),
|
|
691
|
-
title
|
|
692
|
-
action === "file"
|
|
693
|
-
? `SSH ${direction === "download" ? "Download" : "Upload"}`
|
|
694
|
-
: "SSH Command Request",
|
|
780
|
+
title,
|
|
695
781
|
body: [...body, `Enter: ${label}`],
|
|
696
782
|
accent: sudo ? "error" : action === "file" ? "warning" : "accent",
|
|
697
783
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
698
784
|
maxPasswordAttempts: MAX_PASSWORD_ATTEMPTS,
|
|
785
|
+
approveValue: ["yes", "session"],
|
|
699
786
|
passwordLabel: `${label}:`,
|
|
700
787
|
// Login stage: actually test the password against the host so a
|
|
701
788
|
// wrong one re-prompts (up to MAX_PASSWORD_ATTEMPTS), mirroring
|
|
@@ -708,6 +795,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
708
795
|
label: "Allow",
|
|
709
796
|
description: `Enter ${label.toLowerCase()}`,
|
|
710
797
|
},
|
|
798
|
+
...sessionChoices,
|
|
711
799
|
{ value: "no", label: "Deny", description: "Block the command" },
|
|
712
800
|
],
|
|
713
801
|
});
|
|
@@ -731,9 +819,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
731
819
|
// File transfers remain warning-level: normal mode asks every time,
|
|
732
820
|
// AFK denies above, and YOLO may approve when no password is missing.
|
|
733
821
|
if (action === "command") approvedHosts.set(key, Date.now() + APPROVAL_TTL_MS);
|
|
822
|
+
// "session" is only offered for non-privileged commands (sessionChoices).
|
|
823
|
+
if (overlayResult.choice === "session") {
|
|
824
|
+
sessionHosts.add(key);
|
|
825
|
+
ctx.ui.notify(`ssh_run: ${host} allowed for this session (sudo still asks)`, "info");
|
|
826
|
+
}
|
|
734
827
|
|
|
735
828
|
// Persist newly-entered passwords in the session cache.
|
|
736
829
|
const loginPassword = creds.loginPassword ?? collected.login;
|
|
830
|
+
const via = authVia(loginPassword, connection);
|
|
737
831
|
const sudoPassword = creds.sudoPassword ?? collected.sudo;
|
|
738
832
|
credCache.set(key, {
|
|
739
833
|
...(loginPassword ? { loginPassword } : {}),
|
|
@@ -838,6 +932,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
838
932
|
details: makeDetails(command, host, sudo, reason, {
|
|
839
933
|
outcome: "error",
|
|
840
934
|
exitCode: result.code,
|
|
935
|
+
...via,
|
|
841
936
|
lineCount: outputLineCount(result.stderr),
|
|
842
937
|
errorKind: "auth-sudo",
|
|
843
938
|
_render: normalizeLineEndings(result.stderr),
|
|
@@ -856,10 +951,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
856
951
|
.replace(/^\n+|\n+$/g, "");
|
|
857
952
|
|
|
858
953
|
return {
|
|
859
|
-
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
|
+
],
|
|
860
960
|
details: makeDetails(command, host, sudo, reason, {
|
|
861
961
|
outcome: result.code === 0 ? "success" : "error",
|
|
862
962
|
exitCode: result.code,
|
|
963
|
+
...via,
|
|
863
964
|
lineCount: outputLineCount(rendered),
|
|
864
965
|
truncated,
|
|
865
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`
|