@xynogen/pix-ssh 0.5.8 → 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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +40 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "description": "Pi tool \u2014 ssh_run: run remote commands over SSH with password/key auth and remote sudo",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@xynogen/pix-pretty": "^1.32.0",
41
- "@xynogen/pix-runtime": "^0.14.0"
41
+ "@xynogen/pix-runtime": "^0.15.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "typebox": "*",
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 { type OverlayResult, showOverlay } from "@xynogen/pix-pretty/gate-overlay";
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() ? `Intent: ${reason.trim()}` : "No reason provided by AI",
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.Optional(
469
- Type.String({
470
- description: "All actions. Short plain-English intent, shown to the user.",
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;