@xynogen/pix-ssh 0.1.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/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +47 -0
- package/src/index.ts +601 -0
- package/src/lib.test.ts +155 -0
- package/src/lib.ts +304 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xynogen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# pix-ssh
|
|
2
|
+
|
|
3
|
+
Pi tool — `ssh_run`: run a shell command on a remote host over SSH, optionally as remote root.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Registers the `ssh_run` tool, which executes a command on a remote machine behind a permission dialog (the shared overlay from `@xynogen/pix-pretty`, the same one pix-sudo uses). Every command requires explicit per-call Allow/Deny approval in the UI, with a 60-second auto-deny timeout — that approval step is never skipped. Output is truncated to 50 KB / 2000 lines. In non-interactive (RPC/JSON) mode the tool is blocked immediately.
|
|
8
|
+
|
|
9
|
+
**Parameters:** `host` as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`), `command`, optional `sudo` (run as root on the remote), optional `reason`.
|
|
10
|
+
|
|
11
|
+
### Authentication
|
|
12
|
+
|
|
13
|
+
- **SSH login** — tries key/agent/existing-master auth first via a `BatchMode=yes` probe. If that succeeds, no login password is needed. If it fails, a masked overlay (`●` per character) collects the login password, fed to `sshpass -e` through the child's `SSHPASS` env var — never as an argv (no `ps` leak), never written to disk.
|
|
14
|
+
- **Remote sudo** (`sudo: true`) — a separate masked prompt collects the remote sudo password, piped to the remote `sudo -S -p ''` on stdin, so it travels inside the encrypted SSH channel, not as an argv.
|
|
15
|
+
|
|
16
|
+
Both passwords are cached **in-memory per host** for the session (keyed by `user@host:port`), never persisted. A wrong login password drops the login cache; a wrong sudo password drops the sudo cache — the next call re-prompts.
|
|
17
|
+
|
|
18
|
+
### Connection reuse
|
|
19
|
+
|
|
20
|
+
OpenSSH **ControlMaster multiplexing** keeps one authenticated connection per host alive for a short `ControlPersist` window, so repeat `ssh_run` calls to the same host skip re-auth. Host-key policy is `accept-new` and `ConnectTimeout` is 10s.
|
|
21
|
+
|
|
22
|
+
While the approval dialog is open, `ssh_run` holds the shared **agent-state** coordinator in `blocked` (via `withAgentBlock` from [`@xynogen/pix-runtime`](https://www.npmjs.com/package/@xynogen/pix-runtime)), so an away user in a herdr pane is pinged when a command is waiting on approval. `/afk` (pix-commands) denies `ssh_run` immediately; `/yolo` auto-approves it only when no password is missing (a password prompt can't be auto-typed).
|
|
23
|
+
|
|
24
|
+
Completed calls collapse after the configured Pix delay into a status row such as `✓ ssh deploy@10.0.0.5 apt update · exit 0 · 12 lines`. Configure the delay with `collapse.delaySec` and the per-tool toggle with `collapse.tools.ssh` in `~/.pi/agent/pix.json`. Overlays are never collapsed, and passwords are never included in result metadata or render state.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- `ssh` (OpenSSH) on the local machine.
|
|
29
|
+
- `sshpass` on the local machine — only needed for password-based SSH login. Key-based auth works without it.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install npm:@xynogen/pix-ssh
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Full distro
|
|
38
|
+
|
|
39
|
+
Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xynogen/pix-ssh",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi",
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"ssh",
|
|
25
|
+
"sudo",
|
|
26
|
+
"remote"
|
|
27
|
+
],
|
|
28
|
+
"author": "xynogen",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
33
|
+
"directory": "packages/pix-ssh"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"typebox": "^1.1.38",
|
|
40
|
+
"@xynogen/pix-pretty": "^1.16.0",
|
|
41
|
+
"@xynogen/pix-runtime": "^0.6.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "*"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pix-ssh — Pi extension
|
|
3
|
+
*
|
|
4
|
+
* Registers an `ssh_run` tool: run a shell command on a remote host over SSH,
|
|
5
|
+
* optionally as root (remote sudo). One overlay handles confirm + any needed
|
|
6
|
+
* password entry, mirroring pix-sudo.
|
|
7
|
+
*
|
|
8
|
+
* Auth:
|
|
9
|
+
* - SSH: key/agent/existing-master first (BatchMode probe). If that fails,
|
|
10
|
+
* a masked overlay collects the login password (fed to `sshpass -e` via
|
|
11
|
+
* env — never argv, never disk). Login password is cached in-memory per
|
|
12
|
+
* host for the session.
|
|
13
|
+
* - Remote sudo (`sudo: true`): a separate masked prompt collects the remote
|
|
14
|
+
* sudo password, piped to the remote `sudo -S` on stdin (inside the
|
|
15
|
+
* encrypted channel). Cached in-memory per host for the session.
|
|
16
|
+
*
|
|
17
|
+
* Connection reuse: OpenSSH ControlMaster multiplexing keeps one authenticated
|
|
18
|
+
* connection per host alive (ControlPersist window), so repeat calls skip
|
|
19
|
+
* re-auth.
|
|
20
|
+
*
|
|
21
|
+
* Security notes:
|
|
22
|
+
* - Passwords never leave JS memory; never written to disk; never in argv.
|
|
23
|
+
* - Every command still requires explicit per-call confirmation in the UI.
|
|
24
|
+
* - No UI (RPC / JSON mode) = blocked immediately.
|
|
25
|
+
* - Output truncated to 50 KB / 2000 lines.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { AgentToolUpdateCallback, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
30
|
+
import { FG_DIM, RST, resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
|
|
31
|
+
import { MAX_PREVIEW_LINES } from "@xynogen/pix-pretty/config";
|
|
32
|
+
import { type OverlayResult, showOverlay } from "@xynogen/pix-pretty/gate-overlay";
|
|
33
|
+
import { renderBashOutput } from "@xynogen/pix-pretty/renderers";
|
|
34
|
+
import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-pretty/types";
|
|
35
|
+
import {
|
|
36
|
+
dotJoin,
|
|
37
|
+
fillToolBackground,
|
|
38
|
+
getTextContent,
|
|
39
|
+
hideCollapsedToolCall,
|
|
40
|
+
normalizeLineEndings,
|
|
41
|
+
renderCollapsedToolRow,
|
|
42
|
+
renderToolError,
|
|
43
|
+
ruleFrame,
|
|
44
|
+
termW,
|
|
45
|
+
} from "@xynogen/pix-pretty/utils";
|
|
46
|
+
import { withAgentBlock } from "@xynogen/pix-runtime";
|
|
47
|
+
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
48
|
+
import { Type } from "typebox";
|
|
49
|
+
import {
|
|
50
|
+
controlPathFor,
|
|
51
|
+
detectSshFailure,
|
|
52
|
+
detectSudoFailure,
|
|
53
|
+
type HostSpec,
|
|
54
|
+
hostTarget,
|
|
55
|
+
MAX_OUTPUT_BYTES,
|
|
56
|
+
MAX_OUTPUT_LINES,
|
|
57
|
+
parseHost,
|
|
58
|
+
probeKeyAuth,
|
|
59
|
+
runSsh,
|
|
60
|
+
truncate,
|
|
61
|
+
} from "./lib.ts";
|
|
62
|
+
|
|
63
|
+
const PROMPT_TIMEOUT_MS = 60_000;
|
|
64
|
+
const MAX_PASSWORD_ATTEMPTS = 3;
|
|
65
|
+
|
|
66
|
+
// In-memory per-host credential cache (session-scoped, never persisted).
|
|
67
|
+
// Key = canonical "user@host:port". Cleared on process exit.
|
|
68
|
+
interface HostCreds {
|
|
69
|
+
loginPassword?: string;
|
|
70
|
+
sudoPassword?: string;
|
|
71
|
+
}
|
|
72
|
+
const credCache = new Map<string, HostCreds>();
|
|
73
|
+
|
|
74
|
+
function cacheKey(spec: HostSpec): string {
|
|
75
|
+
return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type SshOutcome =
|
|
79
|
+
| "awaiting-approval"
|
|
80
|
+
| "running"
|
|
81
|
+
| "success"
|
|
82
|
+
| "denied"
|
|
83
|
+
| "timed-out"
|
|
84
|
+
| "cancelled"
|
|
85
|
+
| "error";
|
|
86
|
+
|
|
87
|
+
type SshCancellationKind = "denied" | "timeout" | "missing-password" | "aborted";
|
|
88
|
+
type SshErrorKind = "no-ui" | "auth-ssh" | "auth-sudo" | "execution" | "no-result" | "exit-code";
|
|
89
|
+
|
|
90
|
+
export interface SshResultDetails {
|
|
91
|
+
_type: "sshResult";
|
|
92
|
+
command: string;
|
|
93
|
+
host: string;
|
|
94
|
+
sudo?: boolean;
|
|
95
|
+
reason?: string;
|
|
96
|
+
outcome: SshOutcome;
|
|
97
|
+
exitCode?: number;
|
|
98
|
+
lineCount?: number;
|
|
99
|
+
truncated?: boolean;
|
|
100
|
+
cancellationKind?: SshCancellationKind;
|
|
101
|
+
errorKind?: SshErrorKind;
|
|
102
|
+
_render?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function safeOneLine(value: string): string {
|
|
106
|
+
return value
|
|
107
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
|
|
108
|
+
.replace(/\s+/g, " ")
|
|
109
|
+
.trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function makeDetails(
|
|
113
|
+
command: string,
|
|
114
|
+
host: string,
|
|
115
|
+
sudo: boolean,
|
|
116
|
+
reason: string | undefined,
|
|
117
|
+
fields: Omit<SshResultDetails, "_type" | "command" | "host" | "sudo" | "reason">,
|
|
118
|
+
): SshResultDetails {
|
|
119
|
+
return {
|
|
120
|
+
_type: "sshResult",
|
|
121
|
+
command,
|
|
122
|
+
host,
|
|
123
|
+
sudo,
|
|
124
|
+
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
125
|
+
...fields,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function outputLineCount(output: string): number {
|
|
130
|
+
const normalized = normalizeLineEndings(output).replace(/^\n+|\n+$/g, "");
|
|
131
|
+
return normalized ? normalized.split("\n").length : 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function updatePresentation(
|
|
135
|
+
onUpdate: AgentToolUpdateCallback<SshResultDetails> | undefined,
|
|
136
|
+
command: string,
|
|
137
|
+
host: string,
|
|
138
|
+
sudo: boolean,
|
|
139
|
+
reason: string | undefined,
|
|
140
|
+
outcome: "awaiting-approval" | "running",
|
|
141
|
+
): void {
|
|
142
|
+
onUpdate?.({
|
|
143
|
+
content: [
|
|
144
|
+
{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: outcome === "awaiting-approval" ? "Awaiting approval…" : `Running on ${host}…`,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
details: makeDetails(command, host, sudo, reason, { outcome }),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function terminalMeta(details: SshResultDetails): string {
|
|
154
|
+
if (details.outcome === "denied") return "denied";
|
|
155
|
+
if (details.outcome === "timed-out") return "timed out";
|
|
156
|
+
if (details.outcome === "cancelled") return "cancelled";
|
|
157
|
+
if (details.errorKind === "no-ui") return "interactive session required";
|
|
158
|
+
if (details.errorKind === "auth-ssh") return "ssh auth failed";
|
|
159
|
+
if (details.errorKind === "auth-sudo") return "sudo auth failed";
|
|
160
|
+
if (details.errorKind === "execution" || details.errorKind === "no-result") return "failed";
|
|
161
|
+
|
|
162
|
+
const hasLines = typeof details.lineCount === "number" && details.lineCount > 0;
|
|
163
|
+
return dotJoin([
|
|
164
|
+
typeof details.exitCode === "number" && `exit ${details.exitCode}`,
|
|
165
|
+
hasLines && `${details.lineCount} ${details.lineCount === 1 ? "line" : "lines"}`,
|
|
166
|
+
details.truncated && "truncated",
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isTerminal(details: SshResultDetails): boolean {
|
|
171
|
+
return details.outcome !== "awaiting-approval" && details.outcome !== "running";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function cancelResult(
|
|
175
|
+
command: string,
|
|
176
|
+
host: string,
|
|
177
|
+
sudo: boolean,
|
|
178
|
+
reason: string | undefined,
|
|
179
|
+
action: OverlayResult["action"],
|
|
180
|
+
): { content: { type: "text"; text: string }[]; details: SshResultDetails } {
|
|
181
|
+
const cancellationKind: SshCancellationKind =
|
|
182
|
+
action === "timeout" ? "timeout" : action === "denied" ? "denied" : "missing-password";
|
|
183
|
+
const outcome: SshOutcome =
|
|
184
|
+
cancellationKind === "timeout"
|
|
185
|
+
? "timed-out"
|
|
186
|
+
: cancellationKind === "denied"
|
|
187
|
+
? "denied"
|
|
188
|
+
: "cancelled";
|
|
189
|
+
const msg =
|
|
190
|
+
outcome === "timed-out"
|
|
191
|
+
? "Timed out — auto-denied."
|
|
192
|
+
: outcome === "denied"
|
|
193
|
+
? "Denied by user."
|
|
194
|
+
: "Cancelled — no password entered.";
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: "text", text: `Cancelled — ${msg}` }],
|
|
197
|
+
details: makeDetails(command, host, sudo, reason, { outcome, cancellationKind }),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Extension entry point ─────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export default function (pi: ExtensionAPI): void {
|
|
204
|
+
pi.registerTool({
|
|
205
|
+
name: "ssh_run",
|
|
206
|
+
label: "Run over SSH",
|
|
207
|
+
description:
|
|
208
|
+
"Run a shell command on a remote host over SSH, optionally as root (remote sudo). " +
|
|
209
|
+
"Handles the connection and any password entry through a confirmation dialog — " +
|
|
210
|
+
"the command is NEVER executed without explicit user approval. " +
|
|
211
|
+
"SSH auth tries key/agent first, then prompts for a login password if needed. " +
|
|
212
|
+
"Set `sudo: true` to run the command as root on the remote machine (prompts for the " +
|
|
213
|
+
"remote sudo password). Always provide a clear `reason`.",
|
|
214
|
+
promptSnippet: "Run a command on a remote host over SSH (optionally as remote root)",
|
|
215
|
+
promptGuidelines: [
|
|
216
|
+
"Use ssh_run to execute commands on a remote machine over SSH. Provide `host` as " +
|
|
217
|
+
"`[user@]host[:port]`. Set `sudo: true` only when the remote command needs root. " +
|
|
218
|
+
"Always set `reason` to a short plain-English sentence explaining the intent.",
|
|
219
|
+
],
|
|
220
|
+
|
|
221
|
+
renderShell: "self",
|
|
222
|
+
|
|
223
|
+
parameters: Type.Object({
|
|
224
|
+
host: Type.String({
|
|
225
|
+
description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
|
|
226
|
+
}),
|
|
227
|
+
command: Type.String({
|
|
228
|
+
description: "Shell command to run on the remote host (passed to remote `sh -c`).",
|
|
229
|
+
}),
|
|
230
|
+
sudo: Type.Optional(
|
|
231
|
+
Type.Boolean({
|
|
232
|
+
description: "Run the command as root on the remote host via sudo. Default false.",
|
|
233
|
+
}),
|
|
234
|
+
),
|
|
235
|
+
reason: Type.Optional(
|
|
236
|
+
Type.String({
|
|
237
|
+
description: "Short plain-English explanation of intent, shown to the user.",
|
|
238
|
+
}),
|
|
239
|
+
),
|
|
240
|
+
}),
|
|
241
|
+
|
|
242
|
+
async execute(_toolCallId, params, sig, onUpdate, ctx) {
|
|
243
|
+
const { command, reason } = params;
|
|
244
|
+
const sudo = params.sudo === true;
|
|
245
|
+
|
|
246
|
+
let spec: HostSpec;
|
|
247
|
+
try {
|
|
248
|
+
spec = parseHost(params.host);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: `ssh_run failed: ${msg}` }],
|
|
253
|
+
details: makeDetails(command, params.host, sudo, reason, {
|
|
254
|
+
outcome: "error",
|
|
255
|
+
errorKind: "execution",
|
|
256
|
+
}),
|
|
257
|
+
isError: true,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const host = hostTarget(spec);
|
|
261
|
+
const controlPath = controlPathFor(spec);
|
|
262
|
+
const key = cacheKey(spec);
|
|
263
|
+
const creds = credCache.get(key) ?? {};
|
|
264
|
+
|
|
265
|
+
const g = globalThis as { __pixAfk?: boolean; __pixYolo?: boolean };
|
|
266
|
+
const yolo = g.__pixYolo === true;
|
|
267
|
+
if (g.__pixAfk === true && !yolo) {
|
|
268
|
+
return {
|
|
269
|
+
content: [{ type: "text", text: "ssh_run denied immediately — AFK mode is active." }],
|
|
270
|
+
details: makeDetails(command, host, sudo, reason, {
|
|
271
|
+
outcome: "denied",
|
|
272
|
+
cancellationKind: "denied",
|
|
273
|
+
}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!ctx.hasUI) {
|
|
278
|
+
return {
|
|
279
|
+
content: [
|
|
280
|
+
{ type: "text", text: "ssh_run requires an interactive session (no UI available)." },
|
|
281
|
+
],
|
|
282
|
+
details: makeDetails(command, host, sudo, reason, {
|
|
283
|
+
outcome: "error",
|
|
284
|
+
errorKind: "no-ui",
|
|
285
|
+
}),
|
|
286
|
+
isError: true,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
updatePresentation(onUpdate, command, host, sudo, reason, "awaiting-approval");
|
|
291
|
+
|
|
292
|
+
// Probe key/agent/existing-master auth (no password needed on success).
|
|
293
|
+
// Only prompt for a login password when the host is reachable but rejects
|
|
294
|
+
// key auth ("auth"). If it's unreachable (timeout/DNS/refused) a password
|
|
295
|
+
// can't help — skip the prompt and let runSsh surface the real error.
|
|
296
|
+
const probe = creds.loginPassword ? "ok" : await probeKeyAuth(spec, controlPath, sig);
|
|
297
|
+
const keyOk = probe === "ok";
|
|
298
|
+
// Which passwords must the overlay collect this call?
|
|
299
|
+
const needLogin = probe === "auth" && !creds.loginPassword;
|
|
300
|
+
const needSudo = sudo && !creds.sudoPassword;
|
|
301
|
+
// The overlay stage pipeline: any password we still need is prompted
|
|
302
|
+
// (login first, then sudo). Confirm-only when nothing is missing.
|
|
303
|
+
const promptFor: ("login" | "sudo")[] = [
|
|
304
|
+
...(needLogin ? (["login"] as const) : []),
|
|
305
|
+
...(needSudo ? (["sudo"] as const) : []),
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
const body = [
|
|
309
|
+
reason?.trim() ? `Intent: ${reason.trim()}` : "No reason provided by AI",
|
|
310
|
+
`Host: ${host}${spec.port ? ` (port ${spec.port})` : ""}`,
|
|
311
|
+
`Command: ${sudo ? "sudo " : ""}${command}`,
|
|
312
|
+
...(keyOk && !creds.loginPassword ? ["(key-based auth — no login password needed)"] : []),
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
const collected: { login?: string; sudo?: string } = {};
|
|
316
|
+
|
|
317
|
+
// Collect each still-missing password through the same overlay pattern
|
|
318
|
+
// pix-sudo uses (masked input, N attempts). We can't validate remote
|
|
319
|
+
// passwords without connecting, so validatePassword just accepts a
|
|
320
|
+
// non-empty entry; a wrong password surfaces as an auth error after run.
|
|
321
|
+
const runOverlay = (): Promise<OverlayResult> =>
|
|
322
|
+
withAgentBlock(pi.events, "ssh_run", "SSH approval required", async () => {
|
|
323
|
+
if (yolo && promptFor.length === 0) {
|
|
324
|
+
return { action: "approved", password: "" } as OverlayResult;
|
|
325
|
+
}
|
|
326
|
+
// Confirm-only when no password is missing.
|
|
327
|
+
if (promptFor.length === 0) {
|
|
328
|
+
return showOverlay(ctx.ui, {
|
|
329
|
+
mode: "confirm",
|
|
330
|
+
title: "🔐 SSH COMMAND REQUEST",
|
|
331
|
+
body,
|
|
332
|
+
accent: sudo ? "error" : "accent",
|
|
333
|
+
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
334
|
+
choices: [
|
|
335
|
+
{ value: "yes", label: "Allow", description: "Run the command" },
|
|
336
|
+
{ value: "no", label: "Deny", description: "Block the command" },
|
|
337
|
+
],
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
// One masked prompt per missing password, in order.
|
|
341
|
+
let last: OverlayResult = { action: "approved", password: "" };
|
|
342
|
+
for (const stage of promptFor) {
|
|
343
|
+
const label = stage === "login" ? "SSH login password" : "Remote sudo password";
|
|
344
|
+
last = await showOverlay(ctx.ui, {
|
|
345
|
+
mode: "sudo",
|
|
346
|
+
title: "🔐 SSH COMMAND REQUEST",
|
|
347
|
+
body: [...body, `Enter: ${label}`],
|
|
348
|
+
accent: sudo ? "error" : "accent",
|
|
349
|
+
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
350
|
+
maxPasswordAttempts: MAX_PASSWORD_ATTEMPTS,
|
|
351
|
+
passwordLabel: `${label}:`,
|
|
352
|
+
validatePassword: (pw) => Promise.resolve(pw.trim().length > 0),
|
|
353
|
+
choices: [
|
|
354
|
+
{
|
|
355
|
+
value: "yes",
|
|
356
|
+
label: "Allow",
|
|
357
|
+
description: `Enter ${label.toLowerCase()}`,
|
|
358
|
+
},
|
|
359
|
+
{ value: "no", label: "Deny", description: "Block the command" },
|
|
360
|
+
],
|
|
361
|
+
});
|
|
362
|
+
if (last.action !== "approved" || !last.password?.trim()) return last;
|
|
363
|
+
if (stage === "login") collected.login = last.password;
|
|
364
|
+
else collected.sudo = last.password;
|
|
365
|
+
}
|
|
366
|
+
return last;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const overlayResult = await runOverlay();
|
|
370
|
+
const missing =
|
|
371
|
+
overlayResult.action === "approved" &&
|
|
372
|
+
((needLogin && !collected.login) || (needSudo && !collected.sudo));
|
|
373
|
+
if (overlayResult.action !== "approved" || missing) {
|
|
374
|
+
const r = cancelResult(command, host, sudo, reason, overlayResult.action);
|
|
375
|
+
ctx.ui.notify(`🔐 ${r.content[0]?.text}`, "warning");
|
|
376
|
+
return r;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Persist newly-entered passwords in the session cache.
|
|
380
|
+
const loginPassword = creds.loginPassword ?? collected.login;
|
|
381
|
+
const sudoPassword = creds.sudoPassword ?? collected.sudo;
|
|
382
|
+
credCache.set(key, {
|
|
383
|
+
...(loginPassword ? { loginPassword } : {}),
|
|
384
|
+
...(sudoPassword ? { sudoPassword } : {}),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
updatePresentation(onUpdate, command, host, sudo, reason, "running");
|
|
388
|
+
|
|
389
|
+
let result: { stdout: string; stderr: string; code: number } | undefined;
|
|
390
|
+
try {
|
|
391
|
+
result = await runSsh(spec, command, {
|
|
392
|
+
controlPath,
|
|
393
|
+
...(loginPassword ? { loginPassword } : {}),
|
|
394
|
+
sudo,
|
|
395
|
+
...(sudo ? { sudoPassword: sudoPassword ?? "" } : {}),
|
|
396
|
+
...(sig ? { signal: sig } : {}),
|
|
397
|
+
});
|
|
398
|
+
} catch (err) {
|
|
399
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
400
|
+
return {
|
|
401
|
+
content: [{ type: "text", text: `ssh_run failed: ${msg}` }],
|
|
402
|
+
details: makeDetails(
|
|
403
|
+
command,
|
|
404
|
+
host,
|
|
405
|
+
sudo,
|
|
406
|
+
reason,
|
|
407
|
+
sig?.aborted
|
|
408
|
+
? { outcome: "cancelled", cancellationKind: "aborted" }
|
|
409
|
+
: { outcome: "error", errorKind: "execution" },
|
|
410
|
+
),
|
|
411
|
+
isError: sig?.aborted !== true,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!result) {
|
|
416
|
+
return {
|
|
417
|
+
content: [{ type: "text", text: "ssh_run failed: command produced no result" }],
|
|
418
|
+
details: makeDetails(command, host, sudo, reason, {
|
|
419
|
+
outcome: "error",
|
|
420
|
+
errorKind: "no-result",
|
|
421
|
+
}),
|
|
422
|
+
isError: true,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// SSH auth failure → drop the bad login password from the cache so the
|
|
427
|
+
// next call re-prompts.
|
|
428
|
+
if (detectSshFailure(result.code, result.stderr)) {
|
|
429
|
+
credCache.delete(key);
|
|
430
|
+
ctx.ui.notify("🔐 SSH authentication failed", "error");
|
|
431
|
+
return {
|
|
432
|
+
content: [{ type: "text", text: `SSH authentication failed:\n${result.stderr}` }],
|
|
433
|
+
details: makeDetails(command, host, sudo, reason, {
|
|
434
|
+
outcome: "error",
|
|
435
|
+
exitCode: result.code,
|
|
436
|
+
lineCount: outputLineCount(result.stderr),
|
|
437
|
+
errorKind: "auth-ssh",
|
|
438
|
+
_render: normalizeLineEndings(result.stderr),
|
|
439
|
+
}),
|
|
440
|
+
isError: true,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Remote sudo password failure → drop the bad sudo password.
|
|
445
|
+
if (sudo && detectSudoFailure(result.stderr)) {
|
|
446
|
+
credCache.set(key, loginPassword ? { loginPassword } : {});
|
|
447
|
+
ctx.ui.notify("🔐 Remote sudo authentication failed", "error");
|
|
448
|
+
return {
|
|
449
|
+
content: [{ type: "text", text: `Remote sudo authentication failed:\n${result.stderr}` }],
|
|
450
|
+
details: makeDetails(command, host, sudo, reason, {
|
|
451
|
+
outcome: "error",
|
|
452
|
+
exitCode: result.code,
|
|
453
|
+
lineCount: outputLineCount(result.stderr),
|
|
454
|
+
errorKind: "auth-sudo",
|
|
455
|
+
_render: normalizeLineEndings(result.stderr),
|
|
456
|
+
}),
|
|
457
|
+
isError: true,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const combined = [result.stdout, result.stderr].filter(Boolean).join("\n") || "(no output)";
|
|
462
|
+
const { text: truncatedText, truncated } = truncate(combined);
|
|
463
|
+
const suffix = truncated
|
|
464
|
+
? `\n\n[Output truncated to ${MAX_OUTPUT_LINES} lines / ${MAX_OUTPUT_BYTES / 1024}KB]`
|
|
465
|
+
: "";
|
|
466
|
+
const rendered = normalizeLineEndings(combined)
|
|
467
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
468
|
+
.replace(/^\n+|\n+$/g, "");
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
content: [{ type: "text", text: `Exit code: ${result.code}\n\n${truncatedText}${suffix}` }],
|
|
472
|
+
details: makeDetails(command, host, sudo, reason, {
|
|
473
|
+
outcome: result.code === 0 ? "success" : "error",
|
|
474
|
+
exitCode: result.code,
|
|
475
|
+
lineCount: outputLineCount(rendered),
|
|
476
|
+
truncated,
|
|
477
|
+
...(result.code === 0 ? {} : { errorKind: "exit-code" as const }),
|
|
478
|
+
_render: rendered,
|
|
479
|
+
}),
|
|
480
|
+
isError: result.code !== 0,
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
renderCall: ((
|
|
485
|
+
args: { command: string; host: string; sudo?: boolean; reason?: string },
|
|
486
|
+
theme: ThemeLike,
|
|
487
|
+
renderCtx: RenderContextLike,
|
|
488
|
+
) => {
|
|
489
|
+
resolveBaseBackground(theme);
|
|
490
|
+
const text = renderCtx.lastComponent ?? new Text("", 0, 0);
|
|
491
|
+
if (
|
|
492
|
+
hideCollapsedToolCall(renderCtx.state as CollapseState, renderCtx.expanded, (value) =>
|
|
493
|
+
text.setText(value),
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
return text;
|
|
497
|
+
|
|
498
|
+
const command = safeOneLine(args.command) || "(empty command)";
|
|
499
|
+
const host = safeOneLine(args.host);
|
|
500
|
+
const prefix = args.sudo ? "sudo " : "";
|
|
501
|
+
text.setText(
|
|
502
|
+
fillToolBackground(
|
|
503
|
+
`${theme.fg("toolTitle", theme.bold("ssh"))} ${theme.fg("dim", host)} ${theme.fg("accent", prefix + command)}`,
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
return text;
|
|
507
|
+
}) as never,
|
|
508
|
+
|
|
509
|
+
renderResult: ((
|
|
510
|
+
result: ToolResultLike,
|
|
511
|
+
_opt: unknown,
|
|
512
|
+
theme: ThemeLike,
|
|
513
|
+
renderCtx: RenderContextLike,
|
|
514
|
+
) => {
|
|
515
|
+
resolveBaseBackground(theme);
|
|
516
|
+
const text = renderCtx.lastComponent ?? new Text("", 0, 0);
|
|
517
|
+
const details = result.details as SshResultDetails | undefined;
|
|
518
|
+
|
|
519
|
+
if (details?._type !== "sshResult") {
|
|
520
|
+
if (renderCtx.isError) {
|
|
521
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
522
|
+
} else {
|
|
523
|
+
text.setText(
|
|
524
|
+
fillToolBackground(` ${theme.fg("dim", getTextContent(result) || "done")}`),
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
return text;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (
|
|
531
|
+
isTerminal(details) &&
|
|
532
|
+
tickCollapse(
|
|
533
|
+
"ssh",
|
|
534
|
+
renderCtx.state as CollapseState,
|
|
535
|
+
renderCtx.invalidate,
|
|
536
|
+
renderCtx.expanded,
|
|
537
|
+
)
|
|
538
|
+
) {
|
|
539
|
+
const status =
|
|
540
|
+
details.outcome === "success"
|
|
541
|
+
? "success"
|
|
542
|
+
: details.outcome === "error"
|
|
543
|
+
? "error"
|
|
544
|
+
: "warning";
|
|
545
|
+
text.setText(
|
|
546
|
+
renderCollapsedToolRow(
|
|
547
|
+
theme,
|
|
548
|
+
"ssh",
|
|
549
|
+
`${details.host} ${safeOneLine(details.command)}`,
|
|
550
|
+
terminalMeta(details),
|
|
551
|
+
status,
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
return text;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (details.outcome === "awaiting-approval" || details.outcome === "running") {
|
|
558
|
+
text.setText(
|
|
559
|
+
fillToolBackground(` ${theme.fg("dim", getTextContent(result) || "working")}`),
|
|
560
|
+
);
|
|
561
|
+
return text;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (details.outcome !== "success" && details.errorKind !== "exit-code") {
|
|
565
|
+
const diagnostic = getTextContent(result) || "Error";
|
|
566
|
+
text.setText(
|
|
567
|
+
details.outcome === "error"
|
|
568
|
+
? renderToolError(diagnostic, theme)
|
|
569
|
+
: fillToolBackground(` ${theme.fg("warning", diagnostic)}`),
|
|
570
|
+
);
|
|
571
|
+
return text;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const code = typeof details.exitCode === "number" ? details.exitCode : null;
|
|
575
|
+
const rendered = typeof details._render === "string" ? details._render : "";
|
|
576
|
+
const { summary } = renderBashOutput(rendered, code, theme);
|
|
577
|
+
const lines = rendered ? rendered.split("\n") : [];
|
|
578
|
+
const lineCount = lines.length;
|
|
579
|
+
|
|
580
|
+
if (!rendered) {
|
|
581
|
+
text.setText(fillToolBackground(` ${summary}`));
|
|
582
|
+
return text;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const maxShow = renderCtx.expanded ? lineCount : MAX_PREVIEW_LINES;
|
|
586
|
+
const show = lines.slice(0, maxShow);
|
|
587
|
+
const footer =
|
|
588
|
+
lineCount > maxShow ? [`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`] : [];
|
|
589
|
+
const statusKey = code === null ? "dim" : code === 0 ? "success" : "error";
|
|
590
|
+
const paint = (s: string) => theme.fg(statusKey, s);
|
|
591
|
+
const out = ruleFrame(
|
|
592
|
+
show.map((line) => ` ${line}`),
|
|
593
|
+
footer,
|
|
594
|
+
termW(),
|
|
595
|
+
paint,
|
|
596
|
+
);
|
|
597
|
+
text.setText(fillToolBackground(out.join("\n")));
|
|
598
|
+
return text;
|
|
599
|
+
}) as never,
|
|
600
|
+
});
|
|
601
|
+
}
|
package/src/lib.test.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
baseSshArgs,
|
|
4
|
+
controlPathFor,
|
|
5
|
+
detectSshFailure,
|
|
6
|
+
detectSudoFailure,
|
|
7
|
+
filterSudoPrompt,
|
|
8
|
+
hostTarget,
|
|
9
|
+
isUnreachable,
|
|
10
|
+
parseHost,
|
|
11
|
+
remoteCommand,
|
|
12
|
+
shellQuote,
|
|
13
|
+
truncate,
|
|
14
|
+
} from "./lib.ts";
|
|
15
|
+
|
|
16
|
+
describe("parseHost", () => {
|
|
17
|
+
it("parses bare host", () => {
|
|
18
|
+
expect(parseHost("example.com")).toEqual({ host: "example.com" });
|
|
19
|
+
});
|
|
20
|
+
it("parses user@host", () => {
|
|
21
|
+
expect(parseHost("deploy@10.0.0.5")).toEqual({ user: "deploy", host: "10.0.0.5" });
|
|
22
|
+
});
|
|
23
|
+
it("parses user@host:port", () => {
|
|
24
|
+
expect(parseHost("deploy@10.0.0.5:2222")).toEqual({
|
|
25
|
+
user: "deploy",
|
|
26
|
+
host: "10.0.0.5",
|
|
27
|
+
port: 2222,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
it("parses bracketed IPv6 with port", () => {
|
|
31
|
+
expect(parseHost("root@[::1]:22")).toEqual({ user: "root", host: "::1", port: 22 });
|
|
32
|
+
});
|
|
33
|
+
it("leaves bare IPv6 (multiple colons) as host", () => {
|
|
34
|
+
expect(parseHost("fe80::1")).toEqual({ host: "fe80::1" });
|
|
35
|
+
});
|
|
36
|
+
it("rejects empty host", () => {
|
|
37
|
+
expect(() => parseHost(" ")).toThrow();
|
|
38
|
+
expect(() => parseHost("user@")).toThrow();
|
|
39
|
+
});
|
|
40
|
+
it("rejects invalid port", () => {
|
|
41
|
+
expect(() => parseHost("h:0")).toThrow();
|
|
42
|
+
expect(() => parseHost("h:70000")).toThrow();
|
|
43
|
+
expect(() => parseHost("h:abc")).toThrow();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("hostTarget", () => {
|
|
48
|
+
it("joins user and host", () => {
|
|
49
|
+
expect(hostTarget({ user: "a", host: "b" })).toBe("a@b");
|
|
50
|
+
expect(hostTarget({ host: "b" })).toBe("b");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("controlPathFor", () => {
|
|
55
|
+
it("is stable per host+user+port and differs across hosts", () => {
|
|
56
|
+
const a = controlPathFor({ user: "u", host: "h", port: 22 });
|
|
57
|
+
expect(a).toBe(controlPathFor({ user: "u", host: "h", port: 22 }));
|
|
58
|
+
expect(a).not.toBe(controlPathFor({ user: "u", host: "h", port: 23 }));
|
|
59
|
+
expect(a).not.toBe(controlPathFor({ user: "x", host: "h", port: 22 }));
|
|
60
|
+
expect(a).toContain("pix-ssh-");
|
|
61
|
+
expect(a.endsWith(".sock")).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("baseSshArgs", () => {
|
|
66
|
+
it("enables multiplexing and includes the control path", () => {
|
|
67
|
+
const args = baseSshArgs({ host: "h" }, "/tmp/x.sock");
|
|
68
|
+
expect(args).toContain("ControlMaster=auto");
|
|
69
|
+
expect(args).toContain("ControlPath=/tmp/x.sock");
|
|
70
|
+
expect(args.some((a) => a.startsWith("ControlPersist="))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
it("adds -p only when a port is set", () => {
|
|
73
|
+
expect(baseSshArgs({ host: "h" }, "s")).not.toContain("-p");
|
|
74
|
+
const withPort = baseSshArgs({ host: "h", port: 2222 }, "s");
|
|
75
|
+
expect(withPort).toContain("-p");
|
|
76
|
+
expect(withPort).toContain("2222");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("remoteCommand + shellQuote", () => {
|
|
81
|
+
it("returns the command unchanged without sudo", () => {
|
|
82
|
+
expect(remoteCommand("whoami", false)).toBe("whoami");
|
|
83
|
+
});
|
|
84
|
+
it("wraps in sudo -S with a quoted inner command", () => {
|
|
85
|
+
const out = remoteCommand("apt update", true);
|
|
86
|
+
expect(out).toStartWith("sudo -S -p '' -- sh -c ");
|
|
87
|
+
expect(out).toContain("'apt update'");
|
|
88
|
+
});
|
|
89
|
+
it("escapes embedded single quotes safely", () => {
|
|
90
|
+
expect(shellQuote("it's")).toBe(`'it'\\''s'`);
|
|
91
|
+
// The wrapped form has no unescaped quote that would break out of the string.
|
|
92
|
+
const wrapped = remoteCommand("echo 'hi'", true);
|
|
93
|
+
expect(wrapped).toContain(`'echo '\\''hi'\\'''`);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("detectSshFailure", () => {
|
|
98
|
+
it("is false on exit 0", () => {
|
|
99
|
+
expect(detectSshFailure(0, "permission denied")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
it("flags common ssh errors", () => {
|
|
102
|
+
expect(detectSshFailure(255, "Permission denied (publickey,password).")).toBe(true);
|
|
103
|
+
expect(detectSshFailure(255, "ssh: connect to host x: Connection refused")).toBe(true);
|
|
104
|
+
expect(detectSshFailure(255, "Could not resolve hostname x")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
it("does not flag a normal nonzero command exit", () => {
|
|
107
|
+
expect(detectSshFailure(1, "ls: no such file")).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("isUnreachable", () => {
|
|
112
|
+
it("flags connection/DNS failures (not auth)", () => {
|
|
113
|
+
expect(isUnreachable("ssh: connect to host x port 22: Connection timed out")).toBe(true);
|
|
114
|
+
expect(isUnreachable("ssh: connect to host x port 22: Connection refused")).toBe(true);
|
|
115
|
+
expect(isUnreachable("ssh: Could not resolve hostname x")).toBe(true);
|
|
116
|
+
expect(isUnreachable("ssh: connect to host x: No route to host")).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
it("does NOT flag a password rejection (that's an auth failure)", () => {
|
|
119
|
+
expect(isUnreachable("Permission denied, please try again.")).toBe(false);
|
|
120
|
+
expect(isUnreachable("Permission denied (publickey,password).")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("detectSudoFailure", () => {
|
|
125
|
+
it("flags wrong sudo password", () => {
|
|
126
|
+
expect(detectSudoFailure("Sorry, try again.")).toBe(true);
|
|
127
|
+
expect(detectSudoFailure("sudo: 1 incorrect password attempt")).toBe(true);
|
|
128
|
+
expect(detectSudoFailure("sudo: a password is required")).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
it("ignores unrelated stderr", () => {
|
|
131
|
+
expect(detectSudoFailure("warning: something")).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("filterSudoPrompt", () => {
|
|
136
|
+
it("strips bare password prompt lines", () => {
|
|
137
|
+
expect(filterSudoPrompt("[sudo] password for u:\nreal output")).toBe("real output");
|
|
138
|
+
expect(filterSudoPrompt("Password:\nx")).toBe("x");
|
|
139
|
+
});
|
|
140
|
+
it("keeps normal lines", () => {
|
|
141
|
+
expect(filterSudoPrompt("line one\nline two")).toBe("line one\nline two");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("truncate", () => {
|
|
146
|
+
it("passes short text through", () => {
|
|
147
|
+
expect(truncate("a\nb").truncated).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
it("caps by line count", () => {
|
|
150
|
+
const many = Array.from({ length: 3000 }, (_, i) => `l${i}`).join("\n");
|
|
151
|
+
const out = truncate(many, 2000);
|
|
152
|
+
expect(out.truncated).toBe(true);
|
|
153
|
+
expect(out.text.split("\n").length).toBe(2000);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure / side-effect-free helpers for pix-ssh, extracted so they can be
|
|
3
|
+
* unit-tested without opening real SSH connections or loading the Pi host.
|
|
4
|
+
*
|
|
5
|
+
* Security model:
|
|
6
|
+
* - SSH login password (when key auth fails) is fed to `sshpass -e` via the
|
|
7
|
+
* SSHPASS env var of the child only — never as an argv (no `ps` leak),
|
|
8
|
+
* never written to disk.
|
|
9
|
+
* - Remote sudo password is piped to the remote `sudo -S -p ''` on stdin,
|
|
10
|
+
* so it travels inside the encrypted SSH channel, not as an argv.
|
|
11
|
+
* - ControlMaster multiplexing means one authenticated connection per host
|
|
12
|
+
* is reused by later calls (ControlPersist window), so the password is
|
|
13
|
+
* entered once per session per host.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
export const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
22
|
+
export const MAX_OUTPUT_LINES = 2000;
|
|
23
|
+
|
|
24
|
+
/** ControlPersist window (seconds) — the multiplexed connection lingers this
|
|
25
|
+
* long after the last call, so repeat commands skip re-auth. */
|
|
26
|
+
const CONTROL_PERSIST_SECONDS = 120;
|
|
27
|
+
const CONNECT_TIMEOUT_SECONDS = 10;
|
|
28
|
+
|
|
29
|
+
export interface HostSpec {
|
|
30
|
+
user?: string;
|
|
31
|
+
host: string;
|
|
32
|
+
port?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SshResult {
|
|
36
|
+
stdout: string;
|
|
37
|
+
stderr: string;
|
|
38
|
+
code: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Host parsing ─────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Parse `[user@]host[:port]` into parts. Throws on empty host. */
|
|
44
|
+
export function parseHost(spec: string): HostSpec {
|
|
45
|
+
const trimmed = spec.trim();
|
|
46
|
+
if (!trimmed) throw new Error("Empty host");
|
|
47
|
+
|
|
48
|
+
let user: string | undefined;
|
|
49
|
+
let rest = trimmed;
|
|
50
|
+
const at = rest.lastIndexOf("@");
|
|
51
|
+
if (at !== -1) {
|
|
52
|
+
user = rest.slice(0, at) || undefined;
|
|
53
|
+
rest = rest.slice(at + 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let port: number | undefined;
|
|
57
|
+
// IPv6 literals use brackets: [::1]:22 — only split a port off the tail
|
|
58
|
+
// when there is exactly one colon (plain host:port), leaving bare IPv6 alone.
|
|
59
|
+
const colons = rest.split(":").length - 1;
|
|
60
|
+
if (rest.startsWith("[")) {
|
|
61
|
+
const end = rest.indexOf("]");
|
|
62
|
+
const hostPart = rest.slice(1, end);
|
|
63
|
+
const tail = rest.slice(end + 1);
|
|
64
|
+
if (tail.startsWith(":")) port = parsePort(tail.slice(1));
|
|
65
|
+
rest = hostPart;
|
|
66
|
+
} else if (colons === 1) {
|
|
67
|
+
const [h, p] = rest.split(":");
|
|
68
|
+
rest = h ?? "";
|
|
69
|
+
port = parsePort(p ?? "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!rest) throw new Error(`Invalid host: ${spec}`);
|
|
73
|
+
return { user, host: rest, ...(port !== undefined ? { port } : {}) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parsePort(value: string): number {
|
|
77
|
+
const n = Number.parseInt(value, 10);
|
|
78
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
79
|
+
throw new Error(`Invalid port: ${value}`);
|
|
80
|
+
}
|
|
81
|
+
return n;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Canonical `[user@]host` target string for ssh argv. */
|
|
85
|
+
export function hostTarget(spec: HostSpec): string {
|
|
86
|
+
return spec.user ? `${spec.user}@${spec.host}` : spec.host;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Stable per-host ControlMaster socket path (survives across calls in a
|
|
90
|
+
* session so multiplexing can reuse the connection). */
|
|
91
|
+
export function controlPathFor(spec: HostSpec): string {
|
|
92
|
+
const key = `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
93
|
+
const hash = createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
94
|
+
return join(tmpdir(), `pix-ssh-${hash}.sock`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── ssh argv construction ────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/** Base ssh options shared by every invocation: multiplexing + timeouts +
|
|
100
|
+
* non-interactive prompts (BatchMode is toggled by the caller). */
|
|
101
|
+
export function baseSshArgs(spec: HostSpec, controlPath: string): string[] {
|
|
102
|
+
const args = [
|
|
103
|
+
"-o",
|
|
104
|
+
"ControlMaster=auto",
|
|
105
|
+
"-o",
|
|
106
|
+
`ControlPath=${controlPath}`,
|
|
107
|
+
"-o",
|
|
108
|
+
`ControlPersist=${CONTROL_PERSIST_SECONDS}`,
|
|
109
|
+
"-o",
|
|
110
|
+
`ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}`,
|
|
111
|
+
"-o",
|
|
112
|
+
"StrictHostKeyChecking=accept-new",
|
|
113
|
+
];
|
|
114
|
+
if (spec.port !== undefined) args.push("-p", String(spec.port));
|
|
115
|
+
return args;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Wrap a command for optional remote sudo. `sudo -S -p ''` reads the sudo
|
|
119
|
+
* password from stdin with no prompt echo; the command runs under `sh -c`. */
|
|
120
|
+
export function remoteCommand(command: string, sudo: boolean): string {
|
|
121
|
+
if (!sudo) return command;
|
|
122
|
+
return `sudo -S -p '' -- sh -c ${shellQuote(command)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Single-quote a string for POSIX sh (wrap in quotes, escape embedded quotes). */
|
|
126
|
+
export function shellQuote(value: string): string {
|
|
127
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Auth-failure detection ───────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/** SSH-level auth/connection failure (wrong login password, refused, etc.). */
|
|
133
|
+
export function detectSshFailure(code: number, stderr: string): boolean {
|
|
134
|
+
if (code === 0) return false;
|
|
135
|
+
const lower = stderr.toLowerCase();
|
|
136
|
+
return (
|
|
137
|
+
lower.includes("permission denied") ||
|
|
138
|
+
lower.includes("connection refused") ||
|
|
139
|
+
lower.includes("connection timed out") ||
|
|
140
|
+
lower.includes("could not resolve hostname") ||
|
|
141
|
+
lower.includes("no route to host") ||
|
|
142
|
+
lower.includes("host key verification failed")
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Remote sudo password failure. */
|
|
147
|
+
export function detectSudoFailure(stderr: string): boolean {
|
|
148
|
+
const lower = stderr.toLowerCase();
|
|
149
|
+
return (
|
|
150
|
+
lower.includes("incorrect password") ||
|
|
151
|
+
lower.includes("sudo: a password is required") ||
|
|
152
|
+
lower.includes("authentication failure") ||
|
|
153
|
+
lower.includes("sorry, try again")
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Strip the remote sudo prompt lines from stderr (we pass `-p ''` but some
|
|
158
|
+
* sudo builds still emit a newline or prompt fragment). */
|
|
159
|
+
export function filterSudoPrompt(raw: string): string {
|
|
160
|
+
return raw
|
|
161
|
+
.split("\n")
|
|
162
|
+
.filter((l) => !/^\s*(\[sudo\] )?password( for .*)?:?\s*$/i.test(l))
|
|
163
|
+
.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Output truncation ────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export function truncate(
|
|
169
|
+
text: string,
|
|
170
|
+
maxLines = MAX_OUTPUT_LINES,
|
|
171
|
+
maxBytes = MAX_OUTPUT_BYTES,
|
|
172
|
+
): { text: string; truncated: boolean } {
|
|
173
|
+
const lines = text.split("\n");
|
|
174
|
+
const byteLen = Buffer.byteLength(text, "utf8");
|
|
175
|
+
if (lines.length <= maxLines && byteLen <= maxBytes) {
|
|
176
|
+
return { text, truncated: false };
|
|
177
|
+
}
|
|
178
|
+
const kept = lines.slice(0, maxLines);
|
|
179
|
+
let result = kept.join("\n");
|
|
180
|
+
if (Buffer.byteLength(result, "utf8") > maxBytes) {
|
|
181
|
+
result = Buffer.from(result, "utf8").slice(0, maxBytes).toString("utf8");
|
|
182
|
+
}
|
|
183
|
+
return { text: result, truncated: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Runners ──────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/** Outcome of the key-auth probe. `ok` = connected without a password;
|
|
189
|
+
* `auth` = reachable but needs a password; `unreachable` = host down / timeout
|
|
190
|
+
* / DNS — a password won't help, so don't prompt. */
|
|
191
|
+
export type ProbeResult = "ok" | "auth" | "unreachable";
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Probe key-based (or agent, or existing-master) auth with `BatchMode=yes` so
|
|
195
|
+
* ssh never prompts. Distinguishes three cases from the exit code + stderr:
|
|
196
|
+
* clean connect (ok), auth rejected but host reachable (auth), and
|
|
197
|
+
* connection/DNS failure (unreachable) — the last must NOT trigger a password
|
|
198
|
+
* prompt, since a login password can't fix an unreachable host.
|
|
199
|
+
*/
|
|
200
|
+
export function probeKeyAuth(
|
|
201
|
+
spec: HostSpec,
|
|
202
|
+
controlPath: string,
|
|
203
|
+
signal?: AbortSignal,
|
|
204
|
+
): Promise<ProbeResult> {
|
|
205
|
+
const args = [...baseSshArgs(spec, controlPath), "-o", "BatchMode=yes", hostTarget(spec), "true"];
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
let stderr = "";
|
|
208
|
+
const proc = spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
209
|
+
proc.stderr.on("data", (c: Buffer) => {
|
|
210
|
+
stderr += c.toString();
|
|
211
|
+
});
|
|
212
|
+
proc.on("error", () => resolve("unreachable"));
|
|
213
|
+
proc.on("close", (code) => {
|
|
214
|
+
if (code === 0) return resolve("ok");
|
|
215
|
+
if (isUnreachable(stderr)) return resolve("unreachable");
|
|
216
|
+
resolve("auth");
|
|
217
|
+
});
|
|
218
|
+
signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Connection/DNS-level failure (not an auth rejection) — a password can't fix it. */
|
|
223
|
+
export function isUnreachable(stderr: string): boolean {
|
|
224
|
+
const lower = stderr.toLowerCase();
|
|
225
|
+
return (
|
|
226
|
+
lower.includes("connection timed out") ||
|
|
227
|
+
lower.includes("connection refused") ||
|
|
228
|
+
lower.includes("could not resolve hostname") ||
|
|
229
|
+
lower.includes("no route to host") ||
|
|
230
|
+
lower.includes("network is unreachable") ||
|
|
231
|
+
lower.includes("operation timed out")
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface RunOptions {
|
|
236
|
+
/** SSH login password (only used when key auth failed). Fed via SSHPASS env. */
|
|
237
|
+
loginPassword?: string;
|
|
238
|
+
/** Run the remote command under sudo -S. */
|
|
239
|
+
sudo?: boolean;
|
|
240
|
+
/** Remote sudo password, piped to sudo's stdin. */
|
|
241
|
+
sudoPassword?: string;
|
|
242
|
+
controlPath: string;
|
|
243
|
+
signal?: AbortSignal;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Run `command` on the remote host. When `loginPassword` is set, ssh is wrapped
|
|
248
|
+
* in `sshpass -e` (password via env, not argv). When `sudo` is set, the command
|
|
249
|
+
* is wrapped in `sudo -S` and `sudoPassword` is written to the remote stdin.
|
|
250
|
+
*/
|
|
251
|
+
export function runSsh(spec: HostSpec, command: string, opts: RunOptions): Promise<SshResult> {
|
|
252
|
+
const remote = remoteCommand(command, opts.sudo === true);
|
|
253
|
+
const sshArgs = [...baseSshArgs(spec, opts.controlPath), hostTarget(spec), remote];
|
|
254
|
+
|
|
255
|
+
let bin = "ssh";
|
|
256
|
+
let args = sshArgs;
|
|
257
|
+
let env = process.env;
|
|
258
|
+
if (opts.loginPassword) {
|
|
259
|
+
bin = "sshpass";
|
|
260
|
+
args = ["-e", "ssh", ...sshArgs];
|
|
261
|
+
env = { ...process.env, SSHPASS: opts.loginPassword };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
const proc = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
|
266
|
+
let stdout = "";
|
|
267
|
+
let stderr = "";
|
|
268
|
+
|
|
269
|
+
proc.stdout.on("data", (c: Buffer) => {
|
|
270
|
+
stdout += c.toString();
|
|
271
|
+
});
|
|
272
|
+
proc.stderr.on("data", (c: Buffer) => {
|
|
273
|
+
const filtered = opts.sudo ? filterSudoPrompt(c.toString()) : c.toString();
|
|
274
|
+
if (filtered) stderr += filtered;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
proc.on("error", reject);
|
|
278
|
+
proc.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 }));
|
|
279
|
+
|
|
280
|
+
// Remote sudo reads its password from stdin (first line); anything else
|
|
281
|
+
// closes stdin so the remote command sees EOF.
|
|
282
|
+
if (opts.sudo && opts.sudoPassword !== undefined) {
|
|
283
|
+
proc.stdin.write(`${opts.sudoPassword}\n`);
|
|
284
|
+
}
|
|
285
|
+
proc.stdin.end();
|
|
286
|
+
|
|
287
|
+
signal(opts.signal, proc, reject);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function signal(
|
|
292
|
+
sig: AbortSignal | undefined,
|
|
293
|
+
proc: ReturnType<typeof spawn>,
|
|
294
|
+
reject: (e: Error) => void,
|
|
295
|
+
): void {
|
|
296
|
+
sig?.addEventListener(
|
|
297
|
+
"abort",
|
|
298
|
+
() => {
|
|
299
|
+
proc.kill("SIGTERM");
|
|
300
|
+
reject(new Error("Cancelled"));
|
|
301
|
+
},
|
|
302
|
+
{ once: true },
|
|
303
|
+
);
|
|
304
|
+
}
|