co-mono/packages/coding-agent/examples/extensions/ssh.ts
Mario Zechner 9ed88646a8 feat(coding-agent): add pluggable operations for remote tool execution
Adds optional operations parameter to create*Tool functions enabling
delegation to remote systems (SSH, containers, etc.):

- ReadOperations: readFile, access, detectImageMimeType
- WriteOperations: writeFile, mkdir
- EditOperations: readFile, writeFile, access
- BashOperations: exec (with streaming, signal, timeout)

Add ssh.ts example demonstrating --ssh flag for remote execution.
Built-in renderers used automatically for overrides without custom renderers.

fixes #564
2026-01-08 19:32:25 +01:00

194 lines
6.2 KiB
TypeScript

/**
* SSH Remote Execution Example
*
* Demonstrates delegating tool operations to a remote machine via SSH.
* When --ssh is provided, read/write/edit/bash run on the remote.
*
* Usage:
* pi -e ./ssh.ts --ssh user@host
* pi -e ./ssh.ts --ssh user@host:/remote/path
*
* Requirements:
* - SSH key-based auth (no password prompts)
* - bash on remote
*/
import { spawn } from "node:child_process";
import { basename } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import {
type BashOperations,
createBashTool,
createEditTool,
createReadTool,
createWriteTool,
type EditOperations,
type ReadOperations,
type WriteOperations,
} from "@mariozechner/pi-coding-agent";
function sshExec(remote: string, command: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const child = spawn("ssh", [remote, command], { stdio: ["ignore", "pipe", "pipe"] });
const chunks: Buffer[] = [];
const errChunks: Buffer[] = [];
child.stdout.on("data", (data) => chunks.push(data));
child.stderr.on("data", (data) => errChunks.push(data));
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(`SSH failed (${code}): ${Buffer.concat(errChunks).toString()}`));
} else {
resolve(Buffer.concat(chunks));
}
});
});
}
function createRemoteReadOps(remote: string, remoteCwd: string, localCwd: string): ReadOperations {
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
return {
readFile: (p) => sshExec(remote, `cat ${JSON.stringify(toRemote(p))}`),
access: (p) => sshExec(remote, `test -r ${JSON.stringify(toRemote(p))}`).then(() => {}),
detectImageMimeType: async (p) => {
try {
const r = await sshExec(remote, `file --mime-type -b ${JSON.stringify(toRemote(p))}`);
const m = r.toString().trim();
return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(m) ? m : null;
} catch {
return null;
}
},
};
}
function createRemoteWriteOps(remote: string, remoteCwd: string, localCwd: string): WriteOperations {
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
return {
writeFile: async (p, content) => {
const b64 = Buffer.from(content).toString("base64");
await sshExec(remote, `echo ${JSON.stringify(b64)} | base64 -d > ${JSON.stringify(toRemote(p))}`);
},
mkdir: (dir) => sshExec(remote, `mkdir -p ${JSON.stringify(toRemote(dir))}`).then(() => {}),
};
}
function createRemoteEditOps(remote: string, remoteCwd: string, localCwd: string): EditOperations {
const r = createRemoteReadOps(remote, remoteCwd, localCwd);
const w = createRemoteWriteOps(remote, remoteCwd, localCwd);
return { readFile: r.readFile, access: r.access, writeFile: w.writeFile };
}
function createRemoteBashOps(remote: string, remoteCwd: string, localCwd: string): BashOperations {
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
return {
exec: (command, cwd, { onData, signal, timeout }) =>
new Promise((resolve, reject) => {
const cmd = `cd ${JSON.stringify(toRemote(cwd))} && ${command}`;
const child = spawn("ssh", [remote, cmd], { stdio: ["ignore", "pipe", "pipe"] });
let timedOut = false;
const timer = timeout
? setTimeout(() => {
timedOut = true;
child.kill();
}, timeout * 1000)
: undefined;
child.stdout.on("data", onData);
child.stderr.on("data", onData);
child.on("error", (e) => {
if (timer) clearTimeout(timer);
reject(e);
});
const onAbort = () => child.kill();
signal?.addEventListener("abort", onAbort, { once: true });
child.on("close", (code) => {
if (timer) clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
if (signal?.aborted) reject(new Error("aborted"));
else if (timedOut) reject(new Error(`timeout:${timeout}`));
else resolve({ exitCode: code });
});
}),
};
}
export default function (pi: ExtensionAPI) {
pi.registerFlag("ssh", { description: "SSH remote: user@host or user@host:/path", type: "string" });
const localCwd = process.cwd();
const localRead = createReadTool(localCwd);
const localWrite = createWriteTool(localCwd);
const localEdit = createEditTool(localCwd);
const localBash = createBashTool(localCwd);
const getSsh = () => {
const arg = pi.getFlag("ssh") as string | undefined;
if (!arg) return null;
const [remote, path] = arg.includes(":") ? arg.split(":") : [arg, `~/${basename(localCwd)}`];
return { remote, remoteCwd: path };
};
pi.registerTool({
...localRead,
async execute(id, params, onUpdate, _ctx, signal) {
const ssh = getSsh();
if (ssh) {
const tool = createReadTool(localCwd, {
operations: createRemoteReadOps(ssh.remote, ssh.remoteCwd, localCwd),
});
return tool.execute(id, params, signal, onUpdate);
}
return localRead.execute(id, params, signal, onUpdate);
},
});
pi.registerTool({
...localWrite,
async execute(id, params, onUpdate, _ctx, signal) {
const ssh = getSsh();
if (ssh) {
const tool = createWriteTool(localCwd, {
operations: createRemoteWriteOps(ssh.remote, ssh.remoteCwd, localCwd),
});
return tool.execute(id, params, signal, onUpdate);
}
return localWrite.execute(id, params, signal, onUpdate);
},
});
pi.registerTool({
...localEdit,
async execute(id, params, onUpdate, _ctx, signal) {
const ssh = getSsh();
if (ssh) {
const tool = createEditTool(localCwd, {
operations: createRemoteEditOps(ssh.remote, ssh.remoteCwd, localCwd),
});
return tool.execute(id, params, signal, onUpdate);
}
return localEdit.execute(id, params, signal, onUpdate);
},
});
pi.registerTool({
...localBash,
async execute(id, params, onUpdate, _ctx, signal) {
const ssh = getSsh();
if (ssh) {
const tool = createBashTool(localCwd, {
operations: createRemoteBashOps(ssh.remote, ssh.remoteCwd, localCwd),
});
return tool.execute(id, params, signal, onUpdate);
}
return localBash.execute(id, params, signal, onUpdate);
},
});
pi.on("session_start", async (_event, ctx) => {
const ssh = getSsh();
if (ssh) {
ctx.ui.setStatus("ssh", `SSH: ${ssh.remote}:${ssh.remoteCwd}`);
ctx.ui.notify(`SSH mode: ${ssh.remote}:${ssh.remoteCwd}`, "info");
}
});
}