761 lines
28 KiB
JavaScript
761 lines
28 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
import path from "node:path";
|
|
import readline from "node:readline";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const SERVER_VERSION = "0.9.0";
|
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const cliPath =
|
|
process.env.AUTODL_REMOTE_MCP_CLI ||
|
|
path.join(pluginRoot, "bin", "autodl-remote");
|
|
const configuredLimit = Number.parseInt(
|
|
process.env.AUTODL_REMOTE_MCP_MAX_OUTPUT_BYTES || "65536",
|
|
10,
|
|
);
|
|
const maxOutputBytes =
|
|
Number.isSafeInteger(configuredLimit) && configuredLimit > 0
|
|
? configuredLimit
|
|
: 65536;
|
|
const activeChildren = new Set();
|
|
|
|
const projectRootProperty = {
|
|
type: "string",
|
|
description:
|
|
"Absolute path to the local project bound to AutoDL Remote. The CLI runs with this directory as cwd.",
|
|
};
|
|
const accountProperty = {
|
|
type: "string",
|
|
description: "Optional saved account profile override.",
|
|
};
|
|
const remoteProperty = {
|
|
type: "string",
|
|
description: "Optional remote project root override.",
|
|
};
|
|
const stringArray = { type: "array", items: { type: "string" } };
|
|
|
|
function objectSchema(properties, required = []) {
|
|
return {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties,
|
|
...(required.length ? { required } : {}),
|
|
};
|
|
}
|
|
|
|
function tool(name, description, properties, required = [], annotations = {}) {
|
|
return {
|
|
name,
|
|
description,
|
|
inputSchema: objectSchema(properties, required),
|
|
annotations,
|
|
};
|
|
}
|
|
|
|
const tools = [
|
|
tool(
|
|
"autodl_account",
|
|
"Manage saved AutoDL SSH account profiles. Supports list, show, test, use, and add. Password save/delete are intentionally excluded so credentials never pass through MCP arguments.",
|
|
{
|
|
action: { type: "string", enum: ["list", "show", "test", "use", "add"] },
|
|
name: { type: "string", description: "Account profile name." },
|
|
target: { type: "string", description: "SSH target such as root@gpu.example.com." },
|
|
host: { type: "string" },
|
|
user: { type: "string" },
|
|
port: { type: "integer", minimum: 1, maximum: 65535 },
|
|
key: { type: "string", description: "Local private-key path." },
|
|
auth: { type: "string", enum: ["prompt", "keychain", "ssh-key"] },
|
|
defaultRemote: { type: "string" },
|
|
force: { type: "boolean" },
|
|
},
|
|
["action"],
|
|
),
|
|
tool(
|
|
"autodl_bind",
|
|
"Bind a local project directory to a saved account and remote project root.",
|
|
{
|
|
projectRoot: projectRootProperty,
|
|
account: accountProperty,
|
|
remote: { ...remoteProperty, description: "Remote project root to bind." },
|
|
force: { type: "boolean", description: "Overwrite an existing project binding." },
|
|
},
|
|
["projectRoot", "remote"],
|
|
),
|
|
tool(
|
|
"autodl_doctor",
|
|
"Diagnose the bound project, local SSH/rsync, remote connectivity, GPU, and tmux state.",
|
|
{ projectRoot: projectRootProperty },
|
|
["projectRoot"],
|
|
{ readOnlyHint: true },
|
|
),
|
|
tool(
|
|
"autodl_model_dir",
|
|
"Show the conventional remote model directory, optionally creating it.",
|
|
{
|
|
projectRoot: projectRootProperty,
|
|
create: { type: "boolean", description: "Create the directory remotely when missing." },
|
|
},
|
|
["projectRoot"],
|
|
),
|
|
tool(
|
|
"autodl_file",
|
|
"Inspect remote project files without opening an interactive shell. Supports tree, list, read, and tail; tail is always bounded and non-following.",
|
|
{
|
|
action: { type: "string", enum: ["tree", "list", "read", "tail"] },
|
|
projectRoot: projectRootProperty,
|
|
path: { type: "string", description: "Path relative to the remote project root." },
|
|
depth: { type: "integer", minimum: 0, maximum: 20 },
|
|
limit: { type: "integer", minimum: 1, maximum: 5000 },
|
|
lines: { type: "integer", minimum: 0, maximum: 5000 },
|
|
account: accountProperty,
|
|
remote: remoteProperty,
|
|
},
|
|
["action", "projectRoot"],
|
|
{ readOnlyHint: true },
|
|
),
|
|
tool(
|
|
"autodl_transfer",
|
|
"Upload or download one project path using the existing rsync/SCP fallback logic. Supports dry runs; upload mirroring requires explicit deletion confirmation.",
|
|
{
|
|
action: { type: "string", enum: ["upload", "download"] },
|
|
projectRoot: projectRootProperty,
|
|
localPath: { type: "string", description: "Path relative to the local project root." },
|
|
remotePath: { type: "string", description: "Path relative to the remote project root." },
|
|
dryRun: { type: "boolean" },
|
|
delete: { type: "boolean", description: "Upload only: delete extraneous remote files." },
|
|
confirmDelete: {
|
|
type: "string",
|
|
description: "When delete is true, must equal delete-remote-extraneous.",
|
|
},
|
|
account: accountProperty,
|
|
remote: remoteProperty,
|
|
},
|
|
["action", "projectRoot"],
|
|
),
|
|
tool(
|
|
"autodl_exec",
|
|
"Execute an argv array in the remote project. The argv array is passed without a local shell. Use [\"bash\",\"-lc\",\"...\"] only when shell syntax is required. Dangerous commands stay blocked unless explicitly confirmed.",
|
|
{
|
|
projectRoot: projectRootProperty,
|
|
argv: { ...stringArray, minItems: 1, description: "Remote command as an argv array." },
|
|
mode: { type: "string", enum: ["foreground", "detach", "tmux"] },
|
|
account: accountProperty,
|
|
remote: remoteProperty,
|
|
cwd: { type: "string", description: "Remote cwd relative to the remote project root." },
|
|
name: { type: "string" },
|
|
replace: { type: "boolean" },
|
|
model: { type: "string" },
|
|
tags: stringArray,
|
|
stage: { type: "string" },
|
|
purpose: { type: "string" },
|
|
outputs: stringArray,
|
|
allowDangerous: { type: "boolean" },
|
|
confirmDangerous: {
|
|
type: "string",
|
|
description: "Must equal allow-dangerous when allowDangerous is true.",
|
|
},
|
|
},
|
|
["projectRoot", "argv"],
|
|
),
|
|
tool(
|
|
"autodl_job",
|
|
"Inspect or stop detached jobs. Supports list, status, tail, and stop. Tail is bounded and non-following; stop requires exact-name confirmation.",
|
|
{
|
|
action: { type: "string", enum: ["list", "status", "tail", "stop"] },
|
|
projectRoot: projectRootProperty,
|
|
name: { type: "string" },
|
|
lines: { type: "integer", minimum: 0, maximum: 5000 },
|
|
confirm: { type: "string", description: "For stop, must exactly match name." },
|
|
},
|
|
["action", "projectRoot"],
|
|
),
|
|
tool(
|
|
"autodl_run",
|
|
"Submit and manage tracked experiment runs. Supports submit, list, status, tail, stop, and note. Submit is detached by default; stop requires exact-name confirmation.",
|
|
{
|
|
action: { type: "string", enum: ["submit", "list", "status", "tail", "stop", "note"] },
|
|
projectRoot: projectRootProperty,
|
|
argv: { ...stringArray, minItems: 1 },
|
|
syncUp: { ...stringArray, description: "Project-relative local paths to upload before submit." },
|
|
tmux: { type: "boolean" },
|
|
account: accountProperty,
|
|
remote: remoteProperty,
|
|
cwd: { type: "string" },
|
|
name: { type: "string" },
|
|
replace: { type: "boolean" },
|
|
model: { type: "string" },
|
|
tags: stringArray,
|
|
stage: { type: "string" },
|
|
purpose: { type: "string" },
|
|
outputs: stringArray,
|
|
lines: { type: "integer", minimum: 0, maximum: 5000 },
|
|
allowDangerous: { type: "boolean" },
|
|
confirmDangerous: { type: "string" },
|
|
confirm: { type: "string", description: "For stop, must exactly match name." },
|
|
},
|
|
["action", "projectRoot"],
|
|
),
|
|
tool(
|
|
"autodl_fleet",
|
|
"Create, register, list, and inspect multi-GPU account fleets for a bound project.",
|
|
{
|
|
action: { type: "string", enum: ["create", "add", "list", "status"] },
|
|
projectRoot: projectRootProperty,
|
|
fleet: { type: "string" },
|
|
device: { type: "string" },
|
|
account: accountProperty,
|
|
remote: remoteProperty,
|
|
tags: { type: "string", description: "Comma-separated device tags." },
|
|
},
|
|
["action", "projectRoot"],
|
|
),
|
|
tool(
|
|
"autodl_tmux",
|
|
"Check, list, capture, or manage AutoDL Remote tmux sessions. Capture is bounded. Install and kill require explicit confirmation.",
|
|
{
|
|
action: { type: "string", enum: ["check", "list", "capture", "attach_command", "install", "kill"] },
|
|
projectRoot: projectRootProperty,
|
|
session: { type: "string" },
|
|
lines: { type: "integer", minimum: 0, maximum: 5000 },
|
|
account: accountProperty,
|
|
remote: remoteProperty,
|
|
confirm: {
|
|
type: "string",
|
|
description: "For kill, match session; for install, equal install-tmux.",
|
|
},
|
|
},
|
|
["action", "projectRoot"],
|
|
),
|
|
tool(
|
|
"autodl_dashboard",
|
|
"Render a one-shot, read-only local HTML dashboard for the project or a fleet. Watching and auto-opening are intentionally excluded from MCP.",
|
|
{
|
|
projectRoot: projectRootProperty,
|
|
fleet: { type: "string" },
|
|
output: { type: "string", description: "Project-relative local output path." },
|
|
lines: { type: "integer", minimum: 0, maximum: 5000 },
|
|
},
|
|
["projectRoot"],
|
|
),
|
|
tool(
|
|
"autodl_shutdown",
|
|
"Request shutdown of the remote machine bound to the project. Requires confirm=shutdown and is always treated as destructive.",
|
|
{
|
|
projectRoot: projectRootProperty,
|
|
waitSeconds: { type: "integer", minimum: 0, maximum: 120 },
|
|
confirm: { type: "string", description: "Must equal shutdown." },
|
|
},
|
|
["projectRoot", "confirm"],
|
|
{ destructiveHint: true },
|
|
),
|
|
];
|
|
|
|
const toolNames = new Set(tools.map(({ name }) => name));
|
|
|
|
function fail(message) {
|
|
throw Object.assign(new Error(message), { code: -32602 });
|
|
}
|
|
|
|
function requiredString(input, key) {
|
|
const value = input[key];
|
|
if (typeof value !== "string" || value.length === 0) fail(`${key} is required`);
|
|
return value;
|
|
}
|
|
|
|
function optionalString(input, key) {
|
|
const value = input[key];
|
|
if (value === undefined) return undefined;
|
|
if (typeof value !== "string" || value.length === 0) fail(`${key} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
function stringList(input, key, { required = false } = {}) {
|
|
const value = input[key];
|
|
if (value === undefined) {
|
|
if (required) fail(`${key} is required`);
|
|
return [];
|
|
}
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.length)) {
|
|
fail(`${key} must be an array of non-empty strings`);
|
|
}
|
|
if (required && value.length === 0) fail(`${key} must not be empty`);
|
|
return value;
|
|
}
|
|
|
|
function integer(input, key, fallback, min, max) {
|
|
const value = input[key] ?? fallback;
|
|
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
fail(`${key} must be an integer from ${min} to ${max}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function projectCwd(input) {
|
|
const value = requiredString(input, "projectRoot");
|
|
if (!path.isAbsolute(value)) fail("projectRoot must be an absolute path");
|
|
try {
|
|
if (!statSync(value).isDirectory()) fail("projectRoot must be a directory");
|
|
} catch (error) {
|
|
if (error.code === -32602) throw error;
|
|
fail(`projectRoot is not accessible: ${value}`);
|
|
}
|
|
return realpathSync(value);
|
|
}
|
|
|
|
function isWithin(root, candidate) {
|
|
const relative = path.relative(root, candidate);
|
|
return (
|
|
relative === "" ||
|
|
(relative !== ".." &&
|
|
!relative.startsWith(`..${path.sep}`) &&
|
|
!path.isAbsolute(relative))
|
|
);
|
|
}
|
|
|
|
function pathEntryExists(value) {
|
|
try {
|
|
lstatSync(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function validateLocalProjectPath(value, key, projectRoot) {
|
|
if (path.isAbsolute(value)) fail(`${key} must be project-relative`);
|
|
const resolved = path.resolve(projectRoot, value);
|
|
if (!isWithin(projectRoot, resolved)) fail(`${key} cannot escape projectRoot`);
|
|
|
|
let existing = resolved;
|
|
while (!pathEntryExists(existing)) {
|
|
const parent = path.dirname(existing);
|
|
if (parent === existing) break;
|
|
existing = parent;
|
|
}
|
|
let canonicalExisting;
|
|
try {
|
|
canonicalExisting = realpathSync(existing);
|
|
} catch {
|
|
fail(`${key} cannot resolve through a broken symlink inside projectRoot`);
|
|
}
|
|
if (!isWithin(projectRoot, canonicalExisting)) {
|
|
fail(`${key} cannot escape projectRoot through a symlink`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function localProjectPath(input, key, projectRoot, { required = false } = {}) {
|
|
const value = required ? requiredString(input, key) : optionalString(input, key);
|
|
return value === undefined
|
|
? undefined
|
|
: validateLocalProjectPath(value, key, projectRoot);
|
|
}
|
|
|
|
function validateRemoteProjectPath(value, key) {
|
|
if (path.posix.isAbsolute(value) || value.split("/").includes("..")) {
|
|
fail(`${key} must be project-relative and cannot escape projectRoot`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function remoteProjectPath(input, key, { required = false } = {}) {
|
|
const value = required ? requiredString(input, key) : optionalString(input, key);
|
|
return value === undefined ? undefined : validateRemoteProjectPath(value, key);
|
|
}
|
|
|
|
function canonicalNameValue(value, key) {
|
|
if (!/^[A-Za-z0-9_.-]+$/.test(value)) {
|
|
fail(`${key} must already be a canonical name using only A-Z, a-z, 0-9, _, ., or -`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function canonicalName(input, key, { required = false } = {}) {
|
|
const value = required ? requiredString(input, key) : optionalString(input, key);
|
|
return value === undefined ? undefined : canonicalNameValue(value, key);
|
|
}
|
|
|
|
function addOption(args, flag, value) {
|
|
if (value !== undefined) args.push(flag, value);
|
|
}
|
|
|
|
function addCommonRemoteOptions(args, input) {
|
|
addOption(args, "--account", optionalString(input, "account"));
|
|
addOption(args, "--remote", optionalString(input, "remote"));
|
|
}
|
|
|
|
function addMetadataOptions(args, input, projectRoot) {
|
|
addOption(args, "--cwd", remoteProjectPath(input, "cwd"));
|
|
addOption(args, "--name", canonicalName(input, "name"));
|
|
if (input.replace === true) args.push("--replace");
|
|
addOption(args, "--model", optionalString(input, "model"));
|
|
for (const tag of stringList(input, "tags")) args.push("--tag", tag);
|
|
addOption(args, "--stage", optionalString(input, "stage"));
|
|
addOption(args, "--purpose", optionalString(input, "purpose"));
|
|
for (const output of stringList(input, "outputs")) args.push("--output", output);
|
|
if (input.allowDangerous === true) {
|
|
if (input.confirmDangerous !== "allow-dangerous") {
|
|
fail("allowDangerous requires confirmDangerous=allow-dangerous");
|
|
}
|
|
args.push("--allow-dangerous");
|
|
}
|
|
}
|
|
|
|
function requireNameFor(action, input, actions) {
|
|
return canonicalName(input, "name", { required: actions.includes(action) });
|
|
}
|
|
|
|
function buildAccount(input) {
|
|
const action = requiredString(input, "action");
|
|
if (!["list", "show", "test", "use", "add"].includes(action)) fail(`unsupported account action: ${action}`);
|
|
const args = ["account", action];
|
|
if (action === "list") return { args, cwd: process.cwd() };
|
|
if (action === "test") {
|
|
const name = optionalString(input, "name");
|
|
if (name) args.push(name);
|
|
return { args, cwd: process.cwd() };
|
|
}
|
|
const name = requiredString(input, "name");
|
|
args.push(name);
|
|
if (action !== "add") return { args, cwd: process.cwd() };
|
|
const target = optionalString(input, "target");
|
|
const host = optionalString(input, "host");
|
|
if (!target && !host) fail("account add requires target or host");
|
|
addOption(args, "--target", target);
|
|
addOption(args, "--host", host);
|
|
addOption(args, "--user", optionalString(input, "user"));
|
|
if (input.port !== undefined) args.push("--port", String(integer(input, "port", 22, 1, 65535)));
|
|
addOption(args, "--key", optionalString(input, "key"));
|
|
const auth = optionalString(input, "auth");
|
|
if (auth && !["prompt", "keychain", "ssh-key"].includes(auth)) fail("invalid auth");
|
|
addOption(args, "--auth", auth);
|
|
addOption(args, "--default-remote", optionalString(input, "defaultRemote"));
|
|
if (input.force === true) args.push("--force");
|
|
return { args, cwd: process.cwd() };
|
|
}
|
|
|
|
function buildBind(input) {
|
|
const args = ["bind"];
|
|
addOption(args, "--account", optionalString(input, "account"));
|
|
args.push("--remote", requiredString(input, "remote"));
|
|
if (input.force === true) args.push("--force");
|
|
return { args, cwd: projectCwd(input) };
|
|
}
|
|
|
|
function buildFile(input) {
|
|
const action = requiredString(input, "action");
|
|
const command = { tree: "tree", list: "ls", read: "cat", tail: "tail" }[action];
|
|
if (!command) fail(`unsupported file action: ${action}`);
|
|
const cwd = projectCwd(input);
|
|
const args = [command];
|
|
addCommonRemoteOptions(args, input);
|
|
const filePath = ["read", "tail"].includes(action)
|
|
? remoteProjectPath(input, "path", { required: true })
|
|
: remoteProjectPath(input, "path") || ".";
|
|
if (action === "tree") {
|
|
if (filePath.startsWith("-")) fail("tree path must not start with '-'");
|
|
if (input.depth !== undefined) args.push("--depth", String(integer(input, "depth", 3, 0, 20)));
|
|
if (input.limit !== undefined) args.push("--limit", String(integer(input, "limit", 500, 1, 5000)));
|
|
args.push(filePath);
|
|
} else {
|
|
if (input.lines !== undefined && ["read", "tail"].includes(action)) {
|
|
args.push("--lines", String(integer(input, "lines", 120, 0, 5000)));
|
|
}
|
|
args.push("--", filePath);
|
|
}
|
|
return { args, cwd };
|
|
}
|
|
|
|
function buildTransfer(input) {
|
|
const action = requiredString(input, "action");
|
|
if (!["upload", "download"].includes(action)) fail(`unsupported transfer action: ${action}`);
|
|
const cwd = projectCwd(input);
|
|
const args = [action === "upload" ? "put" : "get"];
|
|
addCommonRemoteOptions(args, input);
|
|
if (input.dryRun === true) args.push("--dry-run");
|
|
if (input.delete === true) {
|
|
if (action !== "upload") fail("delete is only valid for upload");
|
|
if (input.confirmDelete !== "delete-remote-extraneous") {
|
|
fail("delete requires confirmDelete=delete-remote-extraneous");
|
|
}
|
|
args.push("--delete");
|
|
}
|
|
args.push("--");
|
|
if (action === "upload") {
|
|
args.push(localProjectPath(input, "localPath", cwd, { required: true }));
|
|
const remotePath = remoteProjectPath(input, "remotePath");
|
|
if (remotePath) args.push(remotePath);
|
|
} else {
|
|
args.push(remoteProjectPath(input, "remotePath", { required: true }));
|
|
const localPath = localProjectPath(input, "localPath", cwd);
|
|
if (localPath) args.push(localPath);
|
|
}
|
|
return { args, cwd };
|
|
}
|
|
|
|
function buildExec(input) {
|
|
const cwd = projectCwd(input);
|
|
const args = ["exec"];
|
|
addCommonRemoteOptions(args, input);
|
|
const mode = input.mode || "foreground";
|
|
if (!["foreground", "detach", "tmux"].includes(mode)) fail(`unsupported exec mode: ${mode}`);
|
|
if (mode === "detach") args.push("--detach");
|
|
if (mode === "tmux") args.push("--tmux");
|
|
addMetadataOptions(args, input, cwd);
|
|
args.push("--", ...stringList(input, "argv", { required: true }));
|
|
return { args, cwd };
|
|
}
|
|
|
|
function buildJob(input) {
|
|
const action = requiredString(input, "action");
|
|
if (!["list", "status", "tail", "stop"].includes(action)) fail(`unsupported job action: ${action}`);
|
|
const args = ["job", action];
|
|
const name = requireNameFor(action, input, ["status", "tail", "stop"]);
|
|
if (action === "tail") args.push("--lines", String(integer(input, "lines", 120, 0, 5000)));
|
|
if (action === "stop" && input.confirm !== name) fail("job stop requires confirm to exactly match name");
|
|
if (name) args.push(name);
|
|
return { args, cwd: projectCwd(input) };
|
|
}
|
|
|
|
function buildRun(input) {
|
|
const action = requiredString(input, "action");
|
|
if (!["submit", "list", "status", "tail", "stop", "note"].includes(action)) fail(`unsupported run action: ${action}`);
|
|
const cwd = projectCwd(input);
|
|
const args = ["run", action];
|
|
if (action === "submit") {
|
|
addCommonRemoteOptions(args, input);
|
|
for (const upload of stringList(input, "syncUp")) {
|
|
args.push("--sync-up", validateLocalProjectPath(upload, "syncUp", cwd));
|
|
}
|
|
if (input.tmux === true) args.push("--tmux");
|
|
addMetadataOptions(args, input, cwd);
|
|
args.push("--", ...stringList(input, "argv", { required: true }));
|
|
} else if (action === "list") {
|
|
// no additional arguments
|
|
} else {
|
|
const name = canonicalName(input, "name", { required: true });
|
|
if (action === "tail") args.push("--lines", String(integer(input, "lines", 120, 0, 5000)));
|
|
args.push(name);
|
|
if (action === "stop" && input.confirm !== name) fail("run stop requires confirm to exactly match name");
|
|
if (action === "note") {
|
|
addOption(args, "--model", optionalString(input, "model"));
|
|
for (const tag of stringList(input, "tags")) args.push("--tag", tag);
|
|
addOption(args, "--stage", optionalString(input, "stage"));
|
|
addOption(args, "--purpose", optionalString(input, "purpose"));
|
|
for (const output of stringList(input, "outputs")) args.push("--output", output);
|
|
}
|
|
}
|
|
return { args, cwd };
|
|
}
|
|
|
|
function buildFleet(input) {
|
|
const action = requiredString(input, "action");
|
|
if (!["create", "add", "list", "status"].includes(action)) fail(`unsupported fleet action: ${action}`);
|
|
const args = ["fleet", action];
|
|
if (action === "create") args.push(requiredString(input, "fleet"));
|
|
if (action === "add") {
|
|
args.push(requiredString(input, "fleet"), requiredString(input, "device"));
|
|
addOption(args, "--account", optionalString(input, "account"));
|
|
addOption(args, "--remote", optionalString(input, "remote"));
|
|
addOption(args, "--tags", optionalString(input, "tags"));
|
|
}
|
|
if (action === "status") {
|
|
const fleet = optionalString(input, "fleet");
|
|
if (fleet) args.push(fleet);
|
|
}
|
|
return { args, cwd: projectCwd(input) };
|
|
}
|
|
|
|
function buildTmux(input) {
|
|
const action = requiredString(input, "action");
|
|
const command = {
|
|
check: "check",
|
|
list: "list",
|
|
capture: "capture",
|
|
attach_command: "attach-cmd",
|
|
install: "install",
|
|
kill: "kill",
|
|
}[action];
|
|
if (!command) fail(`unsupported tmux action: ${action}`);
|
|
const args = ["tmux", command];
|
|
addCommonRemoteOptions(args, input);
|
|
if (action === "capture") args.push("--lines", String(integer(input, "lines", 120, 0, 5000)));
|
|
if (["capture", "attach_command", "kill"].includes(action)) {
|
|
const session = canonicalName(input, "session", { required: true });
|
|
if (action === "kill" && input.confirm !== session) fail("tmux kill requires confirm to exactly match session");
|
|
args.push("--", session);
|
|
}
|
|
if (action === "install" && input.confirm !== "install-tmux") {
|
|
fail("tmux install requires confirm=install-tmux");
|
|
}
|
|
return { args, cwd: projectCwd(input) };
|
|
}
|
|
|
|
function buildCommand(name, input) {
|
|
if (!input || typeof input !== "object" || Array.isArray(input)) fail("arguments must be an object");
|
|
switch (name) {
|
|
case "autodl_account": return buildAccount(input);
|
|
case "autodl_bind": return buildBind(input);
|
|
case "autodl_doctor": return { args: ["doctor"], cwd: projectCwd(input) };
|
|
case "autodl_model_dir": return { args: ["model-dir", ...(input.create === true ? ["--mkdir"] : [])], cwd: projectCwd(input) };
|
|
case "autodl_file": return buildFile(input);
|
|
case "autodl_transfer": return buildTransfer(input);
|
|
case "autodl_exec": return buildExec(input);
|
|
case "autodl_job": return buildJob(input);
|
|
case "autodl_run": return buildRun(input);
|
|
case "autodl_fleet": return buildFleet(input);
|
|
case "autodl_tmux": return buildTmux(input);
|
|
case "autodl_dashboard": {
|
|
const cwd = projectCwd(input);
|
|
const args = ["dashboard"];
|
|
addOption(args, "--fleet", optionalString(input, "fleet"));
|
|
addOption(args, "--output", localProjectPath(input, "output", cwd));
|
|
if (input.lines !== undefined) args.push("--lines", String(integer(input, "lines", 120, 0, 5000)));
|
|
return { args, cwd };
|
|
}
|
|
case "autodl_shutdown": {
|
|
if (input.confirm !== "shutdown") fail("shutdown requires confirm=shutdown");
|
|
const args = ["shutdown"];
|
|
if (input.waitSeconds !== undefined) args.push("--wait", String(integer(input, "waitSeconds", 8, 0, 120)));
|
|
return { args, cwd: projectCwd(input) };
|
|
}
|
|
default: fail(`unknown tool: ${name}`);
|
|
}
|
|
}
|
|
|
|
function appendBounded(state, chunk) {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
const available = maxOutputBytes - state.bytes;
|
|
if (available <= 0) {
|
|
state.truncated = true;
|
|
return;
|
|
}
|
|
const kept = buffer.subarray(0, available);
|
|
state.chunks.push(kept);
|
|
state.bytes += kept.length;
|
|
if (kept.length < buffer.length) state.truncated = true;
|
|
}
|
|
|
|
function displayCommand(args) {
|
|
return args
|
|
.map((arg) => (/^[A-Za-z0-9_./:=+-]+$/.test(arg) ? arg : JSON.stringify(arg)))
|
|
.join(" ");
|
|
}
|
|
|
|
function runCli(args, cwd) {
|
|
return new Promise((resolve) => {
|
|
const started = Date.now();
|
|
const stdoutState = { chunks: [], bytes: 0, truncated: false };
|
|
const stderrState = { chunks: [], bytes: 0, truncated: false };
|
|
let settled = false;
|
|
const child = spawn(cliPath, args, {
|
|
cwd,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
activeChildren.add(child);
|
|
child.stdout.on("data", (chunk) => appendBounded(stdoutState, chunk));
|
|
child.stderr.on("data", (chunk) => appendBounded(stderrState, chunk));
|
|
|
|
const finish = (exitCode, spawnError) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
activeChildren.delete(child);
|
|
if (spawnError) appendBounded(stderrState, Buffer.from(spawnError.message));
|
|
resolve({
|
|
ok: exitCode === 0,
|
|
command: displayCommand(args),
|
|
cwd,
|
|
exitCode,
|
|
stdout: Buffer.concat(stdoutState.chunks).toString("utf8"),
|
|
stderr: Buffer.concat(stderrState.chunks).toString("utf8"),
|
|
stdoutTruncated: stdoutState.truncated,
|
|
stderrTruncated: stderrState.truncated,
|
|
durationMs: Date.now() - started,
|
|
});
|
|
};
|
|
child.on("error", (error) => finish(127, error));
|
|
child.on("close", (code) => finish(code ?? 1));
|
|
});
|
|
}
|
|
|
|
async function callTool(name, input) {
|
|
if (!toolNames.has(name)) fail(`unknown tool: ${name}`);
|
|
const { args, cwd } = buildCommand(name, input);
|
|
const payload = await runCli(args, cwd);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
structuredContent: payload,
|
|
isError: !payload.ok,
|
|
};
|
|
}
|
|
|
|
function send(message) {
|
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
}
|
|
|
|
async function handleRequest(request) {
|
|
switch (request.method) {
|
|
case "initialize":
|
|
return {
|
|
protocolVersion:
|
|
request.params?.protocolVersion === PROTOCOL_VERSION
|
|
? request.params.protocolVersion
|
|
: PROTOCOL_VERSION,
|
|
capabilities: { tools: { listChanged: false } },
|
|
serverInfo: { name: "autodl-remote", version: SERVER_VERSION },
|
|
instructions:
|
|
"Operate AutoDL projects through the existing autodl-remote CLI. Provide an absolute projectRoot for project tools. Prefer read-only actions before mutations; destructive actions require explicit confirmation.",
|
|
};
|
|
case "ping": return {};
|
|
case "tools/list": return { tools };
|
|
case "tools/call": return callTool(request.params?.name, request.params?.arguments || {});
|
|
default:
|
|
throw Object.assign(new Error(`Method not found: ${request.method}`), { code: -32601 });
|
|
}
|
|
}
|
|
|
|
const input = readline.createInterface({ input: process.stdin });
|
|
input.on("line", async (line) => {
|
|
if (!line.trim()) return;
|
|
let request;
|
|
try {
|
|
request = JSON.parse(line);
|
|
} catch (error) {
|
|
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: error.message } });
|
|
return;
|
|
}
|
|
if (request.id === undefined) return;
|
|
try {
|
|
const result = await handleRequest(request);
|
|
send({ jsonrpc: "2.0", id: request.id, result });
|
|
} catch (error) {
|
|
send({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
error: { code: error.code || -32603, message: error.message },
|
|
});
|
|
}
|
|
});
|
|
|
|
function shutdown() {
|
|
for (const child of activeChildren) child.kill("SIGTERM");
|
|
}
|
|
|
|
process.stdout.on("error", (error) => {
|
|
shutdown();
|
|
if (error.code !== "EPIPE") process.stderr.write(`${error.stack || error.message}\n`);
|
|
process.exit(error.code === "EPIPE" ? 0 : 1);
|
|
});
|
|
process.on("SIGINT", () => {
|
|
shutdown();
|
|
process.exit(0);
|
|
});
|
|
process.on("SIGTERM", () => {
|
|
shutdown();
|
|
process.exit(0);
|
|
});
|