1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
|
/**
* SshExecBackend — implements `ExecBackend` over a pooled SSH connection.
*
* `spawn` runs a command on the remote via `client.exec` (shell-quoting the cwd
* into `cd "<cwd>" && <command>`; ssh2 exec has no cwd option). `readFile`/
* `writeFile`/`stat`/`readdir`/`exists` use SFTP. Every ssh2/SFTP error is
* routed through `errors.ts` so it lands as a node:fs-style `.code` error — the
* bundled tools' existing error branches (e.g. `read_file`'s "File not found"
* on `ENOENT`) work unchanged (plan §4.3).
*
* Built per `acquire`: captures the alias + a lazy `acquire` thunk so merely
* RESOLVING a backend never opens a connection — only the first actual method
* call connects (the resolver stays side-effect-free; see exec-backend service).
*/
import type {
DirEntry,
ExecBackend,
ExecResult,
SpawnParams,
StatResult,
} from "@dispatch/exec-backend";
import type { Client, ClientChannel } from "ssh2";
import { mapSshError } from "./errors.js";
import type { SshConnection } from "./pool.js";
/** Acquire the pooled connection for an alias (lazy — the backend is built
* before any connection exists; acquire runs on first method call). */
export type AcquireConnection = (alias: string) => Promise<SshConnection>;
/**
* Build a remote `ExecBackend` for `alias`. The connection is acquired lazily
* inside each method (so resolving a backend in the resolver is free — opening
* a connection is deferred to the first actual tool call). Only the alias is
* needed here: the pool re-resolves the real `Computer` (hostName/port/user/key)
* from `~/.ssh/config` at connect time, so the backend carries no stale params.
*/
export function createSshExecBackend(alias: string, acquire: AcquireConnection): ExecBackend {
const getConn = (): Promise<SshConnection> => acquire(alias);
return {
async spawn(params: SpawnParams): Promise<ExecResult> {
const conn = await getConn();
const client = await conn.getClient();
// ssh2 exec has no cwd option → prefix `cd "<cwd>" && <command>`.
// Shell-quote the cwd so a path with metachars can't break out (plan §7.6).
const wrapped = `cd ${shellQuote(params.cwd)} && ${params.command}`;
return runExec(client, wrapped, params);
},
async readFile(path: string): Promise<string> {
const conn = await getConn();
const sftp = await conn.getSftp();
return new Promise<string>((resolve, reject) => {
sftp.readFile(path, "utf8", (err, data) => {
if (err !== null && err !== undefined) reject(mapSshError(err, `readFile ${path}`));
else resolve(data.toString("utf8"));
});
});
},
async writeFile(path: string, content: string): Promise<void> {
const conn = await getConn();
const sftp = await conn.getSftp();
return new Promise<void>((resolve, reject) => {
sftp.writeFile(path, content, "utf8", (err) => {
if (err !== null && err !== undefined) reject(mapSshError(err, `writeFile ${path}`));
else resolve();
});
});
},
async stat(path: string): Promise<StatResult> {
const conn = await getConn();
const sftp = await conn.getSftp();
return new Promise<StatResult>((resolve, reject) => {
sftp.stat(path, (err, stats) => {
if (err !== null && err !== undefined) reject(mapSshError(err, `stat ${path}`));
else resolve({ isFile: stats.isFile(), isDirectory: stats.isDirectory() });
});
});
},
async readdir(path: string): Promise<readonly DirEntry[]> {
const conn = await getConn();
const sftp = await conn.getSftp();
return new Promise<readonly DirEntry[]>((resolve, reject) => {
sftp.readdir(path, (err, list) => {
if (err !== null && err !== undefined) reject(mapSshError(err, `readdir ${path}`));
else
resolve(
list.map((e): DirEntry => ({ name: e.filename, isDirectory: e.attrs.isDirectory() })),
);
});
});
},
async exists(path: string): Promise<boolean> {
const conn = await getConn();
const sftp = await conn.getSftp();
// ssh2's `sftp.exists` invokes the callback with a boolean that is TRUE
// when the path exists and FALSE when missing (verified empirically).
// Never throws — a missing path resolves `false`.
return new Promise<boolean>((resolve) => {
sftp.exists(path, (exists: boolean) => resolve(exists));
});
},
};
}
// ─── spawn core ─────────────────────────────────────────────────────────────
/**
* Run one `client.exec`, wiring stdout/stderr → `params.onOutput`, exit code,
* abort (`stream.end()`), and timeout. Mirrors `localSpawn`'s settle-once +
* cleanup semantics so the tool sees the same `ExecResult` shape (plan §4.3/§8).
*/
function runExec(client: Client, command: string, params: SpawnParams): Promise<ExecResult> {
return new Promise<ExecResult>((resolve) => {
let settled = false;
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let exitCode: number | null = null;
const settle = (result: ExecResult): void => {
if (settled) return;
settled = true;
if (timer !== undefined) clearTimeout(timer);
params.signal.removeEventListener("abort", onAbort);
client.removeListener("error", onClientError);
resolve(result);
};
const onAbort = (): void => {
if (settled) return;
try {
stream?.end();
} catch {
// best-effort — the remote channel may already be gone
}
settle({ exitCode: null, timedOut: false, aborted: true });
};
// If the client errors mid-exec, surface as a non-zero exit (the turn is
// NOT aborted — the model sees a normal tool error and can retry; §8).
const onClientError = (): void => {
if (!settled) settle({ exitCode: 1, timedOut: false, aborted: false });
};
client.on("error", onClientError);
let stream: ClientChannel | undefined;
client.exec(command, { pty: false }, (err, channel) => {
if (err !== null && err !== undefined) {
// Spawn error → non-zero exit, like localSpawn's error path.
settle({ exitCode: 1, timedOut: false, aborted: false });
return;
}
stream = channel;
// stdout: ssh2 channel IS its stdout stream (this.stdin = this.stdout = this).
channel.on("data", (data: Buffer) => {
params.onOutput(data.toString(), "stdout");
});
channel.stderr.on("data", (data: Buffer) => {
params.onOutput(data.toString(), "stderr");
});
channel.on("exit", (code: number | null) => {
exitCode = code;
});
channel.on("close", () => {
settle({ exitCode, timedOut, aborted: false });
});
params.signal.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(() => {
if (settled) return;
timedOut = true;
try {
channel.end();
} catch {
// best-effort
}
settle({ exitCode: null, timedOut: true, aborted: false });
}, params.timeout);
});
});
}
// ─── shell quoting ─────────────────────────────────────────────────────────
/**
* Shell-quote a path for the `cd "<cwd>" && ...` prefix so a cwd containing
* shell metacharacters cannot break out (plan §7.6). Single-quotes wrap the
* value and any embedded single-quote is escaped (`'\''`).
*/
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
|