@xynogen/pix-ssh 0.2.7 → 0.3.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 +246 -59
- package/src/lib.test.ts +51 -0
- package/src/lib.ts +78 -15
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pix-ssh — Pi extension
|
|
3
3
|
*
|
|
4
|
-
* Registers an `ssh_run` tool: run a
|
|
5
|
-
* optionally as root
|
|
6
|
-
* password entry, mirroring pix-sudo.
|
|
4
|
+
* Registers an `ssh_run` tool: run a command or transfer files/directories over
|
|
5
|
+
* SSH. Commands may optionally run as root through remote sudo. One overlay
|
|
6
|
+
* handles confirmation and any needed password entry, mirroring pix-sudo.
|
|
7
7
|
*
|
|
8
8
|
* Auth:
|
|
9
9
|
* - SSH: key/agent/existing-master first (BatchMode probe). If that fails,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Security notes:
|
|
22
22
|
* - Passwords never leave JS memory; never written to disk; never in argv.
|
|
23
|
-
* -
|
|
23
|
+
* - File transfers are warning-level and show their overwrite risk in the UI.
|
|
24
24
|
* - No UI (RPC / JSON mode) = blocked immediately.
|
|
25
25
|
* - Output truncated to 50 KB / 2000 lines.
|
|
26
26
|
*/
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
sectionRule,
|
|
45
45
|
termW,
|
|
46
46
|
} from "@xynogen/pix-pretty/utils";
|
|
47
|
+
import { SPINNER } from "@xynogen/pix-pretty/widget-format";
|
|
47
48
|
import { getUnattendedMode, withAgentBlock } from "@xynogen/pix-runtime";
|
|
48
49
|
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
49
50
|
import { Type } from "typebox";
|
|
@@ -62,11 +63,15 @@ import {
|
|
|
62
63
|
probeKeyAuth,
|
|
63
64
|
resolveSshHost,
|
|
64
65
|
runSsh,
|
|
66
|
+
runTransfer,
|
|
67
|
+
type TransferDirection,
|
|
68
|
+
transferApprovalDecision,
|
|
65
69
|
truncate,
|
|
66
70
|
} from "./lib.ts";
|
|
67
71
|
|
|
68
72
|
const PROMPT_TIMEOUT_MS = 60_000;
|
|
69
73
|
const MAX_PASSWORD_ATTEMPTS = 3;
|
|
74
|
+
const SPINNER_INTERVAL_MS = 120;
|
|
70
75
|
|
|
71
76
|
// In-memory per-host credential cache (session-scoped, never persisted).
|
|
72
77
|
// Key = canonical "user@host:port". Cleared on process exit.
|
|
@@ -115,6 +120,78 @@ export interface SshResultDetails {
|
|
|
115
120
|
_render?: string;
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
type SshParams =
|
|
124
|
+
| {
|
|
125
|
+
action?: "command";
|
|
126
|
+
host: string;
|
|
127
|
+
command: string;
|
|
128
|
+
sudo?: boolean;
|
|
129
|
+
reason?: string;
|
|
130
|
+
}
|
|
131
|
+
| {
|
|
132
|
+
action: "file";
|
|
133
|
+
host: string;
|
|
134
|
+
direction: TransferDirection;
|
|
135
|
+
source: string;
|
|
136
|
+
destination: string;
|
|
137
|
+
recursive?: boolean;
|
|
138
|
+
reason?: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
interface SshOperation {
|
|
142
|
+
action: "command" | "file";
|
|
143
|
+
command: string;
|
|
144
|
+
sudo: boolean;
|
|
145
|
+
reason?: string;
|
|
146
|
+
direction?: TransferDirection;
|
|
147
|
+
source: string;
|
|
148
|
+
destination: string;
|
|
149
|
+
recursive: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeOperation(params: SshParams): SshOperation {
|
|
153
|
+
if (params.action === "file") {
|
|
154
|
+
const source = params.source.trim();
|
|
155
|
+
const destination = params.destination.trim();
|
|
156
|
+
return {
|
|
157
|
+
action: "file",
|
|
158
|
+
command: `${params.direction} ${source || "(empty source)"} → ${destination || "(empty destination)"}`,
|
|
159
|
+
sudo: false,
|
|
160
|
+
reason: params.reason,
|
|
161
|
+
direction: params.direction,
|
|
162
|
+
source,
|
|
163
|
+
destination,
|
|
164
|
+
recursive: params.recursive === true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
action: "command",
|
|
169
|
+
command: params.command,
|
|
170
|
+
sudo: params.sudo === true,
|
|
171
|
+
reason: params.reason,
|
|
172
|
+
source: "",
|
|
173
|
+
destination: "",
|
|
174
|
+
recursive: false,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function approvalBody(operation: SshOperation, host: string, port?: number): string[] {
|
|
179
|
+
const { action, command, destination, direction, reason, recursive, source, sudo } = operation;
|
|
180
|
+
return [
|
|
181
|
+
reason?.trim() ? `Intent: ${reason.trim()}` : "No reason provided by AI",
|
|
182
|
+
`Host: ${host}${port ? ` (port ${port})` : ""}`,
|
|
183
|
+
...(action === "command"
|
|
184
|
+
? [`Command: ${sudo ? "sudo " : ""}${command}`]
|
|
185
|
+
: [
|
|
186
|
+
`Direction: ${direction === "download" ? "Download" : "Upload"}`,
|
|
187
|
+
`From: ${source}`,
|
|
188
|
+
`To: ${destination}`,
|
|
189
|
+
`Mode: ${recursive ? "Recursive copy" : "Single item"}`,
|
|
190
|
+
"Warning: existing destination may be overwritten",
|
|
191
|
+
]),
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
118
195
|
function safeOneLine(value: string): string {
|
|
119
196
|
return value
|
|
120
197
|
.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
|
|
@@ -151,12 +228,15 @@ function updatePresentation(
|
|
|
151
228
|
sudo: boolean,
|
|
152
229
|
reason: string | undefined,
|
|
153
230
|
outcome: "awaiting-approval" | "running",
|
|
231
|
+
message?: string,
|
|
154
232
|
): void {
|
|
155
233
|
onUpdate?.({
|
|
156
234
|
content: [
|
|
157
235
|
{
|
|
158
236
|
type: "text",
|
|
159
|
-
text:
|
|
237
|
+
text:
|
|
238
|
+
message ??
|
|
239
|
+
(outcome === "awaiting-approval" ? "Awaiting approval…" : `Running on ${host}…`),
|
|
160
240
|
},
|
|
161
241
|
],
|
|
162
242
|
details: makeDetails(command, host, sudo, reason, { outcome }),
|
|
@@ -218,7 +298,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
218
298
|
name: "ssh_run",
|
|
219
299
|
label: "Run over SSH",
|
|
220
300
|
description:
|
|
221
|
-
"Run a command
|
|
301
|
+
"Run a command or transfer files/directories through SSH. Commands may optionally use POSIX sudo. " +
|
|
222
302
|
"Basic cmd/PowerShell/pwsh commands may work, but Windows shells are best-effort: shell selection, " +
|
|
223
303
|
"quoting, PowerShell error/stream/encoding semantics, interactive prompts, and Windows " +
|
|
224
304
|
"administrator/UAC elevation are not supported. Back away and tell the user when correctness " +
|
|
@@ -226,40 +306,80 @@ export default function (pi: ExtensionAPI): void {
|
|
|
226
306
|
"A configured approval window or YOLO mode may auto-approve non-privileged commands when no password is missing. " +
|
|
227
307
|
"SSH auth tries key/agent first, then prompts for a login password if needed. " +
|
|
228
308
|
"Set `sudo: true` to run the command as root on the remote machine (prompts for the " +
|
|
229
|
-
|
|
230
|
-
|
|
309
|
+
'remote sudo password). For transfer, set `action: "file"`, `direction`, `source`, ' +
|
|
310
|
+
"`destination`, and optional `recursive`. Transfers may overwrite the destination. Always provide a clear `reason`.",
|
|
311
|
+
promptSnippet: "Run a remote command or transfer files over SSH",
|
|
231
312
|
promptGuidelines: [
|
|
232
|
-
"ssh_run sends commands
|
|
313
|
+
"ssh_run sends commands or transfers files/directories over SSH. Basic cmd, PowerShell, or " +
|
|
233
314
|
"pwsh commands may work, but treat Windows shells as best-effort. Back away and tell the user " +
|
|
234
315
|
"when correctness depends on explicit shell selection, complex quoting, PowerShell error/stream/encoding " +
|
|
235
316
|
"semantics, interactive prompts, or Windows administrator/UAC elevation. `sudo` covers POSIX sudo " +
|
|
236
|
-
|
|
317
|
+
'only. Provide `host` as `[user@]host[:port]`. For file transfer, use `action: "file"`, ' +
|
|
318
|
+
'`direction: "upload" | "download"`, `source`, `destination`, and optional `recursive`. ' +
|
|
319
|
+
"Transfers may overwrite the destination and are warning-level: AFK/YOLO auto-approve when " +
|
|
320
|
+
"no login password is missing. Always explain the intent in `reason`.",
|
|
237
321
|
],
|
|
238
322
|
|
|
239
323
|
renderShell: "self",
|
|
240
324
|
|
|
241
|
-
parameters: Type.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
325
|
+
parameters: Type.Union([
|
|
326
|
+
Type.Object({
|
|
327
|
+
action: Type.Optional(Type.Literal("command")),
|
|
328
|
+
host: Type.String({
|
|
329
|
+
description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
|
|
330
|
+
}),
|
|
331
|
+
command: Type.String({
|
|
332
|
+
description: "Command sent to the remote host's configured SSH shell.",
|
|
333
|
+
}),
|
|
334
|
+
sudo: Type.Optional(
|
|
335
|
+
Type.Boolean({
|
|
336
|
+
description: "Run the command as root on the remote host via sudo. Default false.",
|
|
337
|
+
}),
|
|
338
|
+
),
|
|
339
|
+
reason: Type.Optional(
|
|
340
|
+
Type.String({
|
|
341
|
+
description: "Short plain-English explanation of intent, shown to the user.",
|
|
342
|
+
}),
|
|
343
|
+
),
|
|
247
344
|
}),
|
|
248
|
-
|
|
249
|
-
Type.
|
|
250
|
-
|
|
345
|
+
Type.Object({
|
|
346
|
+
action: Type.Literal("file"),
|
|
347
|
+
host: Type.String({
|
|
348
|
+
description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
|
|
349
|
+
}),
|
|
350
|
+
direction: Type.Union([Type.Literal("upload"), Type.Literal("download")]),
|
|
351
|
+
source: Type.String({
|
|
352
|
+
description: "Source path. Local for upload; remote for download.",
|
|
251
353
|
}),
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
Type.String({
|
|
255
|
-
description: "Short plain-English explanation of intent, shown to the user.",
|
|
354
|
+
destination: Type.String({
|
|
355
|
+
description: "Destination path. Remote for upload; local for download.",
|
|
256
356
|
}),
|
|
257
|
-
|
|
258
|
-
|
|
357
|
+
recursive: Type.Optional(
|
|
358
|
+
Type.Boolean({ description: "Copy a directory recursively. Default false." }),
|
|
359
|
+
),
|
|
360
|
+
reason: Type.Optional(
|
|
361
|
+
Type.String({
|
|
362
|
+
description: "Short plain-English explanation of intent, shown to the user.",
|
|
363
|
+
}),
|
|
364
|
+
),
|
|
365
|
+
}),
|
|
366
|
+
]),
|
|
259
367
|
|
|
260
368
|
async execute(_toolCallId, params, sig, onUpdate, ctx) {
|
|
261
|
-
const
|
|
262
|
-
const
|
|
369
|
+
const operation = normalizeOperation(params);
|
|
370
|
+
const { action, command, destination, direction, reason, recursive, source, sudo } =
|
|
371
|
+
operation;
|
|
372
|
+
|
|
373
|
+
if (action === "file" && (!source || !destination)) {
|
|
374
|
+
return {
|
|
375
|
+
content: [{ type: "text", text: "ssh_run failed: source and destination are required" }],
|
|
376
|
+
details: makeDetails(command, params.host, false, reason, {
|
|
377
|
+
outcome: "error",
|
|
378
|
+
errorKind: "execution",
|
|
379
|
+
}),
|
|
380
|
+
isError: true,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
263
383
|
|
|
264
384
|
let spec: HostSpec;
|
|
265
385
|
try {
|
|
@@ -287,7 +407,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
287
407
|
|
|
288
408
|
const mode = getUnattendedMode(pi.events);
|
|
289
409
|
const yolo = mode === "yolo";
|
|
290
|
-
if (mode === "afk") {
|
|
410
|
+
if (action === "command" && mode === "afk") {
|
|
291
411
|
return {
|
|
292
412
|
content: [{ type: "text", text: "ssh_run denied immediately — AFK mode is active." }],
|
|
293
413
|
details: makeDetails(command, host, sudo, reason, {
|
|
@@ -327,12 +447,26 @@ export default function (pi: ExtensionAPI): void {
|
|
|
327
447
|
...(needLogin ? (["login"] as const) : []),
|
|
328
448
|
...(needSudo ? (["sudo"] as const) : []),
|
|
329
449
|
];
|
|
450
|
+
const transferDecision =
|
|
451
|
+
action === "file" ? transferApprovalDecision(mode, needLogin) : "ask";
|
|
452
|
+
if (transferDecision === "deny") {
|
|
453
|
+
return {
|
|
454
|
+
content: [
|
|
455
|
+
{
|
|
456
|
+
type: "text",
|
|
457
|
+
text: "ssh_run file transfer denied — unattended mode cannot enter a missing SSH login password.",
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
details: makeDetails(command, host, false, reason, {
|
|
461
|
+
outcome: "denied",
|
|
462
|
+
cancellationKind: "denied",
|
|
463
|
+
}),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
330
466
|
|
|
331
467
|
const body = [
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
`Command: ${sudo ? "sudo " : ""}${command}`,
|
|
335
|
-
...(keyOk && !creds.loginPassword ? ["(key-based auth — no login password needed)"] : []),
|
|
468
|
+
...approvalBody(operation, host, spec.port),
|
|
469
|
+
...(keyOk && !creds.loginPassword ? ["Auth: SSH key (no password)"] : []),
|
|
336
470
|
];
|
|
337
471
|
|
|
338
472
|
const collected: { login?: string; sudo?: string } = {};
|
|
@@ -345,29 +479,49 @@ export default function (pi: ExtensionAPI): void {
|
|
|
345
479
|
// the confirm overlay entirely (visible notify below). Privileged commands
|
|
346
480
|
// (sudo:true, or a sudo/su/doas/pkexec token in the command text) are never
|
|
347
481
|
// auto-approved — they always re-confirm.
|
|
348
|
-
const privileged = sudo || commandEscalatesPrivilege(command);
|
|
482
|
+
const privileged = action === "command" && (sudo || commandEscalatesPrivilege(command));
|
|
349
483
|
const alreadyApproved =
|
|
350
|
-
|
|
484
|
+
action === "command" &&
|
|
485
|
+
!privileged &&
|
|
486
|
+
hostApproved(approvedHosts, key) &&
|
|
487
|
+
promptFor.length === 0;
|
|
351
488
|
if (alreadyApproved) {
|
|
352
489
|
ctx.ui.notify(`🔐 ssh_run auto-approved — ${host} allowed this session`, "warning");
|
|
490
|
+
} else if (transferDecision === "allow") {
|
|
491
|
+
ctx.ui.notify(
|
|
492
|
+
`⚠ ssh_run file transfer auto-approved — ${mode.toUpperCase()} warning policy`,
|
|
493
|
+
"warning",
|
|
494
|
+
);
|
|
353
495
|
}
|
|
354
496
|
|
|
355
497
|
const runOverlay = (): Promise<OverlayResult> =>
|
|
356
498
|
withAgentBlock(pi.events, "ssh_run", "SSH approval required", async () => {
|
|
357
|
-
if ((yolo || alreadyApproved) && promptFor.length === 0) {
|
|
499
|
+
if ((transferDecision === "allow" || yolo || alreadyApproved) && promptFor.length === 0) {
|
|
358
500
|
return { action: "approved", password: "" } as OverlayResult;
|
|
359
501
|
}
|
|
360
502
|
// Confirm-only when no password is missing.
|
|
361
503
|
if (promptFor.length === 0) {
|
|
362
504
|
return showOverlay(ctx.ui, {
|
|
363
505
|
mode: "confirm",
|
|
364
|
-
title:
|
|
506
|
+
title:
|
|
507
|
+
action === "file"
|
|
508
|
+
? `⚠ SSH ${direction === "download" ? "DOWNLOAD" : "UPLOAD"}`
|
|
509
|
+
: "🔐 SSH COMMAND REQUEST",
|
|
365
510
|
body,
|
|
366
|
-
accent: sudo ? "error" : "accent",
|
|
511
|
+
accent: sudo ? "error" : action === "file" ? "warning" : "accent",
|
|
367
512
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
368
513
|
choices: [
|
|
369
|
-
{
|
|
370
|
-
|
|
514
|
+
{
|
|
515
|
+
value: "yes",
|
|
516
|
+
label: "Allow",
|
|
517
|
+
description:
|
|
518
|
+
action === "file" ? "Copy to destination (may overwrite)" : "Run the command",
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
value: "no",
|
|
522
|
+
label: "Deny",
|
|
523
|
+
description: action === "file" ? "Cancel transfer" : "Block the command",
|
|
524
|
+
},
|
|
371
525
|
],
|
|
372
526
|
});
|
|
373
527
|
}
|
|
@@ -377,9 +531,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
377
531
|
const label = stage === "login" ? "SSH login password" : "Remote sudo password";
|
|
378
532
|
last = await showOverlay(ctx.ui, {
|
|
379
533
|
mode: "sudo",
|
|
380
|
-
title:
|
|
534
|
+
title:
|
|
535
|
+
action === "file"
|
|
536
|
+
? `⚠ SSH ${direction === "download" ? "DOWNLOAD" : "UPLOAD"}`
|
|
537
|
+
: "🔐 SSH COMMAND REQUEST",
|
|
381
538
|
body: [...body, `Enter: ${label}`],
|
|
382
|
-
accent: sudo ? "error" : "accent",
|
|
539
|
+
accent: sudo ? "error" : action === "file" ? "warning" : "accent",
|
|
383
540
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
384
541
|
maxPasswordAttempts: MAX_PASSWORD_ATTEMPTS,
|
|
385
542
|
passwordLabel: `${label}:`,
|
|
@@ -410,8 +567,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
410
567
|
return r;
|
|
411
568
|
}
|
|
412
569
|
|
|
413
|
-
//
|
|
414
|
-
|
|
570
|
+
// File transfers remain warning-level: normal mode asks every time,
|
|
571
|
+
// AFK denies above, and YOLO may approve when no password is missing.
|
|
572
|
+
if (action === "command") approvedHosts.set(key, Date.now() + APPROVAL_TTL_MS);
|
|
415
573
|
|
|
416
574
|
// Persist newly-entered passwords in the session cache.
|
|
417
575
|
const loginPassword = creds.loginPassword ?? collected.login;
|
|
@@ -421,17 +579,47 @@ export default function (pi: ExtensionAPI): void {
|
|
|
421
579
|
...(sudoPassword ? { sudoPassword } : {}),
|
|
422
580
|
});
|
|
423
581
|
|
|
424
|
-
|
|
582
|
+
let spinnerFrame = 0;
|
|
583
|
+
const updateTransferPresentation = () => {
|
|
584
|
+
const verb = direction === "download" ? "Downloading from" : "Uploading to";
|
|
585
|
+
updatePresentation(
|
|
586
|
+
onUpdate,
|
|
587
|
+
command,
|
|
588
|
+
host,
|
|
589
|
+
sudo,
|
|
590
|
+
reason,
|
|
591
|
+
"running",
|
|
592
|
+
`${SPINNER[spinnerFrame] ?? ""} ${verb} ${host}…`,
|
|
593
|
+
);
|
|
594
|
+
};
|
|
595
|
+
if (action === "file") updateTransferPresentation();
|
|
596
|
+
else updatePresentation(onUpdate, command, host, sudo, reason, "running");
|
|
597
|
+
// ponytail: spinner shows liveness only; SCP has no stable byte-progress API.
|
|
598
|
+
// Use an SFTP client with byte callbacks if percentage progress is needed.
|
|
599
|
+
const spinnerTimer =
|
|
600
|
+
action === "file" && onUpdate
|
|
601
|
+
? setInterval(() => {
|
|
602
|
+
spinnerFrame = (spinnerFrame + 1) % SPINNER.length;
|
|
603
|
+
updateTransferPresentation();
|
|
604
|
+
}, SPINNER_INTERVAL_MS)
|
|
605
|
+
: undefined;
|
|
425
606
|
|
|
426
607
|
let result: { stdout: string; stderr: string; code: number } | undefined;
|
|
427
608
|
try {
|
|
428
|
-
result =
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
609
|
+
result =
|
|
610
|
+
action === "file"
|
|
611
|
+
? await runTransfer(spec, direction ?? "upload", source, destination, recursive, {
|
|
612
|
+
controlPath,
|
|
613
|
+
...(loginPassword ? { loginPassword } : {}),
|
|
614
|
+
...(sig ? { signal: sig } : {}),
|
|
615
|
+
})
|
|
616
|
+
: await runSsh(spec, command, {
|
|
617
|
+
controlPath,
|
|
618
|
+
...(loginPassword ? { loginPassword } : {}),
|
|
619
|
+
sudo,
|
|
620
|
+
...(sudo ? { sudoPassword: sudoPassword ?? "" } : {}),
|
|
621
|
+
...(sig ? { signal: sig } : {}),
|
|
622
|
+
});
|
|
435
623
|
} catch (err) {
|
|
436
624
|
const msg = err instanceof Error ? err.message : String(err);
|
|
437
625
|
return {
|
|
@@ -447,6 +635,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
447
635
|
),
|
|
448
636
|
isError: sig?.aborted !== true,
|
|
449
637
|
};
|
|
638
|
+
} finally {
|
|
639
|
+
if (spinnerTimer) clearInterval(spinnerTimer);
|
|
450
640
|
}
|
|
451
641
|
|
|
452
642
|
if (!result) {
|
|
@@ -518,11 +708,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
518
708
|
};
|
|
519
709
|
},
|
|
520
710
|
|
|
521
|
-
renderCall: ((
|
|
522
|
-
args: { command: string; host: string; sudo?: boolean; reason?: string },
|
|
523
|
-
theme: ThemeLike,
|
|
524
|
-
renderCtx: RenderContextLike,
|
|
525
|
-
) => {
|
|
711
|
+
renderCall: ((args: SshParams, theme: ThemeLike, renderCtx: RenderContextLike) => {
|
|
526
712
|
resolveBaseBackground(theme);
|
|
527
713
|
const text = renderCtx.lastComponent ?? new Text("", 0, 0);
|
|
528
714
|
if (
|
|
@@ -532,12 +718,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
532
718
|
)
|
|
533
719
|
return text;
|
|
534
720
|
|
|
535
|
-
const
|
|
721
|
+
const operation = normalizeOperation(args);
|
|
722
|
+
const command = safeOneLine(operation.command) || "(empty command)";
|
|
536
723
|
const host = safeOneLine(args.host);
|
|
537
|
-
const prefix =
|
|
724
|
+
const prefix = operation.sudo ? "sudo " : "";
|
|
538
725
|
text.setText(
|
|
539
726
|
fillToolBackground(
|
|
540
|
-
`${theme.fg("toolTitle", theme.bold("ssh"))} ${theme.fg("dim", host)} ${theme.fg("muted", prefix + command)}`,
|
|
727
|
+
`${theme.fg("toolTitle", theme.bold(operation.action === "file" ? "ssh file" : "ssh"))} ${theme.fg("dim", host)} ${theme.fg("muted", prefix + command)}`,
|
|
541
728
|
),
|
|
542
729
|
);
|
|
543
730
|
return text;
|
package/src/lib.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
baseScpArgs,
|
|
3
4
|
baseSshArgs,
|
|
4
5
|
commandEscalatesPrivilege,
|
|
5
6
|
controlPathFor,
|
|
@@ -12,7 +13,10 @@ import {
|
|
|
12
13
|
parseHost,
|
|
13
14
|
parseSshConfig,
|
|
14
15
|
remoteCommand,
|
|
16
|
+
remoteTransferPath,
|
|
15
17
|
shellQuote,
|
|
18
|
+
transferApprovalDecision,
|
|
19
|
+
transferArgs,
|
|
16
20
|
truncate,
|
|
17
21
|
} from "./lib.ts";
|
|
18
22
|
|
|
@@ -94,6 +98,53 @@ describe("baseSshArgs", () => {
|
|
|
94
98
|
});
|
|
95
99
|
});
|
|
96
100
|
|
|
101
|
+
describe("transfer unattended approval", () => {
|
|
102
|
+
it("treats transfer as warning-level in AFK and YOLO", () => {
|
|
103
|
+
expect(transferApprovalDecision("off", false)).toBe("ask");
|
|
104
|
+
expect(transferApprovalDecision("afk", false)).toBe("allow");
|
|
105
|
+
expect(transferApprovalDecision("yolo", false)).toBe("allow");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("denies unattended transfer when a login password is missing", () => {
|
|
109
|
+
expect(transferApprovalDecision("afk", true)).toBe("deny");
|
|
110
|
+
expect(transferApprovalDecision("yolo", true)).toBe("deny");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("SCP transfer arguments", () => {
|
|
115
|
+
it("builds upload and download endpoints", () => {
|
|
116
|
+
const spec = { user: "deploy", host: "example.com", port: 2222 };
|
|
117
|
+
expect(remoteTransferPath(spec, "/srv/app file")).toBe("deploy@example.com:/srv/app file");
|
|
118
|
+
expect(transferArgs(spec, "upload", "./build", "/srv/app")).toEqual([
|
|
119
|
+
"./build",
|
|
120
|
+
"deploy@example.com:/srv/app",
|
|
121
|
+
]);
|
|
122
|
+
expect(transferArgs(spec, "download", "/var/log/app.log", "./app.log")).toEqual([
|
|
123
|
+
"deploy@example.com:/var/log/app.log",
|
|
124
|
+
"./app.log",
|
|
125
|
+
]);
|
|
126
|
+
expect(baseScpArgs(spec, "/tmp/control.sock", true)).toEqual([
|
|
127
|
+
"-o",
|
|
128
|
+
"ControlMaster=auto",
|
|
129
|
+
"-o",
|
|
130
|
+
"ControlPath=/tmp/control.sock",
|
|
131
|
+
"-o",
|
|
132
|
+
"ControlPersist=120",
|
|
133
|
+
"-o",
|
|
134
|
+
"ConnectTimeout=10",
|
|
135
|
+
"-o",
|
|
136
|
+
"StrictHostKeyChecking=accept-new",
|
|
137
|
+
"-P",
|
|
138
|
+
"2222",
|
|
139
|
+
"-r",
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("brackets IPv6 hosts for SCP remote-path syntax", () => {
|
|
144
|
+
expect(remoteTransferPath({ user: "root", host: "::1" }, "/tmp/x")).toBe("root@[::1]:/tmp/x");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
97
148
|
describe("remoteCommand + shellQuote", () => {
|
|
98
149
|
it("returns the command unchanged without sudo", () => {
|
|
99
150
|
expect(remoteCommand("whoami", false)).toBe("whoami");
|
package/src/lib.ts
CHANGED
|
@@ -65,6 +65,18 @@ export interface SshResult {
|
|
|
65
65
|
code: number;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export type TransferDirection = "upload" | "download";
|
|
69
|
+
export type UnattendedMode = "off" | "afk" | "yolo";
|
|
70
|
+
|
|
71
|
+
/** Warning-level transfer policy. Password prompts cannot run unattended. */
|
|
72
|
+
export function transferApprovalDecision(
|
|
73
|
+
mode: UnattendedMode,
|
|
74
|
+
loginPasswordMissing: boolean,
|
|
75
|
+
): "ask" | "allow" | "deny" {
|
|
76
|
+
if (mode === "off") return "ask";
|
|
77
|
+
return loginPasswordMissing ? "deny" : "allow";
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
// ── Host parsing ─────────────────────────────────────────────────────────────
|
|
69
81
|
|
|
70
82
|
/** Parse `[user@]host[:port]` into parts. Throws on empty host. */
|
|
@@ -158,7 +170,7 @@ export function controlPathFor(spec: HostSpec): string {
|
|
|
158
170
|
|
|
159
171
|
/** Base ssh options shared by every invocation: multiplexing + timeouts +
|
|
160
172
|
* non-interactive prompts (BatchMode is toggled by the caller). */
|
|
161
|
-
|
|
173
|
+
function connectionArgs(spec: HostSpec, controlPath: string, portFlag: "-p" | "-P"): string[] {
|
|
162
174
|
const args = [
|
|
163
175
|
"-o",
|
|
164
176
|
"ControlMaster=auto",
|
|
@@ -171,10 +183,36 @@ export function baseSshArgs(spec: HostSpec, controlPath: string): string[] {
|
|
|
171
183
|
"-o",
|
|
172
184
|
"StrictHostKeyChecking=accept-new",
|
|
173
185
|
];
|
|
174
|
-
if (spec.port !== undefined) args.push(
|
|
186
|
+
if (spec.port !== undefined) args.push(portFlag, String(spec.port));
|
|
175
187
|
return args;
|
|
176
188
|
}
|
|
177
189
|
|
|
190
|
+
export function baseSshArgs(spec: HostSpec, controlPath: string): string[] {
|
|
191
|
+
return connectionArgs(spec, controlPath, "-p");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** SCP shares SSH connection options but uses uppercase `-P` for its port. */
|
|
195
|
+
export function baseScpArgs(spec: HostSpec, controlPath: string, recursive: boolean): string[] {
|
|
196
|
+
return [...connectionArgs(spec, controlPath, "-P"), ...(recursive ? ["-r"] : [])];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Format SCP's remote endpoint, bracketing IPv6 literals. */
|
|
200
|
+
export function remoteTransferPath(spec: HostSpec, path: string): string {
|
|
201
|
+
const host = spec.host.includes(":") ? `[${spec.host}]` : spec.host;
|
|
202
|
+
return `${spec.user ? `${spec.user}@` : ""}${host}:${path}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function transferArgs(
|
|
206
|
+
spec: HostSpec,
|
|
207
|
+
direction: TransferDirection,
|
|
208
|
+
source: string,
|
|
209
|
+
destination: string,
|
|
210
|
+
): string[] {
|
|
211
|
+
return direction === "upload"
|
|
212
|
+
? [source, remoteTransferPath(spec, destination)]
|
|
213
|
+
: [remoteTransferPath(spec, source), destination];
|
|
214
|
+
}
|
|
215
|
+
|
|
178
216
|
/** Wrap a command for optional remote sudo. `sudo -S -p ''` reads the sudo
|
|
179
217
|
* password from stdin with no prompt echo; the command runs under `sh -c`. */
|
|
180
218
|
export function remoteCommand(command: string, sudo: boolean): string {
|
|
@@ -238,7 +276,7 @@ export function truncate(
|
|
|
238
276
|
const kept = lines.slice(0, maxLines);
|
|
239
277
|
let result = kept.join("\n");
|
|
240
278
|
if (Buffer.byteLength(result, "utf8") > maxBytes) {
|
|
241
|
-
result = Buffer.from(result, "utf8").
|
|
279
|
+
result = Buffer.from(result, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
242
280
|
}
|
|
243
281
|
return { text: result, truncated: true };
|
|
244
282
|
}
|
|
@@ -308,6 +346,25 @@ export interface RunOptions {
|
|
|
308
346
|
* in `sshpass -e` (password via env, not argv). When `sudo` is set, the command
|
|
309
347
|
* is wrapped in `sudo -S` and `sudoPassword` is written to the remote stdin.
|
|
310
348
|
*/
|
|
349
|
+
export function runTransfer(
|
|
350
|
+
spec: HostSpec,
|
|
351
|
+
direction: TransferDirection,
|
|
352
|
+
source: string,
|
|
353
|
+
destination: string,
|
|
354
|
+
recursive: boolean,
|
|
355
|
+
opts: Pick<RunOptions, "controlPath" | "loginPassword" | "signal">,
|
|
356
|
+
): Promise<SshResult> {
|
|
357
|
+
const scpArgs = [
|
|
358
|
+
...baseScpArgs(spec, opts.controlPath, recursive),
|
|
359
|
+
"--",
|
|
360
|
+
...transferArgs(spec, direction, source, destination),
|
|
361
|
+
];
|
|
362
|
+
const bin = opts.loginPassword ? "sshpass" : "scp";
|
|
363
|
+
const args = opts.loginPassword ? ["-e", "scp", ...scpArgs] : scpArgs;
|
|
364
|
+
const env = opts.loginPassword ? { ...process.env, SSHPASS: opts.loginPassword } : process.env;
|
|
365
|
+
return spawnResult(bin, args, env, opts.signal);
|
|
366
|
+
}
|
|
367
|
+
|
|
311
368
|
export function runSsh(spec: HostSpec, command: string, opts: RunOptions): Promise<SshResult> {
|
|
312
369
|
const remote = remoteCommand(command, opts.sudo === true);
|
|
313
370
|
const sshArgs = [...baseSshArgs(spec, opts.controlPath), hostTarget(spec), remote];
|
|
@@ -321,30 +378,36 @@ export function runSsh(spec: HostSpec, command: string, opts: RunOptions): Promi
|
|
|
321
378
|
env = { ...process.env, SSHPASS: opts.loginPassword };
|
|
322
379
|
}
|
|
323
380
|
|
|
381
|
+
// Remote sudo reads its password from stdin (first line); anything else
|
|
382
|
+
// closes stdin so the remote command sees EOF.
|
|
383
|
+
const stdin = opts.sudo && opts.sudoPassword !== undefined ? `${opts.sudoPassword}\n` : undefined;
|
|
384
|
+
return spawnResult(bin, args, env, opts.signal, opts.sudo ? filterSudoPrompt : undefined, stdin);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function spawnResult(
|
|
388
|
+
bin: string,
|
|
389
|
+
args: string[],
|
|
390
|
+
env: NodeJS.ProcessEnv,
|
|
391
|
+
sig?: AbortSignal,
|
|
392
|
+
filterStderr?: (value: string) => string,
|
|
393
|
+
stdin?: string,
|
|
394
|
+
): Promise<SshResult> {
|
|
324
395
|
return new Promise((resolve, reject) => {
|
|
325
396
|
const proc = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
|
326
397
|
let stdout = "";
|
|
327
398
|
let stderr = "";
|
|
328
|
-
|
|
329
399
|
proc.stdout.on("data", (c: Buffer) => {
|
|
330
400
|
stdout += c.toString();
|
|
331
401
|
});
|
|
332
402
|
proc.stderr.on("data", (c: Buffer) => {
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
403
|
+
const value = filterStderr ? filterStderr(c.toString()) : c.toString();
|
|
404
|
+
if (value) stderr += value;
|
|
335
405
|
});
|
|
336
|
-
|
|
337
406
|
proc.on("error", reject);
|
|
338
407
|
proc.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 }));
|
|
339
|
-
|
|
340
|
-
// Remote sudo reads its password from stdin (first line); anything else
|
|
341
|
-
// closes stdin so the remote command sees EOF.
|
|
342
|
-
if (opts.sudo && opts.sudoPassword !== undefined) {
|
|
343
|
-
proc.stdin.write(`${opts.sudoPassword}\n`);
|
|
344
|
-
}
|
|
408
|
+
if (stdin) proc.stdin.write(stdin);
|
|
345
409
|
proc.stdin.end();
|
|
346
|
-
|
|
347
|
-
signal(opts.signal, proc, reject);
|
|
410
|
+
signal(sig, proc, reject);
|
|
348
411
|
});
|
|
349
412
|
}
|
|
350
413
|
|