842 lines
28 KiB
TypeScript
842 lines
28 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { type UniDeskConfig, repoRoot } from "./config";
|
|
|
|
export interface ParsedSshArgs {
|
|
remoteCommand: string | null;
|
|
requiresStdin: boolean;
|
|
}
|
|
|
|
const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
|
|
|
|
const remoteApplyPatchSource = String.raw`#!/usr/bin/env python3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def die(message):
|
|
print(f"apply_patch: {message}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def write_file(path, text):
|
|
target = Path(path)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(text)
|
|
|
|
|
|
def move_file(source, destination):
|
|
source_path = Path(source)
|
|
destination_path = Path(destination)
|
|
if not source_path.exists():
|
|
die(f"file not found: {source}")
|
|
if destination_path.exists():
|
|
die(f"target file already exists: {destination}")
|
|
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
source_path.rename(destination_path)
|
|
|
|
|
|
def read_file(path):
|
|
try:
|
|
return Path(path).read_text()
|
|
except FileNotFoundError:
|
|
die(f"file not found: {path}")
|
|
|
|
|
|
def apply_update(path, body):
|
|
old = read_file(path)
|
|
output = []
|
|
search_from = 0
|
|
index = 0
|
|
while index < len(body):
|
|
if body[index].startswith("*** End of File"):
|
|
index += 1
|
|
continue
|
|
if not body[index].startswith("@@"):
|
|
die(f"expected hunk header in {path}")
|
|
index += 1
|
|
sequence = []
|
|
while index < len(body) and not body[index].startswith("@@"):
|
|
line = body[index]
|
|
index += 1
|
|
if line.startswith("*** End of File"):
|
|
continue
|
|
if line.startswith(" "):
|
|
sequence.append((" ", line[1:]))
|
|
elif line.startswith("-"):
|
|
sequence.append(("-", line[1:]))
|
|
elif line.startswith("+"):
|
|
sequence.append(("+", line[1:]))
|
|
elif line.rstrip("\n") == r"\ No newline at end of file":
|
|
continue
|
|
else:
|
|
die(f"bad hunk line in {path}: {line[:80].rstrip()}")
|
|
search = "".join(text for kind, text in sequence if kind in (" ", "-"))
|
|
replacement = "".join(text for kind, text in sequence if kind in (" ", "+"))
|
|
offset = old.find(search, search_from)
|
|
if offset < 0:
|
|
offset = old.find(search)
|
|
if offset < 0:
|
|
die(f"hunk context not found in {path}")
|
|
output.append(old[search_from:offset])
|
|
output.append(replacement)
|
|
search_from = offset + len(search)
|
|
output.append(old[search_from:])
|
|
write_file(path, "".join(output))
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
|
|
print("apply_patch: read *** Begin Patch format from stdin; supports add/update/delete/move")
|
|
return
|
|
lines = sys.stdin.read().splitlines(keepends=True)
|
|
if not lines or lines[0].strip() != "*** Begin Patch":
|
|
die("patch must start with *** Begin Patch")
|
|
index = 1
|
|
while index < len(lines):
|
|
line = lines[index].rstrip("\n")
|
|
if line == "*** End Patch":
|
|
print("Done!")
|
|
return
|
|
if line.startswith("*** Add File: "):
|
|
path = line[len("*** Add File: "):].strip()
|
|
index += 1
|
|
content = []
|
|
while index < len(lines) and not lines[index].startswith("*** "):
|
|
if not lines[index].startswith("+"):
|
|
die(f"add file lines must start with + for {path}")
|
|
content.append(lines[index][1:])
|
|
index += 1
|
|
if Path(path).exists():
|
|
die(f"file already exists: {path}")
|
|
write_file(path, "".join(content))
|
|
continue
|
|
if line.startswith("*** Delete File: "):
|
|
path = line[len("*** Delete File: "):].strip()
|
|
try:
|
|
Path(path).unlink()
|
|
except FileNotFoundError:
|
|
die(f"file not found: {path}")
|
|
index += 1
|
|
continue
|
|
if line.startswith("*** Update File: "):
|
|
path = line[len("*** Update File: "):].strip()
|
|
index += 1
|
|
move_to = None
|
|
if index < len(lines) and lines[index].startswith("*** Move to: "):
|
|
move_to = lines[index][len("*** Move to: "):].strip()
|
|
index += 1
|
|
body = []
|
|
while index < len(lines) and not (
|
|
lines[index].startswith("*** Add File: ")
|
|
or lines[index].startswith("*** Update File: ")
|
|
or lines[index].startswith("*** Delete File: ")
|
|
or lines[index].startswith("*** End Patch")
|
|
):
|
|
if lines[index].startswith("*** Move to: "):
|
|
die("move marker must appear before update hunks")
|
|
body.append(lines[index])
|
|
index += 1
|
|
if body:
|
|
apply_update(path, body)
|
|
elif move_to is None and not Path(path).exists():
|
|
die(f"file not found: {path}")
|
|
if move_to is not None:
|
|
move_file(path, move_to)
|
|
continue
|
|
die(f"unexpected patch line: {line}")
|
|
die("missing *** End Patch")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
`;
|
|
|
|
const remoteGlobSource = String.raw`#!/usr/bin/env python3
|
|
import argparse
|
|
import glob
|
|
import os
|
|
import sys
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="remote glob helper for UniDesk ssh passthrough")
|
|
parser.add_argument("patterns", nargs="*", help="glob patterns relative to --root unless absolute")
|
|
parser.add_argument("--root", default=".", help="base directory for relative patterns")
|
|
parser.add_argument("--pattern", action="append", default=[], help="additional glob pattern")
|
|
parser.add_argument("--contains", action="append", default=[], help="match path names containing text")
|
|
parser.add_argument("--icontains", action="append", default=[], help="case-insensitive contains match")
|
|
parser.add_argument("--type", choices=["any", "f", "d"], default="any", help="filter by any/file/dir")
|
|
parser.add_argument("--limit", type=int, default=0, help="maximum number of rows to print")
|
|
parser.add_argument("--sort", action="store_true", help="sort output")
|
|
parser.add_argument("--absolute", action="store_true", help="print absolute paths")
|
|
args = parser.parse_args()
|
|
|
|
if args.limit < 0:
|
|
print("glob: --limit must be >= 0", file=sys.stderr)
|
|
return 2
|
|
|
|
root = os.path.abspath(args.root)
|
|
patterns = list(args.patterns) + list(args.pattern)
|
|
for text in args.contains:
|
|
patterns.append(f"**/*{text}*")
|
|
for text in args.icontains:
|
|
# Python glob is case-sensitive on Linux, so filter from a broad recursive scan.
|
|
patterns.append("**/*")
|
|
if not patterns:
|
|
patterns = ["*"]
|
|
|
|
seen = set()
|
|
rows = []
|
|
lowered_contains = [text.lower() for text in args.icontains]
|
|
for pattern in patterns:
|
|
effective = pattern if os.path.isabs(pattern) else os.path.join(root, pattern)
|
|
for path in glob.iglob(effective, recursive=True):
|
|
full = os.path.abspath(path)
|
|
if full in seen:
|
|
continue
|
|
if lowered_contains and not any(text in os.path.basename(full).lower() or text in full.lower() for text in lowered_contains):
|
|
continue
|
|
if args.type == "f" and not os.path.isfile(full):
|
|
continue
|
|
if args.type == "d" and not os.path.isdir(full):
|
|
continue
|
|
seen.add(full)
|
|
rows.append(full if args.absolute else os.path.relpath(full, root))
|
|
|
|
if args.sort:
|
|
rows.sort()
|
|
if args.limit > 0:
|
|
rows = rows[:args.limit]
|
|
for row in rows:
|
|
print(row)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
`;
|
|
|
|
const remoteSkillDiscoverSource = String.raw`#!/usr/bin/env python3
|
|
import argparse
|
|
import getpass
|
|
import json
|
|
import os
|
|
import platform
|
|
import socket
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
SKIP_PARTS = {"node_modules", ".git", ".state", "logs", "references", "__pycache__"}
|
|
|
|
|
|
def is_wsl():
|
|
try:
|
|
release = Path("/proc/sys/kernel/osrelease").read_text(errors="ignore").lower()
|
|
except Exception:
|
|
release = ""
|
|
return "microsoft" in release or "wsl" in release or "WSL_INTEROP" in os.environ
|
|
|
|
|
|
def to_windows_path(path):
|
|
text = str(path)
|
|
if text.startswith("/mnt/") and len(text) >= 7 and text[5].isalpha() and text[6] == "/":
|
|
drive = text[5].upper()
|
|
rest = text[7:].replace("/", "\\")
|
|
return drive + ":\\" + rest
|
|
return None
|
|
|
|
|
|
def read_bounded(path, limit=16384):
|
|
try:
|
|
data = path.read_bytes()[:limit]
|
|
return data.decode("utf-8", errors="replace")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def frontmatter_value(line):
|
|
if ":" not in line:
|
|
return None
|
|
key, value = line.split(":", 1)
|
|
return key.strip().lower(), value.strip().strip("\"'")
|
|
|
|
|
|
def parse_skill_metadata(skill_md):
|
|
text = read_bounded(skill_md)
|
|
name = skill_md.parent.name
|
|
description = ""
|
|
lines = text.splitlines()
|
|
if lines and lines[0].strip() == "---":
|
|
for line in lines[1:]:
|
|
if line.strip() == "---":
|
|
break
|
|
item = frontmatter_value(line)
|
|
if item is None:
|
|
continue
|
|
key, value = item
|
|
if key == "name" and value:
|
|
name = value
|
|
if key == "description" and value:
|
|
description = value
|
|
if not description:
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("---") and not stripped.startswith("#"):
|
|
description = stripped
|
|
break
|
|
return name, description
|
|
|
|
|
|
def iter_skill_files(root, max_depth):
|
|
try:
|
|
iterator = root.rglob("SKILL.md")
|
|
for skill_md in iterator:
|
|
try:
|
|
rel = skill_md.relative_to(root)
|
|
except ValueError:
|
|
continue
|
|
directory_parts = rel.parts[:-1]
|
|
if len(directory_parts) == 0 or len(directory_parts) > max_depth:
|
|
continue
|
|
if any(part in SKIP_PARTS for part in directory_parts):
|
|
continue
|
|
yield skill_md
|
|
except Exception as exc:
|
|
raise RuntimeError(str(exc)) from exc
|
|
|
|
|
|
def unique_paths(paths):
|
|
seen = set()
|
|
output = []
|
|
for raw in paths:
|
|
path = Path(raw).expanduser()
|
|
key = str(path)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
output.append(path)
|
|
return output
|
|
|
|
|
|
def default_wsl_roots():
|
|
home = Path.home()
|
|
roots = [home / ".agents" / "skills", home / ".codex" / "skills"]
|
|
for raw in ("/root/.agents/skills", "/root/.codex/skills"):
|
|
path = Path(raw)
|
|
if str(path) not in {str(item) for item in roots}:
|
|
roots.append(path)
|
|
return roots
|
|
|
|
|
|
def default_windows_roots():
|
|
if not is_wsl():
|
|
return []
|
|
users = Path("/mnt/c/Users")
|
|
roots = []
|
|
try:
|
|
children = list(users.iterdir()) if users.exists() else []
|
|
except Exception:
|
|
children = []
|
|
for child in children:
|
|
try:
|
|
if not child.is_dir():
|
|
continue
|
|
except Exception:
|
|
continue
|
|
lower = child.name.lower()
|
|
if lower in {"all users", "default", "default user", "public"}:
|
|
continue
|
|
roots.append(child / ".agents" / "skills")
|
|
roots.append(child / ".codex" / "skills")
|
|
return roots
|
|
|
|
|
|
def scan_root(scope, root, max_depth):
|
|
try:
|
|
root_exists = root.exists()
|
|
root_error = None
|
|
except Exception as exc:
|
|
root_exists = False
|
|
root_error = str(exc)
|
|
record = {
|
|
"scope": scope,
|
|
"path": str(root),
|
|
"windowsPath": to_windows_path(root),
|
|
"exists": root_exists,
|
|
"skillCount": 0,
|
|
"error": root_error,
|
|
}
|
|
skills = []
|
|
if not record["exists"]:
|
|
return record, skills
|
|
try:
|
|
for skill_md in iter_skill_files(root, max_depth):
|
|
name, description = parse_skill_metadata(skill_md)
|
|
skill = {
|
|
"scope": scope,
|
|
"name": name,
|
|
"description": description,
|
|
"path": str(skill_md.parent),
|
|
"skillMd": str(skill_md),
|
|
"windowsPath": to_windows_path(skill_md.parent),
|
|
"root": str(root),
|
|
}
|
|
skills.append(skill)
|
|
except Exception as exc:
|
|
record["error"] = str(exc)
|
|
record["skillCount"] = len(skills)
|
|
return record, skills
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="discover WSL/Linux and Windows skill directories from a UniDesk ssh passthrough session")
|
|
parser.add_argument("--scope", choices=["all", "wsl", "windows"], default="all", help="which skill roots to scan")
|
|
parser.add_argument("--max-depth", type=int, default=4, help="maximum directory depth below each skill root")
|
|
parser.add_argument("--limit", type=int, default=0, help="maximum skill rows to return; 0 means unlimited")
|
|
parser.add_argument("--root", action="append", default=[], help="extra WSL/Linux skill root")
|
|
parser.add_argument("--windows-root", action="append", default=[], help="extra Windows skill root, expressed as /mnt/<drive>/...")
|
|
args = parser.parse_args()
|
|
|
|
if args.max_depth <= 0:
|
|
print(json.dumps({"ok": False, "error": "--max-depth must be positive"}, ensure_ascii=False))
|
|
return 2
|
|
if args.limit < 0:
|
|
print(json.dumps({"ok": False, "error": "--limit must be >= 0"}, ensure_ascii=False))
|
|
return 2
|
|
|
|
roots = []
|
|
if args.scope in ("all", "wsl"):
|
|
roots.extend(("wsl", path) for path in default_wsl_roots())
|
|
roots.extend(("wsl", Path(raw).expanduser()) for raw in args.root)
|
|
if args.scope in ("all", "windows"):
|
|
roots.extend(("windows", path) for path in default_windows_roots())
|
|
roots.extend(("windows", Path(raw).expanduser()) for raw in args.windows_root)
|
|
|
|
seen = set()
|
|
unique = []
|
|
for scope, path in roots:
|
|
key = (scope, str(path))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
unique.append((scope, path))
|
|
|
|
root_records = []
|
|
skills = []
|
|
for scope, root in unique:
|
|
record, found = scan_root(scope, root, args.max_depth)
|
|
root_records.append(record)
|
|
skills.extend(found)
|
|
|
|
scope_order = {"wsl": 0, "windows": 1}
|
|
skills.sort(key=lambda item: (scope_order.get(str(item["scope"]), 9), str(item["name"]).lower(), str(item["path"])))
|
|
total_before_limit = len(skills)
|
|
if args.limit > 0:
|
|
skills = skills[:args.limit]
|
|
|
|
counts = {"total": len(skills), "totalBeforeLimit": total_before_limit, "wsl": 0, "windows": 0}
|
|
for skill in skills:
|
|
scope = str(skill["scope"])
|
|
if scope in counts:
|
|
counts[scope] += 1
|
|
|
|
payload = {
|
|
"ok": True,
|
|
"command": "unidesk ssh skills",
|
|
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
"node": {
|
|
"hostname": socket.gethostname(),
|
|
"user": getpass.getuser(),
|
|
"home": str(Path.home()),
|
|
"platform": platform.platform(),
|
|
"isWsl": is_wsl(),
|
|
"python": sys.version.split()[0],
|
|
},
|
|
"counts": counts,
|
|
"roots": root_records,
|
|
"skills": skills,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
`;
|
|
|
|
const sshOptionsWithValue = new Set([
|
|
"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
|
|
]);
|
|
|
|
export function isSshSkillDiscoveryArgs(args: string[]): boolean {
|
|
const subcommand = args[0] ?? "";
|
|
return subcommand === "skills" || subcommand === "skill-discover" || subcommand === "discover-skills" || (subcommand === "skill" && args[1] === "discover");
|
|
}
|
|
|
|
export function parseSshArgs(args: string[]): ParsedSshArgs {
|
|
const subcommand = args[0] ?? "";
|
|
if (isSshSkillDiscoveryArgs(args)) {
|
|
const toolArgs = subcommand === "skill" ? ["skill-discover", ...args.slice(2)] : ["skill-discover", ...args.slice(1)];
|
|
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false };
|
|
}
|
|
if (subcommand === "apply-patch" || subcommand === "patch") {
|
|
const toolArgs = ["apply_patch", ...args.slice(1)];
|
|
return { remoteCommand: shellArgv(toolArgs), requiresStdin: true };
|
|
}
|
|
if (subcommand === "py") {
|
|
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true };
|
|
}
|
|
if (subcommand === "argv" || subcommand === "exec") {
|
|
const toolArgs = args.slice(1);
|
|
if (toolArgs.length === 0) throw new Error(`ssh ${subcommand} requires a command`);
|
|
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false };
|
|
}
|
|
if (subcommand === "find") {
|
|
return { remoteCommand: buildFindCommand(args.slice(1)), requiresStdin: false };
|
|
}
|
|
if (subcommand === "glob") {
|
|
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false };
|
|
}
|
|
if (argvQuotedSshSubcommands.has(subcommand)) {
|
|
return { remoteCommand: shellArgv(args), requiresStdin: false };
|
|
}
|
|
const remote: string[] = [];
|
|
let remoteStarted = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (remoteStarted) {
|
|
remote.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "--") {
|
|
remoteStarted = true;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-") && arg !== "-") {
|
|
if (sshOptionsWithValue.has(arg) && index + 1 < args.length) index += 1;
|
|
continue;
|
|
}
|
|
remoteStarted = true;
|
|
remote.push(arg);
|
|
}
|
|
return { remoteCommand: remote.length === 0 ? null : remote.join(" "), requiresStdin: false };
|
|
}
|
|
|
|
function shellArgv(args: string[]): string {
|
|
return args.map(shellQuote).join(" ");
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
function positiveInt(value: string, option: string): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`);
|
|
return parsed;
|
|
}
|
|
|
|
function findOptionValue(args: string[], index: number, option: string): string {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error(`ssh find ${option} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function buildFindCommand(args: string[]): string {
|
|
const paths: string[] = [];
|
|
const predicates: string[] = [];
|
|
const patternPredicates: Array<[string, string]> = [];
|
|
let limit: number | null = null;
|
|
let sortOutput = false;
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "--limit") {
|
|
const value = findOptionValue(args, index, arg);
|
|
limit = positiveInt(value, "ssh find --limit");
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--sort") {
|
|
sortOutput = true;
|
|
continue;
|
|
}
|
|
if (arg === "--max-depth" || arg === "-maxdepth" || arg === "--min-depth" || arg === "-mindepth") {
|
|
const value = findOptionValue(args, index, arg);
|
|
const findArg = arg === "--max-depth" ? "-maxdepth" : arg === "--min-depth" ? "-mindepth" : arg;
|
|
predicates.push(findArg, String(positiveInt(value, `ssh find ${arg}`)));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--type" || arg === "-type") {
|
|
const value = findOptionValue(args, index, arg);
|
|
if (!/^[bcdpfls]$/u.test(value)) throw new Error("ssh find --type must be one of: b c d p f l s");
|
|
predicates.push("-type", value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--name" || arg === "-name" || arg === "--iname" || arg === "-iname" || arg === "--path" || arg === "-path" || arg === "--ipath" || arg === "-ipath") {
|
|
const value = findOptionValue(args, index, arg);
|
|
const findArg = arg.startsWith("--") ? `-${arg.slice(2)}` : arg;
|
|
patternPredicates.push([findArg, value]);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--contains" || arg === "--icontains") {
|
|
const value = findOptionValue(args, index, arg);
|
|
const findArg = arg === "--contains" ? "-name" : "-iname";
|
|
patternPredicates.push([findArg, `*${value}*`]);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--mtime" || arg === "-mtime" || arg === "--mmin" || arg === "-mmin" || arg === "--size" || arg === "-size") {
|
|
const value = findOptionValue(args, index, arg);
|
|
const findArg = arg.startsWith("--") ? `-${arg.slice(2)}` : arg;
|
|
predicates.push(findArg, value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-")) {
|
|
throw new Error(`unsupported ssh find option: ${arg}`);
|
|
}
|
|
paths.push(arg);
|
|
}
|
|
|
|
const findArgs = ["find", ...(paths.length > 0 ? paths : ["."]), ...predicates];
|
|
if (patternPredicates.length === 1) {
|
|
const [kind, pattern] = patternPredicates[0]!;
|
|
findArgs.push(kind, pattern);
|
|
} else if (patternPredicates.length > 1) {
|
|
findArgs.push("(");
|
|
patternPredicates.forEach(([kind, pattern], index) => {
|
|
if (index > 0) findArgs.push("-o");
|
|
findArgs.push(kind, pattern);
|
|
});
|
|
findArgs.push(")");
|
|
}
|
|
findArgs.push("-print");
|
|
|
|
let command = shellArgv(findArgs);
|
|
if (sortOutput) command = `${command} | sort`;
|
|
if (limit !== null) command = `${command} | head -n ${limit}`;
|
|
return command;
|
|
}
|
|
|
|
function buildPythonStdinCommand(args: string[]): string {
|
|
const pythonArgs = args.map(shellQuote).join(" ");
|
|
const execArgs = pythonArgs.length > 0 ? ` "$UNIDESK_SSH_PY_FILE" ${pythonArgs}` : ' "$UNIDESK_SSH_PY_FILE"';
|
|
return [
|
|
'UNIDESK_SSH_PY_FILE="$(mktemp /tmp/unidesk-ssh-py.XXXXXX.py)" || exit 1',
|
|
`trap 'rm -f "$UNIDESK_SSH_PY_FILE"' EXIT`,
|
|
'cat > "$UNIDESK_SSH_PY_FILE"',
|
|
`python3 -u${execArgs}`,
|
|
].join("; ");
|
|
}
|
|
|
|
function remoteToolBootstrapCommand(): string {
|
|
const encodedApplyPatch = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64");
|
|
const encodedGlob = Buffer.from(remoteGlobSource, "utf8").toString("base64");
|
|
const encodedSkillDiscover = Buffer.from(remoteSkillDiscoverSource, "utf8").toString("base64");
|
|
return [
|
|
"UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools",
|
|
'mkdir -p "$UNIDESK_SSH_TOOL_DIR"',
|
|
`printf %s ${shellQuote(encodedApplyPatch)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/apply_patch"`,
|
|
`printf %s ${shellQuote(encodedGlob)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/glob"`,
|
|
`printf %s ${shellQuote(encodedSkillDiscover)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/skill-discover"`,
|
|
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"',
|
|
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/glob"',
|
|
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/skill-discover"',
|
|
'export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"',
|
|
].join("; ");
|
|
}
|
|
|
|
export function wrapSshRemoteCommand(command: string | null): string {
|
|
const bootstrap = remoteToolBootstrapCommand();
|
|
if (command === null) return `${bootstrap}; exec "\${SHELL:-/bin/bash}" -l`;
|
|
return `${bootstrap}; stty -echo 2>/dev/null || true; ${command}`;
|
|
}
|
|
|
|
function brokerSource(): string {
|
|
return String.raw`
|
|
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
|
|
const token = process.env.PROVIDER_TOKEN || "";
|
|
const url = "ws://127.0.0.1:8080/ws/ssh?token=" + encodeURIComponent(token);
|
|
const ws = new WebSocket(url);
|
|
let exitCode = 255;
|
|
let canSend = false;
|
|
let sessionReady = false;
|
|
let opened = false;
|
|
const pending = [];
|
|
const pendingInput = [];
|
|
const openTimer = setTimeout(() => {
|
|
if (opened) return;
|
|
process.stderr.write("unidesk ssh bridge timed out waiting for provider session\n");
|
|
try { ws.close(); } catch {}
|
|
process.exit(255);
|
|
}, Number(open.openTimeoutMs || 15000));
|
|
|
|
function send(value) {
|
|
const text = JSON.stringify(value);
|
|
if (!canSend || ws.readyState !== WebSocket.OPEN) {
|
|
pending.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
}
|
|
|
|
function flush() {
|
|
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(pending.shift());
|
|
}
|
|
}
|
|
|
|
function sendInput(value) {
|
|
const text = JSON.stringify(value);
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) {
|
|
pendingInput.push(text);
|
|
return;
|
|
}
|
|
ws.send(text);
|
|
}
|
|
|
|
function flushInput() {
|
|
if (!sessionReady || ws.readyState !== WebSocket.OPEN) return;
|
|
while (pendingInput.length > 0) {
|
|
ws.send(pendingInput.shift());
|
|
}
|
|
}
|
|
|
|
function decodeData(data) {
|
|
return typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
}
|
|
|
|
ws.addEventListener("open", () => {
|
|
canSend = true;
|
|
send({
|
|
type: "ssh.open",
|
|
providerId: open.providerId,
|
|
command: open.command || undefined,
|
|
cwd: open.cwd || undefined,
|
|
tty: open.tty === true,
|
|
cols: open.cols || 100,
|
|
rows: open.rows || 30,
|
|
});
|
|
flush();
|
|
});
|
|
|
|
ws.addEventListener("message", (event) => {
|
|
const message = JSON.parse(decodeData(event.data));
|
|
if (message.type === "ssh.data") {
|
|
opened = true;
|
|
const chunk = Buffer.from(message.data || "", "base64");
|
|
if (message.stream === "stderr") process.stderr.write(chunk);
|
|
else process.stdout.write(chunk);
|
|
return;
|
|
}
|
|
if (message.type === "ssh.opened") {
|
|
opened = true;
|
|
sessionReady = true;
|
|
clearTimeout(openTimer);
|
|
if (open.stdinEotOnEnd === true) setTimeout(flushInput, 200);
|
|
else flushInput();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.dispatched") {
|
|
return;
|
|
}
|
|
if (message.type === "ssh.error") {
|
|
clearTimeout(openTimer);
|
|
process.stderr.write(String(message.message || "ssh bridge error") + "\n");
|
|
exitCode = 255;
|
|
ws.close();
|
|
return;
|
|
}
|
|
if (message.type === "ssh.exit") {
|
|
clearTimeout(openTimer);
|
|
exitCode = Number.isInteger(message.exitCode) ? message.exitCode : 255;
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
ws.addEventListener("close", () => {
|
|
process.exit(exitCode);
|
|
});
|
|
|
|
ws.addEventListener("error", () => {
|
|
process.stderr.write("unidesk ssh bridge websocket error\n");
|
|
process.exit(255);
|
|
});
|
|
|
|
process.stdin.on("data", (chunk) => {
|
|
sendInput({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" });
|
|
});
|
|
process.stdin.on("end", () => {
|
|
if (open.stdinEotOnEnd === true) {
|
|
sendInput({ type: "ssh.input", data: Buffer.from([4]).toString("base64"), encoding: "base64" });
|
|
}
|
|
sendInput({ type: "ssh.eof" });
|
|
});
|
|
`;
|
|
}
|
|
|
|
function terminalSize(): { cols: number; rows: number } {
|
|
return {
|
|
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
|
|
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
|
|
};
|
|
}
|
|
|
|
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
|
|
if (!providerId) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D518");
|
|
const parsed = parseSshArgs(args);
|
|
const size = terminalSize();
|
|
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
|
|
const payload = {
|
|
providerId,
|
|
command: wrapSshRemoteCommand(parsed.remoteCommand),
|
|
tty: parsed.remoteCommand === null,
|
|
stdinEotOnEnd: parsed.remoteCommand !== null,
|
|
openTimeoutMs,
|
|
cols: size.cols,
|
|
rows: size.rows,
|
|
};
|
|
const child = spawn("docker", [
|
|
"exec",
|
|
"-i",
|
|
"unidesk-backend-core",
|
|
"bun",
|
|
"-e",
|
|
brokerSource(),
|
|
"--",
|
|
JSON.stringify(payload),
|
|
], {
|
|
cwd: repoRoot,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
|
|
const rawMode = parsed.remoteCommand === null && process.stdin.isTTY;
|
|
if (rawMode) process.stdin.setRawMode(true);
|
|
process.stdin.resume();
|
|
process.stdin.pipe(child.stdin);
|
|
child.stdout.pipe(process.stdout);
|
|
child.stderr.pipe(process.stderr);
|
|
|
|
return await new Promise<number>((resolve) => {
|
|
const restore = (): void => {
|
|
process.stdin.unpipe(child.stdin);
|
|
if (rawMode) process.stdin.setRawMode(false);
|
|
};
|
|
child.on("error", (error) => {
|
|
restore();
|
|
process.stderr.write(`unidesk ssh failed to start broker: ${error.message}\n`);
|
|
resolve(255);
|
|
});
|
|
child.on("close", (code) => {
|
|
restore();
|
|
resolve(code ?? 255);
|
|
});
|
|
});
|
|
}
|