pi-better-harness 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/extensions/ssh/index.ts +1 -0
- package/lib/cli.mjs +1 -0
- package/node_modules/pi-better-ssh/LICENSE +21 -0
- package/node_modules/pi-better-ssh/README.md +99 -0
- package/node_modules/pi-better-ssh/package.json +57 -0
- package/node_modules/pi-better-ssh/src/index.ts +314 -0
- package/node_modules/pi-better-ssh/src/mux-registry.ts +86 -0
- package/node_modules/pi-better-ssh/src/process-runner.ts +166 -0
- package/node_modules/pi-better-ssh/src/profile.ts +132 -0
- package/node_modules/pi-better-ssh/src/remote-bash.ts +184 -0
- package/node_modules/pi-better-ssh/src/shared-ssh-core/index.ts +902 -0
- package/package.json +7 -2
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { closeSync, mkdtempSync, openSync, rmSync, writeSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type {
|
|
7
|
+
CommandOutputCapture,
|
|
8
|
+
CommandResult,
|
|
9
|
+
CommandSpec,
|
|
10
|
+
RemoteRunner,
|
|
11
|
+
SpawnedProcess,
|
|
12
|
+
} from "./shared-ssh-core/index.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CAPTURE_BYTES = DEFAULT_MAX_BYTES * 2;
|
|
15
|
+
|
|
16
|
+
export function createProcessRemoteRunner(): RemoteRunner {
|
|
17
|
+
return {
|
|
18
|
+
spawn(): SpawnedProcess {
|
|
19
|
+
throw new Error("pi-better-ssh does not support detached remote processes; use bg_task_spawn with structured ssh");
|
|
20
|
+
},
|
|
21
|
+
runOnce: runProcessOnce,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function runProcessOnce(
|
|
26
|
+
spec: CommandSpec,
|
|
27
|
+
maxBufferBytes = DEFAULT_CAPTURE_BYTES,
|
|
28
|
+
timeoutMs?: number,
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
): Promise<CommandResult> {
|
|
31
|
+
const argv = validateCommandSpec(spec);
|
|
32
|
+
const startedAt = Date.now();
|
|
33
|
+
const child = nodeSpawn(argv[0]!, argv.slice(1), {
|
|
34
|
+
cwd: spec.cwd,
|
|
35
|
+
env: { ...process.env, ...spec.env },
|
|
36
|
+
shell: false,
|
|
37
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
});
|
|
40
|
+
const retainedBytes = Math.max(DEFAULT_CAPTURE_BYTES, Math.floor(maxBufferBytes));
|
|
41
|
+
const combined = new CommandOutputCaptureBuffer(retainedBytes);
|
|
42
|
+
const stdout = new TailBuffer(retainedBytes);
|
|
43
|
+
const stderr = new TailBuffer(retainedBytes);
|
|
44
|
+
let timedOut = false;
|
|
45
|
+
let settled = false;
|
|
46
|
+
|
|
47
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
48
|
+
stdout.append(chunk);
|
|
49
|
+
combined.append(chunk);
|
|
50
|
+
});
|
|
51
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
52
|
+
stderr.append(chunk);
|
|
53
|
+
combined.append(chunk);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const stopSlave = (): void => {
|
|
58
|
+
if (!settled) child.kill("SIGTERM");
|
|
59
|
+
};
|
|
60
|
+
const onAbort = (): void => stopSlave();
|
|
61
|
+
const timeout = timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
62
|
+
timedOut = true;
|
|
63
|
+
stopSlave();
|
|
64
|
+
}, Math.max(1, timeoutMs));
|
|
65
|
+
timeout?.unref();
|
|
66
|
+
if (signal?.aborted) stopSlave();
|
|
67
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
68
|
+
|
|
69
|
+
const finish = (): void => {
|
|
70
|
+
settled = true;
|
|
71
|
+
if (timeout) clearTimeout(timeout);
|
|
72
|
+
signal?.removeEventListener("abort", onAbort);
|
|
73
|
+
};
|
|
74
|
+
child.on("error", (error) => {
|
|
75
|
+
finish();
|
|
76
|
+
combined.discard();
|
|
77
|
+
reject(error);
|
|
78
|
+
});
|
|
79
|
+
child.on("close", (exitCode, childSignal) => {
|
|
80
|
+
finish();
|
|
81
|
+
resolve({
|
|
82
|
+
exitCode,
|
|
83
|
+
signal: childSignal,
|
|
84
|
+
stdout: stdout.text(),
|
|
85
|
+
stderr: stderr.text(),
|
|
86
|
+
startedAt,
|
|
87
|
+
endedAt: Date.now(),
|
|
88
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
89
|
+
outputCapture: combined.finish(),
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateCommandSpec(spec: CommandSpec): string[] {
|
|
96
|
+
if (spec.shell !== false || !spec.argv?.[0]) {
|
|
97
|
+
throw new Error("pi-better-ssh runner requires a non-empty shell:false argv");
|
|
98
|
+
}
|
|
99
|
+
return spec.argv;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
class TailBuffer {
|
|
103
|
+
private chunks: Buffer[] = [];
|
|
104
|
+
private bytes = 0;
|
|
105
|
+
|
|
106
|
+
constructor(private readonly maxBytes: number) {}
|
|
107
|
+
|
|
108
|
+
append(chunk: Buffer): void {
|
|
109
|
+
this.chunks.push(Buffer.from(chunk));
|
|
110
|
+
this.bytes += chunk.length;
|
|
111
|
+
while (this.bytes > this.maxBytes && this.chunks.length > 1) {
|
|
112
|
+
const removed = this.chunks.shift()!;
|
|
113
|
+
this.bytes -= removed.length;
|
|
114
|
+
}
|
|
115
|
+
if (this.bytes > this.maxBytes && this.chunks.length === 1) {
|
|
116
|
+
this.chunks[0] = this.chunks[0]!.subarray(this.bytes - this.maxBytes);
|
|
117
|
+
this.bytes = this.chunks[0]!.length;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
text(): string {
|
|
122
|
+
return Buffer.concat(this.chunks).toString("utf8");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
class CommandOutputCaptureBuffer extends TailBuffer {
|
|
127
|
+
private readonly directory = mkdtempSync(join(tmpdir(), "pi-remote-bash-"));
|
|
128
|
+
private readonly path = join(this.directory, "output.log");
|
|
129
|
+
private readonly fd = openSync(this.path, "w", 0o600);
|
|
130
|
+
private totalBytes = 0;
|
|
131
|
+
private newlineCount = 0;
|
|
132
|
+
private closed = false;
|
|
133
|
+
|
|
134
|
+
override append(chunk: Buffer): void {
|
|
135
|
+
super.append(chunk);
|
|
136
|
+
writeSync(this.fd, chunk);
|
|
137
|
+
this.totalBytes += chunk.length;
|
|
138
|
+
for (const byte of chunk) {
|
|
139
|
+
if (byte === 0x0a) this.newlineCount += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
finish(): CommandOutputCapture {
|
|
144
|
+
this.close();
|
|
145
|
+
const totalLines = this.newlineCount + 1;
|
|
146
|
+
const truncated = this.totalBytes > DEFAULT_MAX_BYTES || totalLines > DEFAULT_MAX_LINES;
|
|
147
|
+
if (!truncated) rmSync(this.directory, { recursive: true, force: true });
|
|
148
|
+
return {
|
|
149
|
+
output: this.text(),
|
|
150
|
+
totalBytes: this.totalBytes,
|
|
151
|
+
totalLines,
|
|
152
|
+
...(truncated ? { fullOutputPath: this.path } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
discard(): void {
|
|
157
|
+
this.close();
|
|
158
|
+
rmSync(this.directory, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private close(): void {
|
|
162
|
+
if (this.closed) return;
|
|
163
|
+
this.closed = true;
|
|
164
|
+
closeSync(this.fd);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const SSH_PROFILE_ENTRY_TYPE = "pi-better-ssh-profile";
|
|
6
|
+
|
|
7
|
+
export interface SshProfile {
|
|
8
|
+
host: string;
|
|
9
|
+
workdir?: string;
|
|
10
|
+
env?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SessionEntryLike {
|
|
14
|
+
type?: unknown;
|
|
15
|
+
customType?: unknown;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function defaultSshConfigPath(): string {
|
|
20
|
+
return join(homedir(), ".ssh", "config");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function listSshHostAliases(configPath = defaultSshConfigPath()): string[] {
|
|
24
|
+
let config: string;
|
|
25
|
+
try {
|
|
26
|
+
config = readFileSync(configPath, "utf8");
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const aliases = new Set<string>();
|
|
33
|
+
for (const line of config.split(/\r?\n/)) {
|
|
34
|
+
const directive = line.trim().match(/^Host\s+(.+?)(?:\s+#.*)?$/i);
|
|
35
|
+
if (!directive) continue;
|
|
36
|
+
for (const token of directive[1]!.trim().split(/\s+/)) {
|
|
37
|
+
if (!token || token.startsWith("!") || /[*?]/.test(token)) continue;
|
|
38
|
+
aliases.add(token);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [...aliases].sort((left, right) => left.localeCompare(right));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createSshProfile(
|
|
45
|
+
input: { host?: string; workdir?: string; env?: Record<string, string> },
|
|
46
|
+
aliases: readonly string[],
|
|
47
|
+
configPath = defaultSshConfigPath(),
|
|
48
|
+
): SshProfile {
|
|
49
|
+
const host = input.host?.trim();
|
|
50
|
+
if (!host) throw new Error("ssh_profile use requires host (an SSH config Host alias or user@host)");
|
|
51
|
+
if (/\s/.test(host) || host.startsWith("@") || host.endsWith("@")) {
|
|
52
|
+
throw new Error("ssh_profile host must be an SSH config Host alias or user@host");
|
|
53
|
+
}
|
|
54
|
+
if (!host.includes("@") && !aliases.includes(host)) {
|
|
55
|
+
throw new Error(`ssh_profile host ${JSON.stringify(host)} is not a Host alias in ${configPath}; use a configured alias or user@host`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const workdir = input.workdir?.trim();
|
|
59
|
+
if (input.workdir !== undefined && !workdir) {
|
|
60
|
+
throw new Error("ssh_profile workdir must not be empty");
|
|
61
|
+
}
|
|
62
|
+
const env = validateEnvironment(input.env);
|
|
63
|
+
return {
|
|
64
|
+
host,
|
|
65
|
+
...(workdir ? { workdir } : {}),
|
|
66
|
+
...(env ? { env } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function restoreSshProfile(entries: Iterable<SessionEntryLike>): SshProfile | undefined {
|
|
71
|
+
let active: SshProfile | undefined;
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (entry.type !== "custom" || entry.customType !== SSH_PROFILE_ENTRY_TYPE) continue;
|
|
74
|
+
if (!entry.data || typeof entry.data !== "object") continue;
|
|
75
|
+
const data = entry.data as { version?: unknown; active?: unknown };
|
|
76
|
+
if (data.version !== 1) continue;
|
|
77
|
+
if (data.active === null) {
|
|
78
|
+
active = undefined;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const restored = validateStoredProfile(data.active);
|
|
82
|
+
if (restored) active = restored;
|
|
83
|
+
}
|
|
84
|
+
return active;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function profileStateEntry(active: SshProfile | undefined): { version: 1; active: SshProfile | null } {
|
|
88
|
+
return { version: 1, active: active ? cloneProfile(active) : null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatSshProfileChip(profile: SshProfile, muxState: "up" | "down"): string {
|
|
92
|
+
return `SSH: ${profile.host}${profile.workdir ? `:${profile.workdir}` : ""} (mux ${muxState})`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateStoredProfile(value: unknown): SshProfile | undefined {
|
|
96
|
+
if (!value || typeof value !== "object") return undefined;
|
|
97
|
+
const candidate = value as { host?: unknown; workdir?: unknown; env?: unknown };
|
|
98
|
+
if (typeof candidate.host !== "string" || !candidate.host.trim() || /\s/.test(candidate.host)) return undefined;
|
|
99
|
+
if (candidate.workdir !== undefined && (typeof candidate.workdir !== "string" || !candidate.workdir.trim())) return undefined;
|
|
100
|
+
const env = validateEnvironment(candidate.env, false);
|
|
101
|
+
if (candidate.env !== undefined && !env) return undefined;
|
|
102
|
+
return {
|
|
103
|
+
host: candidate.host,
|
|
104
|
+
...(typeof candidate.workdir === "string" ? { workdir: candidate.workdir } : {}),
|
|
105
|
+
...(env ? { env } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateEnvironment(value: unknown, throwOnInvalid = true): Record<string, string> | undefined {
|
|
110
|
+
if (value === undefined) return undefined;
|
|
111
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
112
|
+
if (throwOnInvalid) throw new Error("ssh_profile env must be a string map");
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const env: Record<string, string> = {};
|
|
116
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
117
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof entry !== "string") {
|
|
118
|
+
if (throwOnInvalid) throw new Error(`ssh_profile env contains an invalid variable ${JSON.stringify(name)}`);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
env[name] = entry;
|
|
122
|
+
}
|
|
123
|
+
return env;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function cloneProfile(profile: SshProfile): SshProfile {
|
|
127
|
+
return {
|
|
128
|
+
host: profile.host,
|
|
129
|
+
...(profile.workdir ? { workdir: profile.workdir } : {}),
|
|
130
|
+
...(profile.env ? { env: { ...profile.env } } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MAX_BYTES,
|
|
6
|
+
DEFAULT_MAX_LINES,
|
|
7
|
+
truncateTail,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
createSshMuxController,
|
|
12
|
+
resolveSshCommand,
|
|
13
|
+
wrapRemoteBashCommand,
|
|
14
|
+
} from "./shared-ssh-core/index.js";
|
|
15
|
+
import type { RemoteRunner, SshConnectionParams } from "./shared-ssh-core/index.js";
|
|
16
|
+
import type { SshMuxRegistry } from "./mux-registry.js";
|
|
17
|
+
|
|
18
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
19
|
+
const REMOTE_CAPTURE_BYTES = DEFAULT_MAX_BYTES * 2;
|
|
20
|
+
|
|
21
|
+
export interface RemoteBashParams {
|
|
22
|
+
command: string;
|
|
23
|
+
host?: string;
|
|
24
|
+
workdir?: string;
|
|
25
|
+
timeout?: number;
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
user?: string;
|
|
28
|
+
port?: number;
|
|
29
|
+
identity_file?: string;
|
|
30
|
+
jump?: string;
|
|
31
|
+
options?: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RemoteBashDependencies {
|
|
35
|
+
runner: RemoteRunner;
|
|
36
|
+
sessionScope: string;
|
|
37
|
+
controlPathRoot?: string;
|
|
38
|
+
muxRegistry?: Pick<SshMuxRegistry, "controllerFor">;
|
|
39
|
+
activeProfile?: {
|
|
40
|
+
host: string;
|
|
41
|
+
workdir?: string;
|
|
42
|
+
env?: Record<string, string>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RemoteBashResult {
|
|
47
|
+
output: string;
|
|
48
|
+
exitCode: number | undefined;
|
|
49
|
+
cancelled: boolean;
|
|
50
|
+
timedOut: boolean;
|
|
51
|
+
truncated: boolean;
|
|
52
|
+
target: string;
|
|
53
|
+
workdir?: string;
|
|
54
|
+
mux: {
|
|
55
|
+
state: "up";
|
|
56
|
+
reused: boolean;
|
|
57
|
+
};
|
|
58
|
+
truncation?: TruncationResult;
|
|
59
|
+
fullOutputPath?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function executeRemoteBash(
|
|
63
|
+
params: RemoteBashParams,
|
|
64
|
+
dependencies: RemoteBashDependencies,
|
|
65
|
+
signal?: AbortSignal,
|
|
66
|
+
): Promise<RemoteBashResult> {
|
|
67
|
+
const effectiveParams = applyActiveProfile(params, dependencies.activeProfile);
|
|
68
|
+
const ssh = resolveRemoteBashConnection(effectiveParams);
|
|
69
|
+
const timeoutMs = resolveTimeoutMs(effectiveParams.timeout);
|
|
70
|
+
const remoteCommand = wrapRemoteBashCommand({
|
|
71
|
+
command: effectiveParams.command,
|
|
72
|
+
workdir: effectiveParams.workdir,
|
|
73
|
+
env: effectiveParams.env,
|
|
74
|
+
});
|
|
75
|
+
const resolved = resolveSshCommand({ command: remoteCommand, ssh });
|
|
76
|
+
const mux = dependencies.muxRegistry
|
|
77
|
+
? dependencies.muxRegistry.controllerFor(resolved, dependencies.sessionScope)
|
|
78
|
+
: createSshMuxController({
|
|
79
|
+
...resolved,
|
|
80
|
+
runner: dependencies.runner,
|
|
81
|
+
sessionScope: dependencies.sessionScope,
|
|
82
|
+
...(dependencies.controlPathRoot ? { controlPathRoot: dependencies.controlPathRoot } : {}),
|
|
83
|
+
});
|
|
84
|
+
const muxState = await mux.ensure();
|
|
85
|
+
const commandResult = await dependencies.runner.runOnce(
|
|
86
|
+
mux.withMux(resolved.commandSpec),
|
|
87
|
+
REMOTE_CAPTURE_BYTES,
|
|
88
|
+
timeoutMs,
|
|
89
|
+
signal,
|
|
90
|
+
);
|
|
91
|
+
const combinedOutput = commandResult.outputCapture?.output ?? `${commandResult.stdout}${commandResult.stderr}`;
|
|
92
|
+
const retainedTruncation = truncateTail(combinedOutput, {
|
|
93
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
94
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
95
|
+
});
|
|
96
|
+
const truncation = commandResult.outputCapture
|
|
97
|
+
? {
|
|
98
|
+
...retainedTruncation,
|
|
99
|
+
totalBytes: commandResult.outputCapture.totalBytes,
|
|
100
|
+
totalLines: commandResult.outputCapture.totalLines,
|
|
101
|
+
}
|
|
102
|
+
: retainedTruncation;
|
|
103
|
+
const cancelled = commandResult.timedOut === true || signal?.aborted === true;
|
|
104
|
+
const fullOutputPath = truncation.truncated
|
|
105
|
+
? commandResult.outputCapture?.fullOutputPath ?? persistFullOutput(`${commandResult.stdout}${commandResult.stderr}`)
|
|
106
|
+
: undefined;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
output: truncation.content,
|
|
110
|
+
exitCode: cancelled ? undefined : commandResult.exitCode ?? undefined,
|
|
111
|
+
cancelled,
|
|
112
|
+
timedOut: commandResult.timedOut === true,
|
|
113
|
+
truncated: truncation.truncated,
|
|
114
|
+
target: resolved.identity.target,
|
|
115
|
+
...(effectiveParams.workdir !== undefined ? { workdir: effectiveParams.workdir } : {}),
|
|
116
|
+
mux: { state: "up", reused: muxState.reused },
|
|
117
|
+
...(truncation.truncated ? { truncation } : {}),
|
|
118
|
+
...(fullOutputPath ? { fullOutputPath } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function applyActiveProfile(
|
|
123
|
+
params: RemoteBashParams,
|
|
124
|
+
profile: RemoteBashDependencies["activeProfile"],
|
|
125
|
+
): RemoteBashParams {
|
|
126
|
+
if (!profile) return params;
|
|
127
|
+
const env = profile.env || params.env
|
|
128
|
+
? { ...profile.env, ...params.env }
|
|
129
|
+
: undefined;
|
|
130
|
+
return {
|
|
131
|
+
...params,
|
|
132
|
+
host: params.host ?? profile.host,
|
|
133
|
+
workdir: params.workdir ?? profile.workdir,
|
|
134
|
+
...(env ? { env } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function resolveRemoteBashConnection(params: RemoteBashParams): SshConnectionParams {
|
|
139
|
+
const rawHost = params.host?.trim();
|
|
140
|
+
if (!rawHost) {
|
|
141
|
+
throw new Error("remote_bash requires host (an SSH Host alias or user@host); no active SSH profile is available");
|
|
142
|
+
}
|
|
143
|
+
if (/\s/.test(rawHost)) {
|
|
144
|
+
throw new Error("remote_bash host must not contain whitespace");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const separator = rawHost.lastIndexOf("@");
|
|
148
|
+
const embeddedUser = separator > 0 ? rawHost.slice(0, separator) : undefined;
|
|
149
|
+
const host = separator > 0 ? rawHost.slice(separator + 1) : rawHost;
|
|
150
|
+
if (!host || (separator === 0)) {
|
|
151
|
+
throw new Error("remote_bash host must be an SSH Host alias or user@host");
|
|
152
|
+
}
|
|
153
|
+
if (embeddedUser && params.user && embeddedUser !== params.user) {
|
|
154
|
+
throw new Error(`remote_bash user ${JSON.stringify(params.user)} conflicts with host user ${JSON.stringify(embeddedUser)}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
host,
|
|
159
|
+
...(params.user || embeddedUser ? { user: params.user ?? embeddedUser } : {}),
|
|
160
|
+
...(params.port !== undefined ? { port: params.port } : {}),
|
|
161
|
+
...(params.identity_file !== undefined ? { identity_file: params.identity_file } : {}),
|
|
162
|
+
...(params.jump !== undefined ? { jump: params.jump } : {}),
|
|
163
|
+
...(params.options !== undefined ? { options: params.options } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolveTimeoutMs(timeoutSeconds: number | undefined): number | undefined {
|
|
168
|
+
if (timeoutSeconds === undefined) return undefined;
|
|
169
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
170
|
+
throw new Error("remote_bash timeout must be a positive finite number of seconds");
|
|
171
|
+
}
|
|
172
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
173
|
+
if (timeoutMs > MAX_TIMEOUT_MS) {
|
|
174
|
+
throw new Error(`remote_bash timeout must not exceed ${MAX_TIMEOUT_MS / 1000} seconds`);
|
|
175
|
+
}
|
|
176
|
+
return timeoutMs;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function persistFullOutput(output: string): string {
|
|
180
|
+
const directory = mkdtempSync(join(tmpdir(), "pi-remote-bash-"));
|
|
181
|
+
const path = join(directory, "output.log");
|
|
182
|
+
writeFileSync(path, output, "utf8");
|
|
183
|
+
return path;
|
|
184
|
+
}
|