@oh-my-pi/pi-coding-agent 3.32.0 → 3.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +49 -9
  2. package/README.md +12 -0
  3. package/docs/custom-tools.md +1 -1
  4. package/docs/extensions.md +4 -4
  5. package/docs/hooks.md +2 -2
  6. package/docs/sdk.md +4 -8
  7. package/examples/custom-tools/README.md +2 -2
  8. package/examples/extensions/README.md +1 -1
  9. package/examples/extensions/todo.ts +1 -1
  10. package/examples/hooks/custom-compaction.ts +4 -2
  11. package/examples/hooks/handoff.ts +1 -1
  12. package/examples/hooks/qna.ts +1 -1
  13. package/examples/sdk/02-custom-model.ts +1 -1
  14. package/examples/sdk/README.md +1 -1
  15. package/package.json +5 -5
  16. package/src/capability/ssh.ts +42 -0
  17. package/src/cli/file-processor.ts +1 -1
  18. package/src/cli/list-models.ts +1 -1
  19. package/src/core/agent-session.ts +21 -6
  20. package/src/core/auth-storage.ts +1 -1
  21. package/src/core/compaction/branch-summarization.ts +2 -2
  22. package/src/core/compaction/compaction.ts +2 -2
  23. package/src/core/compaction/utils.ts +1 -1
  24. package/src/core/custom-tools/types.ts +1 -1
  25. package/src/core/extensions/runner.ts +1 -1
  26. package/src/core/extensions/types.ts +1 -1
  27. package/src/core/extensions/wrapper.ts +1 -1
  28. package/src/core/file-mentions.ts +147 -5
  29. package/src/core/hooks/runner.ts +2 -2
  30. package/src/core/hooks/types.ts +1 -1
  31. package/src/core/index.ts +11 -0
  32. package/src/core/messages.ts +1 -1
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/model-resolver.ts +9 -4
  35. package/src/core/sdk.ts +26 -2
  36. package/src/core/session-manager.ts +3 -2
  37. package/src/core/settings-manager.ts +70 -0
  38. package/src/core/ssh/connection-manager.ts +466 -0
  39. package/src/core/ssh/ssh-executor.ts +190 -0
  40. package/src/core/ssh/sshfs-mount.ts +162 -0
  41. package/src/core/ssh-executor.ts +5 -0
  42. package/src/core/system-prompt.ts +424 -1
  43. package/src/core/title-generator.ts +109 -55
  44. package/src/core/tools/index.test.ts +1 -0
  45. package/src/core/tools/index.ts +3 -0
  46. package/src/core/tools/output.ts +37 -2
  47. package/src/core/tools/read.ts +24 -11
  48. package/src/core/tools/renderers.ts +2 -0
  49. package/src/core/tools/ssh.ts +302 -0
  50. package/src/core/tools/task/index.ts +1 -1
  51. package/src/core/tools/task/render.ts +10 -16
  52. package/src/core/tools/task/types.ts +1 -1
  53. package/src/core/tools/task/worker.ts +1 -1
  54. package/src/core/voice.ts +1 -1
  55. package/src/discovery/index.ts +3 -0
  56. package/src/discovery/ssh.ts +162 -0
  57. package/src/main.ts +2 -1
  58. package/src/modes/interactive/components/assistant-message.ts +1 -1
  59. package/src/modes/interactive/components/bash-execution.ts +9 -10
  60. package/src/modes/interactive/components/custom-message.ts +1 -1
  61. package/src/modes/interactive/components/footer.ts +1 -1
  62. package/src/modes/interactive/components/hook-message.ts +1 -1
  63. package/src/modes/interactive/components/model-selector.ts +1 -1
  64. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  65. package/src/modes/interactive/components/status-line.ts +1 -1
  66. package/src/modes/interactive/components/tree-selector.ts +9 -12
  67. package/src/modes/interactive/interactive-mode.ts +5 -2
  68. package/src/modes/interactive/theme/theme.ts +2 -2
  69. package/src/modes/print-mode.ts +1 -1
  70. package/src/modes/rpc/rpc-client.ts +1 -1
  71. package/src/modes/rpc/rpc-types.ts +1 -1
  72. package/src/prompts/system-prompt.md +4 -0
  73. package/src/prompts/tools/ssh.md +74 -0
  74. package/src/utils/image-resize.ts +1 -1
