@xynogen/pix-ssh 0.6.1 → 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 +51 -24
- package/src/lib.ts +0 -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
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
hideCollapsedToolCall,
|
|
54
54
|
moreLines,
|
|
55
55
|
normalizeLineEndings,
|
|
56
|
+
renderCollapsedToolRow,
|
|
56
57
|
renderToolError,
|
|
57
58
|
ruleFrame,
|
|
58
59
|
termW,
|
|
@@ -64,7 +65,6 @@ import { requireTool } from "@xynogen/pix-runtime/binaries";
|
|
|
64
65
|
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
65
66
|
import { Type } from "typebox";
|
|
66
67
|
import {
|
|
67
|
-
APPROVAL_TTL_MS,
|
|
68
68
|
closeMaster,
|
|
69
69
|
commandEscalatesPrivilege,
|
|
70
70
|
controlPathFor,
|
|
@@ -73,7 +73,6 @@ import {
|
|
|
73
73
|
type HostAlias,
|
|
74
74
|
type HostInfo,
|
|
75
75
|
type HostSpec,
|
|
76
|
-
hostApproved,
|
|
77
76
|
hostTarget,
|
|
78
77
|
MAX_OUTPUT_BYTES,
|
|
79
78
|
MAX_OUTPUT_LINES,
|
|
@@ -108,14 +107,6 @@ const credCache = new Map<string, HostCreds>();
|
|
|
108
107
|
// Per-host epoch ms before which the cached-password key re-check is skipped.
|
|
109
108
|
const keyRecheckAt = new Map<string, number>();
|
|
110
109
|
|
|
111
|
-
// Per-host allow-memory (on by default): once the user approves any command on
|
|
112
|
-
// a host, later calls to that host skip the confirm overlay until the approval
|
|
113
|
-
// expires — mirroring sudo's PAM ticket window (~15 min), not the whole session.
|
|
114
|
-
// Password prompts are NOT skipped — a still-missing login or sudo password
|
|
115
|
-
// always re-prompts. Each auto-approve emits a visible notify so the decision is
|
|
116
|
-
// never silent. Map value = epoch ms when the approval lapses (see APPROVAL_TTL_MS).
|
|
117
|
-
const approvedHosts = new Map<string, number>();
|
|
118
|
-
|
|
119
110
|
function cacheKey(spec: HostSpec): string {
|
|
120
111
|
return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
121
112
|
}
|
|
@@ -401,7 +392,7 @@ function formatHostInfo(host: string, info: HostInfo): string {
|
|
|
401
392
|
["IdentityFile", info.identityFile],
|
|
402
393
|
].filter((r): r is [string, string] => Boolean(r[1]));
|
|
403
394
|
if (rows.length === 0) return `No SSH config found for ${host}.`;
|
|
404
|
-
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");
|
|
405
396
|
}
|
|
406
397
|
|
|
407
398
|
function formatAliasList(aliases: HostAlias[]): string {
|
|
@@ -421,15 +412,14 @@ function formatAliasList(aliases: HostAlias[]): string {
|
|
|
421
412
|
target = `${userPart}${a.hostname}${portPart}`;
|
|
422
413
|
}
|
|
423
414
|
const via = a.proxyJump && a.proxyJump !== "none" ? ` via ${a.proxyJump}` : "";
|
|
424
|
-
lines.push(
|
|
415
|
+
lines.push(`${a.alias}${target ? ` → ${target}` : ""}${via}`);
|
|
425
416
|
}
|
|
426
417
|
return [`SSH host aliases (${lines.length}):`, ...lines].join("\n");
|
|
427
418
|
}
|
|
428
419
|
|
|
429
420
|
/** Build the info-action result: per-host effective config when `host` is
|
|
430
421
|
* given, otherwise the alias inventory from ~/.ssh/config. Read-only.
|
|
431
|
-
* Details carry `_type: "sshInfo"`
|
|
432
|
-
* plain-text branch (no collapse, no exit-code framing). */
|
|
422
|
+
* Details carry `_type: "sshInfo"` + `host` for the collapsed row. */
|
|
433
423
|
/**
|
|
434
424
|
* The pix-runtime lookup message for the first missing binary this action
|
|
435
425
|
* needs (ssh, plus scp for file transfers), or undefined when all resolve.
|
|
@@ -447,7 +437,7 @@ export function missingSshTools(action: "command" | "file"): string | undefined
|
|
|
447
437
|
}
|
|
448
438
|
|
|
449
439
|
async function infoResult(host: string | undefined, sig?: AbortSignal) {
|
|
450
|
-
const details = { _type: "sshInfo" as const };
|
|
440
|
+
const details = { _type: "sshInfo" as const, host: host?.trim() || "aliases" };
|
|
451
441
|
if (host?.trim()) {
|
|
452
442
|
let spec: HostSpec;
|
|
453
443
|
try {
|
|
@@ -480,7 +470,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
480
470
|
// "Allow for this session" hosts. Lives in the factory: Pi hands each new session a fresh `pi`,
|
|
481
471
|
// so this resets on /new. Privileged commands never use it (see `privileged` below).
|
|
482
472
|
const sessionHosts = new Set<string>();
|
|
483
|
-
const approved = (key: string) => sessionHosts.has(key)
|
|
473
|
+
const approved = (key: string) => sessionHosts.has(key);
|
|
484
474
|
pi.registerTool({
|
|
485
475
|
name: "ssh_run",
|
|
486
476
|
...({ exposure: "deferred" } as const),
|
|
@@ -807,6 +797,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
807
797
|
});
|
|
808
798
|
|
|
809
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
|
+
}
|
|
810
812
|
const missing =
|
|
811
813
|
overlayResult.action === "approved" &&
|
|
812
814
|
((needLogin && !collected.login) || (needSudo && !collected.sudo));
|
|
@@ -816,10 +818,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
816
818
|
return r;
|
|
817
819
|
}
|
|
818
820
|
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
if (action === "command") approvedHosts.set(key, Date.now() + APPROVAL_TTL_MS);
|
|
822
|
-
// "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.
|
|
823
823
|
if (overlayResult.choice === "session") {
|
|
824
824
|
sessionHosts.add(key);
|
|
825
825
|
ctx.ui.notify(`ssh_run: ${host} allowed for this session (sudo still asks)`, "info");
|
|
@@ -1016,13 +1016,40 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1016
1016
|
const completed = (isError: boolean) => frameToolResult(text, theme, isError);
|
|
1017
1017
|
|
|
1018
1018
|
if (details?._type !== "sshResult") {
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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";
|
|
1022
1031
|
text.setText(
|
|
1023
|
-
|
|
1032
|
+
renderCollapsedToolRow(
|
|
1033
|
+
theme,
|
|
1034
|
+
"ssh info",
|
|
1035
|
+
info.host ?? "",
|
|
1036
|
+
renderCtx.isError ? "failed" : "",
|
|
1037
|
+
status,
|
|
1038
|
+
),
|
|
1024
1039
|
);
|
|
1040
|
+
return text;
|
|
1025
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
|
+
);
|
|
1026
1053
|
return isPartial ? text : completed(renderCtx.isError);
|
|
1027
1054
|
}
|
|
1028
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
|