@xynogen/pix-ssh 0.5.9 → 0.6.0
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 +40 -19
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -30,7 +30,11 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
30
30
|
import { resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
|
|
31
31
|
import { collapsedCommandRow, commandPreview } from "@xynogen/pix-pretty/command-preview";
|
|
32
32
|
import { MAX_PREVIEW_LINES } from "@xynogen/pix-pretty/config";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
type OverlayChoice,
|
|
35
|
+
type OverlayResult,
|
|
36
|
+
showOverlay,
|
|
37
|
+
} from "@xynogen/pix-pretty/gate-overlay";
|
|
34
38
|
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
35
39
|
import { renderBashOutput } from "@xynogen/pix-pretty/renderers";
|
|
36
40
|
import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-pretty/types";
|
|
@@ -203,10 +207,17 @@ function normalizeOperation(params: SshParams): SshOperation {
|
|
|
203
207
|
};
|
|
204
208
|
}
|
|
205
209
|
|
|
210
|
+
/** The model's reason heads the modal; the action name is only the fallback. */
|
|
211
|
+
function approvalTitle({ action, direction, reason }: SshOperation): string {
|
|
212
|
+
if (reason?.trim()) return reason.trim();
|
|
213
|
+
if (action !== "file") return "SSH Command Request";
|
|
214
|
+
return `SSH ${direction === "download" ? "Download" : "Upload"}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
206
217
|
function approvalBody(operation: SshOperation, host: string, port?: number): string[] {
|
|
207
218
|
const { action, command, destination, direction, reason, recursive, source, sudo } = operation;
|
|
208
219
|
return [
|
|
209
|
-
reason?.trim() ?
|
|
220
|
+
...(reason?.trim() ? [] : ["No reason provided by AI"]),
|
|
210
221
|
`Host: ${host}${port ? ` (port ${port})` : ""}`,
|
|
211
222
|
...(action === "command"
|
|
212
223
|
? [`Command: ${sudo ? "sudo " : ""}${command}`]
|
|
@@ -398,7 +409,16 @@ async function infoResult(host: string | undefined, sig?: AbortSignal) {
|
|
|
398
409
|
|
|
399
410
|
// ── Extension entry point ─────────────────────────────────────────────────────
|
|
400
411
|
|
|
412
|
+
/** "Allow for this session" choice. Offered only for non-privileged commands. */
|
|
413
|
+
const SESSION_CHOICE: OverlayChoice[] = [
|
|
414
|
+
{ value: "session", label: "Allow for this session", description: "Host, until /new; sudo asks" },
|
|
415
|
+
];
|
|
416
|
+
|
|
401
417
|
export default function (pi: ExtensionAPI): void {
|
|
418
|
+
// "Allow for this session" hosts. Lives in the factory: Pi hands each new session a fresh `pi`,
|
|
419
|
+
// so this resets on /new. Privileged commands never use it (see `privileged` below).
|
|
420
|
+
const sessionHosts = new Set<string>();
|
|
421
|
+
const approved = (key: string) => sessionHosts.has(key) || hostApproved(approvedHosts, key);
|
|
402
422
|
pi.registerTool({
|
|
403
423
|
name: "ssh_run",
|
|
404
424
|
...({ exposure: "deferred" } as const),
|
|
@@ -465,11 +485,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
465
485
|
description: "[file] Optional, default false. Copy a directory recursively.",
|
|
466
486
|
}),
|
|
467
487
|
),
|
|
468
|
-
reason: Type.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}),
|
|
472
|
-
),
|
|
488
|
+
reason: Type.String({
|
|
489
|
+
description: "Short plain-English intent, shown to the user as the approval title.",
|
|
490
|
+
}),
|
|
473
491
|
}),
|
|
474
492
|
|
|
475
493
|
async execute(_toolCallId, params, sig, onUpdate, ctx) {
|
|
@@ -619,6 +637,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
619
637
|
};
|
|
620
638
|
}
|
|
621
639
|
|
|
640
|
+
const title = approvalTitle(operation);
|
|
622
641
|
const body = [
|
|
623
642
|
...approvalBody(operation, host, spec.port),
|
|
624
643
|
...(keyOk && !creds.loginPassword ? ["Auth: SSH key (no password)"] : []),
|
|
@@ -636,10 +655,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
636
655
|
// auto-approved — they always re-confirm.
|
|
637
656
|
const privileged = action === "command" && (sudo || commandEscalatesPrivilege(command));
|
|
638
657
|
const alreadyApproved =
|
|
639
|
-
action === "command" &&
|
|
640
|
-
!privileged &&
|
|
641
|
-
hostApproved(approvedHosts, key) &&
|
|
642
|
-
promptFor.length === 0;
|
|
658
|
+
action === "command" && !privileged && approved(key) && promptFor.length === 0;
|
|
643
659
|
if (alreadyApproved) {
|
|
644
660
|
ctx.ui.notify(`ssh_run: reused session approval for ${host}`, "info");
|
|
645
661
|
} else if (transferDecision === "allow") {
|
|
@@ -649,6 +665,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
649
665
|
);
|
|
650
666
|
}
|
|
651
667
|
|
|
668
|
+
const sessionChoices = action === "command" && !privileged ? SESSION_CHOICE : [];
|
|
669
|
+
|
|
652
670
|
const runOverlay = (): Promise<OverlayResult> =>
|
|
653
671
|
withAgentBlock(pi.events, "ssh_run", "SSH approval required", async () => {
|
|
654
672
|
if ((transferDecision === "allow" || yolo || alreadyApproved) && promptFor.length === 0) {
|
|
@@ -659,13 +677,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
659
677
|
return showOverlay(ctx.ui, {
|
|
660
678
|
mode: "confirm",
|
|
661
679
|
icon: action === "file" ? icon("warn") : icon("lock"),
|
|
662
|
-
title
|
|
663
|
-
action === "file"
|
|
664
|
-
? `SSH ${direction === "download" ? "Download" : "Upload"}`
|
|
665
|
-
: "SSH Command Request",
|
|
680
|
+
title,
|
|
666
681
|
body,
|
|
667
682
|
accent: sudo ? "error" : action === "file" ? "warning" : "accent",
|
|
668
683
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
684
|
+
approveValue: ["yes", "session"],
|
|
669
685
|
choices: [
|
|
670
686
|
{
|
|
671
687
|
value: "yes",
|
|
@@ -673,6 +689,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
673
689
|
description:
|
|
674
690
|
action === "file" ? "Copy to destination (may overwrite)" : "Run the command",
|
|
675
691
|
},
|
|
692
|
+
...sessionChoices,
|
|
676
693
|
{
|
|
677
694
|
value: "no",
|
|
678
695
|
label: "Deny",
|
|
@@ -688,14 +705,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
688
705
|
last = await showOverlay(ctx.ui, {
|
|
689
706
|
mode: "sudo",
|
|
690
707
|
icon: action === "file" ? icon("warn") : icon("lock"),
|
|
691
|
-
title
|
|
692
|
-
action === "file"
|
|
693
|
-
? `SSH ${direction === "download" ? "Download" : "Upload"}`
|
|
694
|
-
: "SSH Command Request",
|
|
708
|
+
title,
|
|
695
709
|
body: [...body, `Enter: ${label}`],
|
|
696
710
|
accent: sudo ? "error" : action === "file" ? "warning" : "accent",
|
|
697
711
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
698
712
|
maxPasswordAttempts: MAX_PASSWORD_ATTEMPTS,
|
|
713
|
+
approveValue: ["yes", "session"],
|
|
699
714
|
passwordLabel: `${label}:`,
|
|
700
715
|
// Login stage: actually test the password against the host so a
|
|
701
716
|
// wrong one re-prompts (up to MAX_PASSWORD_ATTEMPTS), mirroring
|
|
@@ -708,6 +723,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
708
723
|
label: "Allow",
|
|
709
724
|
description: `Enter ${label.toLowerCase()}`,
|
|
710
725
|
},
|
|
726
|
+
...sessionChoices,
|
|
711
727
|
{ value: "no", label: "Deny", description: "Block the command" },
|
|
712
728
|
],
|
|
713
729
|
});
|
|
@@ -731,6 +747,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
731
747
|
// File transfers remain warning-level: normal mode asks every time,
|
|
732
748
|
// AFK denies above, and YOLO may approve when no password is missing.
|
|
733
749
|
if (action === "command") approvedHosts.set(key, Date.now() + APPROVAL_TTL_MS);
|
|
750
|
+
// "session" is only offered for non-privileged commands (sessionChoices).
|
|
751
|
+
if (overlayResult.choice === "session") {
|
|
752
|
+
sessionHosts.add(key);
|
|
753
|
+
ctx.ui.notify(`ssh_run: ${host} allowed for this session (sudo still asks)`, "info");
|
|
754
|
+
}
|
|
734
755
|
|
|
735
756
|
// Persist newly-entered passwords in the session cache.
|
|
736
757
|
const loginPassword = creds.loginPassword ?? collected.login;
|