@@ -0,0 +1,466 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CONFIG_DIR_NAME } from "../../config";
5
+ import { logger } from "../logger";
6
+
7
+ export interface SSHConnectionTarget {
8
+ name: string;
9
+ host: string;
10
+ username?: string;
11
+ port?: number;
12
+ keyPath?: string;
13
+ compat?: boolean;
14
+ }
15
+
16
+ export type SSHHostOs = "windows" | "linux" | "macos" | "unknown";
17
+ export type SSHHostShell = "cmd" | "powershell" | "bash" | "zsh" | "sh" | "unknown";
18
+
19
+ export interface SSHHostInfo {
20
+ version: number;
21
+ os: SSHHostOs;
22
+ shell: SSHHostShell;
23
+ compatShell?: "bash" | "sh";
24
+ compatEnabled: boolean;
25
+ }
26
+
27
+ const CONTROL_DIR = join(homedir(), CONFIG_DIR_NAME, "ssh-control");
28
+ const CONTROL_PATH = join(CONTROL_DIR, "%h.sock");
29
+ const HOST_INFO_DIR = join(homedir(), CONFIG_DIR_NAME, "remote-host");
30
+ const HOST_INFO_VERSION = 2;
31
+
32
+ const activeHosts = new Map<string, SSHConnectionTarget>();
33
+ const pendingConnections = new Map<string, Promise<void>>();
34
+ const hostInfoCache = new Map<string, SSHHostInfo>();
35
+
36
+ function ensureControlDir(): void {
37
+ if (!existsSync(CONTROL_DIR)) {
38
+ mkdirSync(CONTROL_DIR, { recursive: true, mode: 0o700 });
39
+ }
40
+ try {
41
+ chmodSync(CONTROL_DIR, 0o700);
42
+ } catch (err) {
43
+ logger.debug("SSH control dir chmod failed", { path: CONTROL_DIR, error: String(err) });
44
+ }
45
+ }
46
+
47
+ function ensureHostInfoDir(): void {
48
+ if (!existsSync(HOST_INFO_DIR)) {
49
+ mkdirSync(HOST_INFO_DIR, { recursive: true, mode: 0o700 });
50
+ }
51
+ try {
52
+ chmodSync(HOST_INFO_DIR, 0o700);
53
+ } catch (err) {
54
+ logger.debug("SSH host info dir chmod failed", { path: HOST_INFO_DIR, error: String(err) });
55
+ }
56
+ }
57
+
58
+ function sanitizeHostName(name: string): string {
59
+ const sanitized = name.replace(/[^a-zA-Z0-9._-]+/g, "_");
60
+ return sanitized.length > 0 ? sanitized : "host";
61
+ }
62
+
63
+ function getHostInfoPath(name: string): string {
64
+ return join(HOST_INFO_DIR, `${sanitizeHostName(name)}.json`);
65
+ }
66
+
67
+ function validateKeyPermissions(keyPath?: string): void {
68
+ if (!keyPath) return;
69
+ if (!existsSync(keyPath)) {
70
+ throw new Error(`SSH key not found: ${keyPath}`);
71
+ }
72
+ const stats = statSync(keyPath);
73
+ if (!stats.isFile()) {
74
+ throw new Error(`SSH key is not a file: ${keyPath}`);
75
+ }
76
+ const mode = stats.mode & 0o777;
77
+ if ((mode & 0o077) !== 0) {
78
+ throw new Error(`SSH key permissions must be 600 or stricter: ${keyPath}`);
79
+ }
80
+ }
81
+
82
+ function buildSshTarget(host: SSHConnectionTarget): string {
83
+ return host.username ? `${host.username}@${host.host}` : host.host;
84
+ }
85
+
86
+ function buildCommonArgs(host: SSHConnectionTarget): string[] {
87
+ const args = [
88
+ "-o",
89
+ "ControlMaster=auto",
90
+ "-o",
91
+ `ControlPath=${CONTROL_PATH}`,
92
+ "-o",
93
+ "ControlPersist=3600",
94
+ "-o",
95
+ "BatchMode=yes",
96
+ "-o",
97
+ "StrictHostKeyChecking=accept-new",
98
+ ];
99
+
100
+ if (host.port) {
101
+ args.push("-p", String(host.port));
102
+ }
103
+ if (host.keyPath) {
104
+ args.push("-i", host.keyPath);
105
+ }
106
+
107
+ return args;
108
+ }
109
+
110
+ function decodeOutput(buffer?: Uint8Array): string {
111
+ if (!buffer || buffer.length === 0) return "";
112
+ return new TextDecoder().decode(buffer).trim();
113
+ }
114
+
115
+ function runSshSync(args: string[]): { exitCode: number | null; stderr: string } {
116
+ const result = Bun.spawnSync(["ssh", ...args], {
117
+ stdin: "ignore",
118
+ stdout: "ignore",
119
+ stderr: "pipe",
120
+ });
121
+
122
+ return { exitCode: result.exitCode, stderr: decodeOutput(result.stderr) };
123
+ }
124
+
125
+ function runSshCaptureSync(args: string[]): { exitCode: number | null; stdout: string; stderr: string } {
126
+ const result = Bun.spawnSync(["ssh", ...args], {
127
+ stdin: "ignore",
128
+ stdout: "pipe",
129
+ stderr: "pipe",
130
+ });
131
+
132
+ return {
133
+ exitCode: result.exitCode,
134
+ stdout: decodeOutput(result.stdout),
135
+ stderr: decodeOutput(result.stderr),
136
+ };
137
+ }
138
+
139
+ function ensureSshBinary(): void {
140
+ if (!Bun.which("ssh")) {
141
+ throw new Error("ssh binary not found on PATH");
142
+ }
143
+ }
144
+
145
+ function parseOs(value: unknown): SSHHostOs | null {
146
+ if (typeof value !== "string") return null;
147
+ const normalized = value.trim().toLowerCase();
148
+ switch (normalized) {
149
+ case "windows":
150
+ return "windows";
151
+ case "linux":
152
+ return "linux";
153
+ case "macos":
154
+ case "darwin":
155
+ return "macos";
156
+ case "unknown":
157
+ return "unknown";
158
+ default:
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function parseShell(value: unknown): SSHHostShell | null {
164
+ if (typeof value !== "string") return null;
165
+ const normalized = value.trim().toLowerCase();
166
+ if (!normalized) return "unknown";
167
+ if (normalized.includes("bash")) return "bash";
168
+ if (normalized.includes("zsh")) return "zsh";
169
+ if (normalized.includes("pwsh") || normalized.includes("powershell")) return "powershell";
170
+ if (normalized.includes("cmd.exe") || normalized === "cmd") return "cmd";
171
+ if (normalized.endsWith("sh") || normalized.includes("/sh")) return "sh";
172
+ return "unknown";
173
+ }
174
+
175
+ function parseCompatShell(value: unknown): "bash" | "sh" | undefined {
176
+ if (value === "bash" || value === "sh") return value;
177
+ return undefined;
178
+ }
179
+
180
+ function applyCompatOverride(host: SSHConnectionTarget, info: SSHHostInfo): SSHHostInfo {
181
+ const compatShell =
182
+ info.compatShell ??
183
+ (info.os === "windows" && info.shell === "bash"
184
+ ? "bash"
185
+ : info.os === "windows" && info.shell === "sh"
186
+ ? "sh"
187
+ : undefined);
188
+ const compatEnabled = host.compat === false ? false : info.os === "windows" && compatShell !== undefined;
189
+ if (host.compat === true && !compatShell) {
190
+ logger.warn("SSH compat requested but no compatible shell detected", {
191
+ host: host.name,
192
+ shell: info.shell,
193
+ });
194
+ }
195
+ return { ...info, version: info.version ?? 0, compatShell, compatEnabled };
196
+ }
197
+
198
+ function parseHostInfo(value: unknown): SSHHostInfo | null {
199
+ if (!value || typeof value !== "object") return null;
200
+ const record = value as Record<string, unknown>;
201
+ const os = parseOs(record.os) ?? "unknown";
202
+ const shell = parseShell(record.shell) ?? "unknown";
203
+ const compatShell = parseCompatShell(record.compatShell);
204
+ const compatEnabled = typeof record.compatEnabled === "boolean" ? record.compatEnabled : false;
205
+ const version = typeof record.version === "number" ? record.version : 0;
206
+ return {
207
+ version,
208
+ os,
209
+ shell,
210
+ compatShell,
211
+ compatEnabled,
212
+ };
213
+ }
214
+
215
+ function shouldRefreshHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): boolean {
216
+ if (info.version !== HOST_INFO_VERSION) return true;
217
+ if (info.os === "unknown") return true;
218
+ if (info.os !== "windows" && info.compatEnabled) return true;
219
+ if (info.os === "windows" && info.compatEnabled && !info.compatShell) return true;
220
+ if (info.os === "windows" && info.compatShell === "bash" && info.shell === "unknown") return true;
221
+ if (host.compat === true && info.os === "windows" && !info.compatShell) return true;
222
+ return false;
223
+ }
224
+
225
+ function loadHostInfoFromDisk(host: SSHConnectionTarget): SSHHostInfo | undefined {
226
+ const path = getHostInfoPath(host.name);
227
+ if (!existsSync(path)) return undefined;
228
+ try {
229
+ const raw = readFileSync(path, "utf-8");
230
+ const parsed = parseHostInfo(JSON.parse(raw));
231
+ if (!parsed) return undefined;
232
+ const resolved = applyCompatOverride(host, parsed);
233
+ hostInfoCache.set(host.name, resolved);
234
+ return resolved;
235
+ } catch (err) {
236
+ logger.warn("Failed to load SSH host info", { host: host.name, error: String(err) });
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ function loadHostInfoFromDiskByName(hostName: string): SSHHostInfo | undefined {
242
+ const path = getHostInfoPath(hostName);
243
+ if (!existsSync(path)) return undefined;
244
+ try {
245
+ const raw = readFileSync(path, "utf-8");
246
+ const parsed = parseHostInfo(JSON.parse(raw));
247
+ if (!parsed) return undefined;
248
+ return parsed;
249
+ } catch (err) {
250
+ logger.warn("Failed to load SSH host info", { host: hostName, error: String(err) });
251
+ return undefined;
252
+ }
253
+ }
254
+
255
+ async function persistHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): Promise<void> {
256
+ try {
257
+ ensureHostInfoDir();
258
+ const path = getHostInfoPath(host.name);
259
+ const payload = { ...info, version: HOST_INFO_VERSION };
260
+ hostInfoCache.set(host.name, payload);
261
+ await Bun.write(path, JSON.stringify(payload, null, 2), { createPath: true });
262
+ } catch (err) {
263
+ logger.warn("Failed to persist SSH host info", { host: host.name, error: String(err) });
264
+ }
265
+ }
266
+
267
+ async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
268
+ const command = 'echo "$OSTYPE|$SHELL|$BASH_VERSION" 2>/dev/null || echo "%OS%|%COMSPEC%|"';
269
+ const result = runSshCaptureSync(buildRemoteCommand(host, command));
270
+ if (result.exitCode !== 0 && !result.stdout) {
271
+ logger.debug("SSH host probe failed", { host: host.name, error: result.stderr });
272
+ const fallback: SSHHostInfo = {
273
+ version: HOST_INFO_VERSION,
274
+ os: "unknown",
275
+ shell: "unknown",
276
+ compatShell: undefined,
277
+ compatEnabled: false,
278
+ };
279
+ hostInfoCache.set(host.name, fallback);
280
+ return fallback;
281
+ }
282
+
283
+ const output = (result.stdout || result.stderr).split("\n")[0]?.trim() ?? "";
284
+ const [rawOs = "", rawShell = "", rawBash = ""] = output.split("|");
285
+ const ostype = rawOs.trim();
286
+ const shellRaw = rawShell.trim();
287
+ const bashVersion = rawBash.trim();
288
+ const outputLower = output.toLowerCase();
289
+ const osLower = ostype.toLowerCase();
290
+ const shellLower = shellRaw.toLowerCase();
291
+ const unexpandedPosixVars =
292
+ output.includes("$OSTYPE") || output.includes("$SHELL") || output.includes("$BASH_VERSION");
293
+ const windowsDetected =
294
+ osLower.includes("windows") ||
295
+ osLower.includes("msys") ||
296
+ osLower.includes("cygwin") ||
297
+ osLower.includes("mingw") ||
298
+ outputLower.includes("windows_nt") ||
299
+ outputLower.includes("comspec") ||
300
+ shellLower.includes("cmd") ||
301
+ shellLower.includes("powershell") ||
302
+ unexpandedPosixVars ||
303
+ output.includes("%OS%");
304
+
305
+ let os: SSHHostOs = "unknown";
306
+ if (windowsDetected) {
307
+ os = "windows";
308
+ } else if (osLower.includes("darwin")) {
309
+ os = "macos";
310
+ } else if (osLower.includes("linux") || osLower.includes("gnu")) {
311
+ os = "linux";
312
+ }
313
+
314
+ let shell: SSHHostShell = "unknown";
315
+ if (shellLower.includes("bash")) {
316
+ shell = "bash";
317
+ } else if (shellLower.includes("zsh")) {
318
+ shell = "zsh";
319
+ } else if (shellLower.includes("pwsh") || shellLower.includes("powershell")) {
320
+ shell = "powershell";
321
+ } else if (shellLower.includes("cmd.exe") || shellLower === "cmd") {
322
+ shell = "cmd";
323
+ } else if (shellLower.endsWith("sh") || shellLower.includes("/sh")) {
324
+ shell = "sh";
325
+ } else if (os === "windows" && !shellLower) {
326
+ shell = "cmd";
327
+ }
328
+
329
+ const hasBash = !unexpandedPosixVars && (Boolean(bashVersion) || shell === "bash");
330
+ let compatShell: SSHHostInfo["compatShell"];
331
+ if (os === "windows" && host.compat !== false) {
332
+ const bashProbe = runSshCaptureSync(buildRemoteCommand(host, 'bash -lc "echo OMP_BASH_OK"'));
333
+ if (bashProbe.exitCode === 0 && bashProbe.stdout.includes("OMP_BASH_OK")) {
334
+ compatShell = "bash";
335
+ } else {
336
+ const shProbe = runSshCaptureSync(buildRemoteCommand(host, 'sh -lc "echo OMP_SH_OK"'));
337
+ if (shProbe.exitCode === 0 && shProbe.stdout.includes("OMP_SH_OK")) {
338
+ compatShell = "sh";
339
+ }
340
+ }
341
+ } else if (os === "windows" && hasBash) {
342
+ compatShell = "bash";
343
+ } else if (os === "windows" && shell === "sh") {
344
+ compatShell = "sh";
345
+ }
346
+ const compatEnabled = host.compat === false ? false : os === "windows" && compatShell !== undefined;
347
+
348
+ const info: SSHHostInfo = applyCompatOverride(host, {
349
+ version: HOST_INFO_VERSION,
350
+ os,
351
+ shell,
352
+ compatShell,
353
+ compatEnabled,
354
+ });
355
+
356
+ hostInfoCache.set(host.name, info);
357
+ await persistHostInfo(host, info);
358
+ return info;
359
+ }
360
+
361
+ export function getHostInfo(hostName: string): SSHHostInfo | undefined {
362
+ return hostInfoCache.get(hostName) ?? loadHostInfoFromDiskByName(hostName);
363
+ }
364
+
365
+ export function getHostInfoForHost(host: SSHConnectionTarget): SSHHostInfo | undefined {
366
+ const cached = hostInfoCache.get(host.name);
367
+ if (cached) {
368
+ const resolved = applyCompatOverride(host, cached);
369
+ if (resolved !== cached) hostInfoCache.set(host.name, resolved);
370
+ return resolved;
371
+ }
372
+ return loadHostInfoFromDisk(host);
373
+ }
374
+
375
+ export async function ensureHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
376
+ const cached = hostInfoCache.get(host.name);
377
+ if (cached) {
378
+ const resolved = applyCompatOverride(host, cached);
379
+ hostInfoCache.set(host.name, resolved);
380
+ if (!shouldRefreshHostInfo(host, resolved)) return resolved;
381
+ }
382
+ const fromDisk = loadHostInfoFromDisk(host);
383
+ if (fromDisk && !shouldRefreshHostInfo(host, fromDisk)) return fromDisk;
384
+ await ensureConnection(host);
385
+ const current = hostInfoCache.get(host.name);
386
+ if (current && !shouldRefreshHostInfo(host, current)) return current;
387
+ return probeHostInfo(host);
388
+ }
389
+
390
+ export function buildRemoteCommand(host: SSHConnectionTarget, command: string): string[] {
391
+ validateKeyPermissions(host.keyPath);
392
+ return [...buildCommonArgs(host), buildSshTarget(host), command];
393
+ }
394
+
395
+ export async function ensureConnection(host: SSHConnectionTarget): Promise<void> {
396
+ const key = host.name;
397
+ const pending = pendingConnections.get(key);
398
+ if (pending) {
399
+ await pending;
400
+ return;
401
+ }
402
+
403
+ const promise = (async () => {
404
+ ensureSshBinary();
405
+ ensureControlDir();
406
+ validateKeyPermissions(host.keyPath);
407
+
408
+ const target = buildSshTarget(host);
409
+ const check = runSshSync(["-O", "check", ...buildCommonArgs(host), target]);
410
+ if (check.exitCode === 0) {
411
+ activeHosts.set(key, host);
412
+ if (!hostInfoCache.has(key) && !loadHostInfoFromDisk(host)) {
413
+ await probeHostInfo(host);
414
+ }
415
+ return;
416
+ }
417
+
418
+ const start = runSshSync(["-M", "-N", "-f", ...buildCommonArgs(host), target]);
419
+ if (start.exitCode !== 0) {
420
+ const detail = start.stderr ? `: ${start.stderr}` : "";
421
+ throw new Error(`Failed to start SSH master for ${target}${detail}`);
422
+ }
423
+
424
+ activeHosts.set(key, host);
425
+ if (!hostInfoCache.has(key) && !loadHostInfoFromDisk(host)) {
426
+ await probeHostInfo(host);
427
+ }
428
+ })();
429
+
430
+ pendingConnections.set(key, promise);
431
+ try {
432
+ await promise;
433
+ } finally {
434
+ pendingConnections.delete(key);
435
+ }
436
+ }
437
+
438
+ function closeConnectionInternal(host: SSHConnectionTarget): void {
439
+ const target = buildSshTarget(host);
440
+ runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
441
+ }
442
+
443
+ export async function closeConnection(hostName: string): Promise<void> {
444
+ const host = activeHosts.get(hostName);
445
+ if (!host) {
446
+ closeConnectionInternal({ name: hostName, host: hostName });
447
+ return;
448
+ }
449
+ closeConnectionInternal(host);
450
+ activeHosts.delete(hostName);
451
+ }
452
+
453
+ export async function closeAllConnections(): Promise<void> {
454
+ for (const [name, host] of Array.from(activeHosts.entries())) {
455
+ closeConnectionInternal(host);
456
+ activeHosts.delete(name);
457
+ }
458
+ }
459
+
460
+ export function getControlPathTemplate(): string {
461
+ return CONTROL_PATH;
462
+ }
463
+
464
+ export function getControlDir(): string {
465
+ return CONTROL_DIR;
466
+ }
@@ -0,0 +1,190 @@
1
+ import { createWriteStream, type WriteStream } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { Subprocess } from "bun";
5
+ import { nanoid } from "nanoid";
6
+ import stripAnsi from "strip-ansi";
7
+ import { killProcessTree, sanitizeBinaryOutput } from "../../utils/shell";
8
+ import { logger } from "../logger";
9
+ import { DEFAULT_MAX_BYTES, truncateTail } from "../tools/truncate";
10
+ import { ScopeSignal } from "../utils";
11
+ import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
12
+ import { hasSshfs, mountRemote } from "./sshfs-mount";
13
+
14
+ export interface SSHExecutorOptions {
15
+ /** Timeout in milliseconds */
16
+ timeout?: number;
17
+ /** Callback for streaming output chunks (already sanitized) */
18
+ onChunk?: (chunk: string) => void;
19
+ /** AbortSignal for cancellation */
20
+ signal?: AbortSignal;
21
+ /** Remote path to mount when sshfs is available */
22
+ remotePath?: string;
23
+ /** Wrap commands in a POSIX shell for compat mode */
24
+ compatEnabled?: boolean;
25
+ }
26
+
27
+ export interface SSHResult {
28
+ /** Combined stdout + stderr output (sanitized, possibly truncated) */
29
+ output: string;
30
+ /** Process exit code (undefined if killed/cancelled) */
31
+ exitCode: number | undefined;
32
+ /** Whether the command was cancelled via signal */
33
+ cancelled: boolean;
34
+ /** Whether the output was truncated */
35
+ truncated: boolean;
36
+ /** Path to temp file containing full output (if output exceeded truncation threshold) */
37
+ fullOutputPath?: string;
38
+ }
39
+
40
+ function createSanitizer(): TransformStream<Uint8Array, string> {
41
+ const decoder = new TextDecoder();
42
+ return new TransformStream({
43
+ transform(chunk, controller) {
44
+ const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(chunk, { stream: true }))).replace(/\r/g, "");
45
+ controller.enqueue(text);
46
+ },
47
+ });
48
+ }
49
+
50
+ function createOutputSink(
51
+ spillThreshold: number,
52
+ maxBuffer: number,
53
+ onChunk?: (text: string) => void,
54
+ ): WritableStream<string> & {
55
+ dump: (annotation?: string) => { output: string; truncated: boolean; fullOutputPath?: string };
56
+ } {
57
+ const chunks: string[] = [];
58
+ let chunkBytes = 0;
59
+ let totalBytes = 0;
60
+ let fullOutputPath: string | undefined;
61
+ let fullOutputStream: WriteStream | undefined;
62
+
63
+ const sink = new WritableStream<string>({
64
+ write(text) {
65
+ totalBytes += text.length;
66
+
67
+ if (totalBytes > spillThreshold && !fullOutputPath) {
68
+ fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`);
69
+ const ts = createWriteStream(fullOutputPath);
70
+ chunks.forEach((c) => {
71
+ ts.write(c);
72
+ });
73
+ fullOutputStream = ts;
74
+ }
75
+ fullOutputStream?.write(text);
76
+
77
+ chunks.push(text);
78
+ chunkBytes += text.length;
79
+ while (chunkBytes > maxBuffer && chunks.length > 1) {
80
+ chunkBytes -= chunks.shift()!.length;
81
+ }
82
+
83
+ onChunk?.(text);
84
+ },
85
+ close() {
86
+ fullOutputStream?.end();
87
+ },
88
+ });
89
+
90
+ return Object.assign(sink, {
91
+ dump(annotation?: string) {
92
+ if (annotation) {
93
+ chunks.push(`\n\n${annotation}`);
94
+ }
95
+ const full = chunks.join("");
96
+ const { content, truncated } = truncateTail(full);
97
+ return { output: truncated ? content : full, truncated, fullOutputPath };
98
+ },
99
+ });
100
+ }
101
+
102
+ function quoteForCompatShell(command: string): string {
103
+ if (command.length === 0) {
104
+ return "''";
105
+ }
106
+ const escaped = command.replace(/'/g, "'\\''");
107
+ return `'${escaped}'`;
108
+ }
109
+
110
+ function buildCompatCommand(shell: "bash" | "sh", command: string): string {
111
+ return `${shell} -c ${quoteForCompatShell(command)}`;
112
+ }
113
+
114
+ export async function executeSSH(
115
+ host: SSHConnectionTarget,
116
+ command: string,
117
+ options?: SSHExecutorOptions,
118
+ ): Promise<SSHResult> {
119
+ await ensureConnection(host);
120
+ if (hasSshfs()) {
121
+ try {
122
+ await mountRemote(host, options?.remotePath ?? "/");
123
+ } catch (err) {
124
+ logger.warn("SSHFS mount failed", { host: host.name, error: String(err) });
125
+ }
126
+ }
127
+
128
+ using signal = new ScopeSignal(options);
129
+
130
+ let resolvedCommand = command;
131
+ if (options?.compatEnabled) {
132
+ const info = await ensureHostInfo(host);
133
+ if (info.compatShell) {
134
+ resolvedCommand = buildCompatCommand(info.compatShell, command);
135
+ } else {
136
+ logger.warn("SSH compat enabled without detected compat shell", { host: host.name });
137
+ }
138
+ }
139
+ const child: Subprocess = Bun.spawn(["ssh", ...buildRemoteCommand(host, resolvedCommand)], {
140
+ stdin: "ignore",
141
+ stdout: "pipe",
142
+ stderr: "pipe",
143
+ });
144
+
145
+ signal.catch(() => {
146
+ killProcessTree(child.pid);
147
+ });
148
+
149
+ const sink = createOutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
150
+
151
+ const writer = sink.getWriter();
152
+ try {
153
+ async function pumpStream(readable: ReadableStream<Uint8Array>) {
154
+ const reader = readable.pipeThrough(createSanitizer()).getReader();
155
+ try {
156
+ while (true) {
157
+ const { done, value } = await reader.read();
158
+ if (done) break;
159
+ await writer.write(value);
160
+ }
161
+ } finally {
162
+ reader.releaseLock();
163
+ }
164
+ }
165
+ await Promise.all([
166
+ pumpStream(child.stdout as ReadableStream<Uint8Array>),
167
+ pumpStream(child.stderr as ReadableStream<Uint8Array>),
168
+ ]);
169
+ } finally {
170
+ await writer.close();
171
+ }
172
+
173
+ const exitCode = await child.exited;
174
+ const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false));
175
+
176
+ if (signal.timedOut()) {
177
+ const secs = Math.round(options!.timeout! / 1000);
178
+ return {
179
+ exitCode: undefined,
180
+ cancelled: true,
181
+ ...sink.dump(`SSH command timed out after ${secs} seconds`),
182
+ };
183
+ }
184
+
185
+ return {
186
+ exitCode: cancelled ? undefined : exitCode,
187
+ cancelled,
188
+ ...sink.dump(),
189
+ };
190
+ }