1858 lines
66 KiB
TypeScript
1858 lines
66 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { type UniDeskConfig, repoRoot } from "./config";
|
|
|
|
export interface ParsedSshArgs {
|
|
remoteCommand: string | null;
|
|
requiresStdin: boolean;
|
|
invocationKind: SshInvocationKind;
|
|
stdinPrefix?: string;
|
|
stdinSuffix?: string;
|
|
requiredHelpers?: SshHelperName[];
|
|
}
|
|
|
|
export type SshInvocationKind = "interactive" | "argv" | "helper" | "ssh-like";
|
|
export type SshHelperName = "apply_patch" | "glob" | "skill-discover";
|
|
|
|
export interface ParsedSshRoute {
|
|
providerId: string;
|
|
plane: "host" | "k3s";
|
|
entry: string | null;
|
|
namespace: string | null;
|
|
resource: string | null;
|
|
container: string | null;
|
|
workspace: string | null;
|
|
raw: string;
|
|
}
|
|
|
|
export interface ParsedSshInvocation {
|
|
providerId: string;
|
|
route: ParsedSshRoute;
|
|
parsed: ParsedSshArgs;
|
|
}
|
|
|
|
export interface SshFailureHint {
|
|
code: "ssh-like-command-friction";
|
|
providerId: string;
|
|
trigger: "timeout-or-kex" | "exit-255";
|
|
exitCode: number;
|
|
message: string;
|
|
try: string;
|
|
triage: string;
|
|
note: string;
|
|
}
|
|
|
|
export interface SshRuntimeTimingHint {
|
|
code: "ssh-runtime-timing";
|
|
level: "info" | "warning";
|
|
providerId: string;
|
|
route: string;
|
|
transport: "backend-core-broker" | "frontend-websocket";
|
|
invocationKind: SshInvocationKind;
|
|
exitCode: number;
|
|
elapsedMs: number;
|
|
elapsedSeconds: number;
|
|
thresholdMs: number;
|
|
slow: boolean;
|
|
message: string;
|
|
note: string;
|
|
}
|
|
|
|
const argvQuotedSshSubcommands = new Set(["git", "rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
|
|
const nativeK3sKubeconfig = "/etc/rancher/k3s/k3s.yaml";
|
|
const defaultSshSlowWarningMs = 10_000;
|
|
const k3sResourceKindAliases = new Set(["pod", "po", "pods", "deployment", "deploy", "deployments", "statefulset", "sts", "daemonset", "ds", "job", "jobs"]);
|
|
const legacyK3sOperationRouteSegments = new Set([
|
|
"guard",
|
|
"kubectl",
|
|
"exec",
|
|
"script",
|
|
"apply-patch",
|
|
"logs",
|
|
"get",
|
|
"describe",
|
|
"top",
|
|
"rollout",
|
|
"wait",
|
|
"config",
|
|
"version",
|
|
"cluster-info",
|
|
"auth",
|
|
"api-resources",
|
|
"api-versions",
|
|
]);
|
|
|
|
export const remoteApplyPatchSource = String.raw`#!/bin/sh
|
|
set -eu
|
|
|
|
allow_loose=0
|
|
|
|
die() {
|
|
printf 'apply_patch: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
mk_temp() {
|
|
mktemp "${"$"}{TMPDIR:-/tmp}/unidesk-apply-patch.XXXXXX" || exit 1
|
|
}
|
|
|
|
ensure_parent() {
|
|
case "$1" in
|
|
*/*)
|
|
dir=${"$"}{1%/*}
|
|
[ -n "$dir" ] || dir=/
|
|
mkdir -p "$dir"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
read_text_preserve_newlines() {
|
|
marker="__UNIDESK_APPLY_PATCH_EOF_$$__"
|
|
text=$(cat "$1"; printf '%s' "$marker") || die "failed to read $1"
|
|
printf '%s' "${"$"}{text%"$marker"}"
|
|
}
|
|
|
|
write_file() {
|
|
target=$1
|
|
source=$2
|
|
ensure_parent "$target"
|
|
cp "$source" "$target" || die "failed to write $target"
|
|
}
|
|
|
|
line_number_for_prefix() {
|
|
newline_count=$(printf '%s' "$1" | tr -cd '\n' | wc -c | tr -d ' ')
|
|
printf '%s\n' $((newline_count + 1))
|
|
}
|
|
|
|
replace_once_with_perl() {
|
|
command -v perl >/dev/null 2>&1 || return 127
|
|
perl -0777 -e '
|
|
use strict;
|
|
use warnings;
|
|
|
|
sub fail {
|
|
print STDERR "apply_patch: ", @_, "\n";
|
|
exit 1;
|
|
}
|
|
|
|
sub read_all {
|
|
my ($path, $label) = @_;
|
|
open my $fh, "<", $path or fail("failed to read $label");
|
|
binmode $fh;
|
|
local $/;
|
|
my $data = <$fh>;
|
|
close $fh;
|
|
return defined $data ? $data : "";
|
|
}
|
|
|
|
my ($target, $search_file, $replace_file, $hunk_id, $allow_loose, $out) = @ARGV;
|
|
my $old = read_all($target, $target);
|
|
my $search = read_all($search_file, "hunk search");
|
|
my $replacement = read_all($replace_file, "hunk replacement");
|
|
my $new;
|
|
|
|
if ($search eq "") {
|
|
fail("hunk $hunk_id in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review") if $allow_loose ne "1";
|
|
print STDERR "apply_patch: hunk $hunk_id matched $target:1 (loose)\n";
|
|
$new = $replacement . $old;
|
|
} else {
|
|
my $pos = -1;
|
|
my $offset = 0;
|
|
my $count = 0;
|
|
while (1) {
|
|
my $found = index($old, $search, $offset);
|
|
last if $found < 0;
|
|
$pos = $found if $count == 0;
|
|
$count += 1;
|
|
last if $count > 1 && $allow_loose ne "1";
|
|
$offset = $found + length($search);
|
|
}
|
|
fail("hunk $hunk_id context not found in $target") if $count == 0;
|
|
fail("hunk $hunk_id context matched multiple locations in $target; add more unchanged context or pass --allow-loose after manual review") if $count > 1 && $allow_loose ne "1";
|
|
my $prefix = substr($old, 0, $pos);
|
|
my $suffix = substr($old, $pos + length($search));
|
|
my $line = ($prefix =~ tr/\n//) + 1;
|
|
print STDERR "apply_patch: hunk $hunk_id matched $target:$line\n";
|
|
$new = $prefix . $replacement . $suffix;
|
|
}
|
|
|
|
open my $ofh, ">", $out or fail("failed to render patched file");
|
|
binmode $ofh;
|
|
print $ofh $new or fail("failed to render patched file");
|
|
close $ofh or fail("failed to render patched file");
|
|
' "$@"
|
|
}
|
|
|
|
replace_once() {
|
|
target=$1
|
|
search_file=$2
|
|
replace_file=$3
|
|
hunk_id=$4
|
|
[ -e "$target" ] || die "file not found: $target"
|
|
|
|
fast_out=$(mk_temp)
|
|
if replace_once_with_perl "$target" "$search_file" "$replace_file" "$hunk_id" "$allow_loose" "$fast_out"; then
|
|
write_file "$target" "$fast_out"
|
|
rm -f "$fast_out"
|
|
return 0
|
|
else
|
|
status=$?
|
|
fi
|
|
rm -f "$fast_out"
|
|
[ "$status" = 127 ] || exit "$status"
|
|
|
|
marker="__UNIDESK_APPLY_PATCH_EOF_$$__"
|
|
old=$(cat "$target"; printf '%s' "$marker") || die "failed to read $target"
|
|
old=${"$"}{old%"$marker"}
|
|
search=$(cat "$search_file"; printf '%s' "$marker") || die "failed to read hunk search"
|
|
search=${"$"}{search%"$marker"}
|
|
replacement=$(cat "$replace_file"; printf '%s' "$marker") || die "failed to read hunk replacement"
|
|
replacement=${"$"}{replacement%"$marker"}
|
|
|
|
if [ -z "$search" ]; then
|
|
[ "$allow_loose" = 1 ] || die "hunk $hunk_id in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review"
|
|
printf 'apply_patch: hunk %s matched %s:1 (loose)\n' "$hunk_id" "$target" >&2
|
|
new=$replacement$old
|
|
else
|
|
case "$old" in
|
|
*"$search"*)
|
|
prefix=${"$"}{old%%"$search"*}
|
|
suffix=${"$"}{old#*"$search"}
|
|
case "$suffix" in
|
|
*"$search"*)
|
|
[ "$allow_loose" = 1 ] || die "hunk $hunk_id context matched multiple locations in $target; add more unchanged context or pass --allow-loose after manual review"
|
|
;;
|
|
esac
|
|
printf 'apply_patch: hunk %s matched %s:%s\n' "$hunk_id" "$target" "$(line_number_for_prefix "$prefix")" >&2
|
|
new=$prefix$replacement$suffix
|
|
;;
|
|
*)
|
|
die "hunk $hunk_id context not found in $target"
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
out=$(mk_temp)
|
|
printf '%s' "$new" > "$out" || die "failed to render patched file"
|
|
write_file "$target" "$out"
|
|
rm -f "$out"
|
|
}
|
|
|
|
apply_update() {
|
|
target=$1
|
|
body=$2
|
|
in_hunk=0
|
|
search_file=
|
|
replace_file=
|
|
changed=0
|
|
hunk_number=0
|
|
has_add=0
|
|
has_delete=0
|
|
seen_add=0
|
|
anchor_before_add=0
|
|
anchor_after_add=0
|
|
explicit_eof=0
|
|
|
|
finish_hunk() {
|
|
[ "$in_hunk" = 1 ] || return 0
|
|
if [ "$allow_loose" != 1 ]; then
|
|
[ -s "$search_file" ] || die "hunk $hunk_number in $target has no context; add unchanged/deleted anchor lines or pass --allow-loose after manual review"
|
|
if [ "$has_add" = 1 ] && [ "$has_delete" = 0 ]; then
|
|
if [ "$anchor_before_add" != 1 ] || { [ "$anchor_after_add" != 1 ] && [ "$explicit_eof" != 1 ]; }; then
|
|
die "hunk $hunk_number in $target is insert-only without both leading and trailing context; include nearby unchanged lines after the insertion or pass --allow-loose after manual review"
|
|
fi
|
|
fi
|
|
fi
|
|
replace_once "$target" "$search_file" "$replace_file" "$hunk_number"
|
|
rm -f "$search_file" "$replace_file"
|
|
search_file=
|
|
replace_file=
|
|
in_hunk=0
|
|
changed=1
|
|
}
|
|
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in
|
|
"*** End of File"*)
|
|
continue
|
|
;;
|
|
"@@"*)
|
|
finish_hunk
|
|
search_file=$(mk_temp)
|
|
replace_file=$(mk_temp)
|
|
hunk_number=$((hunk_number + 1))
|
|
has_add=0
|
|
has_delete=0
|
|
seen_add=0
|
|
anchor_before_add=0
|
|
anchor_after_add=0
|
|
explicit_eof=0
|
|
in_hunk=1
|
|
continue
|
|
;;
|
|
esac
|
|
[ "$in_hunk" = 1 ] || die "expected hunk header in $target"
|
|
case "$line" in
|
|
" "*)
|
|
text=${"$"}{line#?}
|
|
printf '%s\n' "$text" >> "$search_file"
|
|
printf '%s\n' "$text" >> "$replace_file"
|
|
if [ "$seen_add" = 1 ]; then anchor_after_add=1; else anchor_before_add=1; fi
|
|
;;
|
|
"-"*)
|
|
text=${"$"}{line#?}
|
|
printf '%s\n' "$text" >> "$search_file"
|
|
has_delete=1
|
|
if [ "$seen_add" = 1 ]; then anchor_after_add=1; else anchor_before_add=1; fi
|
|
;;
|
|
"+"*)
|
|
text=${"$"}{line#?}
|
|
printf '%s\n' "$text" >> "$replace_file"
|
|
has_add=1
|
|
seen_add=1
|
|
;;
|
|
"\\ No newline at end of file")
|
|
;;
|
|
"*** End of File"*)
|
|
explicit_eof=1
|
|
;;
|
|
*)
|
|
die "bad hunk line in $target: $line"
|
|
;;
|
|
esac
|
|
done < "$body"
|
|
|
|
finish_hunk
|
|
[ "$changed" = 1 ] || [ -e "$target" ] || die "file not found: $target"
|
|
}
|
|
|
|
is_top_header() {
|
|
case "$1" in
|
|
"*** Add File: "*|"*** Update File: "*|"*** Delete File: "*|"*** End Patch")
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
pushed=0
|
|
pushed_line=
|
|
next_patch_line() {
|
|
if [ "$pushed" = 1 ]; then
|
|
line=$pushed_line
|
|
pushed=0
|
|
pushed_line=
|
|
return 0
|
|
fi
|
|
if IFS= read -r line; then
|
|
return 0
|
|
fi
|
|
[ -n "${"$"}{line:-}" ]
|
|
}
|
|
|
|
push_patch_line() {
|
|
pushed_line=$1
|
|
pushed=1
|
|
}
|
|
|
|
parse_add_file() {
|
|
target=$1
|
|
[ ! -e "$target" ] || die "file already exists: $target"
|
|
tmp=$(mk_temp)
|
|
while next_patch_line; do
|
|
if is_top_header "$line"; then
|
|
push_patch_line "$line"
|
|
break
|
|
fi
|
|
case "$line" in
|
|
"+"*)
|
|
printf '%s\n' "${"$"}{line#?}" >> "$tmp"
|
|
;;
|
|
*)
|
|
rm -f "$tmp"
|
|
die "add file lines must start with + for $target"
|
|
;;
|
|
esac
|
|
done
|
|
write_file "$target" "$tmp"
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
parse_update_file() {
|
|
target=$1
|
|
move_to=
|
|
body=$(mk_temp)
|
|
if next_patch_line; then
|
|
case "$line" in
|
|
"*** Move to: "*)
|
|
move_to=${"$"}{line#"*** Move to: "}
|
|
;;
|
|
*)
|
|
push_patch_line "$line"
|
|
;;
|
|
esac
|
|
fi
|
|
while next_patch_line; do
|
|
if is_top_header "$line"; then
|
|
push_patch_line "$line"
|
|
break
|
|
fi
|
|
case "$line" in
|
|
"*** Move to: "*)
|
|
rm -f "$body"
|
|
die "move marker must appear before update hunks"
|
|
;;
|
|
*)
|
|
printf '%s\n' "$line" >> "$body"
|
|
;;
|
|
esac
|
|
done
|
|
if [ -s "$body" ]; then
|
|
apply_update "$target" "$body"
|
|
elif [ -z "$move_to" ] && [ ! -e "$target" ]; then
|
|
rm -f "$body"
|
|
die "file not found: $target"
|
|
fi
|
|
rm -f "$body"
|
|
if [ -n "$move_to" ]; then
|
|
[ -e "$target" ] || die "file not found: $target"
|
|
[ ! -e "$move_to" ] || die "target file already exists: $move_to"
|
|
ensure_parent "$move_to"
|
|
mv "$target" "$move_to" || die "failed to move $target to $move_to"
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
while [ "$#" -gt 0 ]; do
|
|
case "${"$"}1" in
|
|
-h|--help)
|
|
printf 'apply_patch: sh-only helper; reads *** Begin Patch format from stdin; --allow-loose bypasses low-context hunk guards\n'
|
|
return 0
|
|
;;
|
|
--allow-loose)
|
|
allow_loose=1
|
|
shift
|
|
;;
|
|
*)
|
|
die "unsupported option: ${"$"}1"
|
|
;;
|
|
esac
|
|
done
|
|
next_patch_line || die "patch must start with *** Begin Patch"
|
|
[ "$line" = "*** Begin Patch" ] || die "patch must start with *** Begin Patch"
|
|
while next_patch_line; do
|
|
case "$line" in
|
|
"*** End Patch")
|
|
printf 'Done!\n'
|
|
return 0
|
|
;;
|
|
"*** Add File: "*)
|
|
parse_add_file "${"$"}{line#"*** Add File: "}"
|
|
;;
|
|
"*** Delete File: "*)
|
|
target=${"$"}{line#"*** Delete File: "}
|
|
rm -f "$target" || die "failed to delete $target"
|
|
;;
|
|
"*** Update File: "*)
|
|
parse_update_file "${"$"}{line#"*** Update File: "}"
|
|
;;
|
|
*)
|
|
die "unexpected patch line: $line"
|
|
;;
|
|
esac
|
|
done
|
|
die "missing *** End Patch"
|
|
}
|
|
|
|
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, invocationKind: "helper", requiredHelpers: ["skill-discover"] };
|
|
}
|
|
if (subcommand === "apply-patch" || subcommand === "patch") {
|
|
const toolArgs = ["apply_patch", ...args.slice(1)];
|
|
return { remoteCommand: shellArgv(toolArgs), requiresStdin: true, invocationKind: "helper", requiredHelpers: ["apply_patch"] };
|
|
}
|
|
if (subcommand === "py") {
|
|
return { remoteCommand: buildPythonStdinCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
|
}
|
|
if (subcommand === "script" || subcommand === "sh") {
|
|
return buildShellCommand(args.slice(1));
|
|
}
|
|
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, invocationKind: "argv" };
|
|
}
|
|
if (subcommand === "find") {
|
|
return { remoteCommand: buildFindCommand(args.slice(1)), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
if (subcommand === "glob") {
|
|
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper", requiredHelpers: ["glob"] };
|
|
}
|
|
if (subcommand === "k3s") {
|
|
throw new Error("ssh k3s shorthand is unsupported; put k3s in the route, for example: ssh D601:k3s kubectl get nodes");
|
|
}
|
|
if (argvQuotedSshSubcommands.has(subcommand)) {
|
|
return { remoteCommand: shellArgv(args), requiresStdin: false, invocationKind: "argv" };
|
|
}
|
|
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,
|
|
invocationKind: remote.length === 0 ? "interactive" : "ssh-like",
|
|
};
|
|
}
|
|
|
|
export function parseSshInvocation(target: string, args: string[]): ParsedSshInvocation {
|
|
const route = parseSshRoute(target);
|
|
if (route.plane === "k3s") {
|
|
return { providerId: route.providerId, route, parsed: parseK3sRouteArgs(route, args) };
|
|
}
|
|
if ((args[0] ?? "") === "k3s") {
|
|
throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: ssh ${route.providerId}:k3s ${args.slice(1).join(" ")}`.trim());
|
|
}
|
|
return { providerId: route.providerId, route, parsed: parseSshArgs(args) };
|
|
}
|
|
|
|
export function parseSshRoute(target: string): ParsedSshRoute {
|
|
if (!target) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D601");
|
|
const firstColon = target.indexOf(":");
|
|
if (firstColon < 0) {
|
|
return hostSshRoute(target, target, null);
|
|
}
|
|
const providerId = target.slice(0, firstColon);
|
|
const tail = target.slice(firstColon + 1);
|
|
if (!providerId) throw new Error("ssh route requires a provider id before ':'");
|
|
if (tail.length === 0) {
|
|
return hostSshRoute(providerId, target, null);
|
|
}
|
|
if (tail.startsWith("/")) {
|
|
return hostSshRoute(providerId, target, tail);
|
|
}
|
|
const [plane, ...rest] = tail.split(":");
|
|
if (plane === undefined || plane.length === 0 || plane === "host") {
|
|
const workspace = rest.length > 0 ? rest.join(":") : null;
|
|
if (workspace !== null && !workspace.startsWith("/")) throw new Error("ssh host workspace route requires an absolute path after provider:host:");
|
|
return hostSshRoute(providerId, target, workspace);
|
|
}
|
|
if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`);
|
|
const [first, second, third, fourth] = rest;
|
|
const operationInRoute = [first, second, third].map((segment) => segment === undefined ? undefined : routeSegmentHead(segment)).find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment));
|
|
if (operationInRoute !== undefined) throw new Error(k3sOperationInRouteMessage(target, operationInRoute));
|
|
if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container");
|
|
const targetRoute = parseK3sRouteTargetSegments(second ?? null, third ?? null);
|
|
return {
|
|
providerId,
|
|
plane: "k3s",
|
|
entry: null,
|
|
namespace: first && first.length > 0 ? first : null,
|
|
resource: targetRoute.resource,
|
|
container: targetRoute.container,
|
|
workspace: targetRoute.workspace,
|
|
raw: target,
|
|
};
|
|
}
|
|
|
|
function hostSshRoute(providerId: string, raw: string, workspace: string | null): ParsedSshRoute {
|
|
return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, workspace, raw };
|
|
}
|
|
|
|
function routeSegmentHead(segment: string): string {
|
|
return segment.split("/")[0] ?? segment;
|
|
}
|
|
|
|
function parseK3sRouteTargetSegments(rawResource: string | null, rawContainer: string | null): { resource: string | null; container: string | null; workspace: string | null } {
|
|
const resourceParts = splitK3sResourceWorkspace(rawResource);
|
|
const containerParts = splitK3sContainerWorkspace(rawContainer);
|
|
return {
|
|
resource: resourceParts.resource,
|
|
container: containerParts.container,
|
|
workspace: combineK3sRouteWorkspace(resourceParts.workspace, containerParts.workspace),
|
|
};
|
|
}
|
|
|
|
function splitK3sResourceWorkspace(value: string | null): { resource: string | null; workspace: string | null } {
|
|
if (value === null || value.length === 0) return { resource: null, workspace: null };
|
|
const parts = value.split("/");
|
|
if (parts.length <= 1) return { resource: value, workspace: null };
|
|
if (isK3sResourceKindAlias(parts[0] ?? "") && (parts[1] ?? "").length > 0) {
|
|
const workspaceParts = parts.slice(2);
|
|
return {
|
|
resource: `${parts[0]}/${parts[1]}`,
|
|
workspace: workspaceParts.length > 0 ? `/${workspaceParts.join("/")}` : null,
|
|
};
|
|
}
|
|
return {
|
|
resource: parts[0] ?? value,
|
|
workspace: parts.length > 1 ? `/${parts.slice(1).join("/")}` : null,
|
|
};
|
|
}
|
|
|
|
function splitK3sContainerWorkspace(value: string | null): { container: string | null; workspace: string | null } {
|
|
if (value === null || value.length === 0) return { container: null, workspace: null };
|
|
const parts = value.split("/");
|
|
if (parts.length <= 1) return { container: value, workspace: null };
|
|
return { container: parts[0] ?? value, workspace: `/${parts.slice(1).join("/")}` };
|
|
}
|
|
|
|
function combineK3sRouteWorkspace(first: string | null, second: string | null): string | null {
|
|
if (first !== null && second !== null && first !== second) throw new Error("ssh k3s route workspace can be specified once, either after the workload or after the container");
|
|
return first ?? second;
|
|
}
|
|
|
|
function isK3sResourceKindAlias(value: string): boolean {
|
|
return k3sResourceKindAliases.has(value);
|
|
}
|
|
|
|
function k3sOperationInRouteMessage(target: string, operation: string): string {
|
|
const providerId = target.split(":")[0] || "<provider>";
|
|
const operationExample = operation === "guard" ? "guard" : `${operation} ...`;
|
|
return `ssh k3s route must locate a target only; put operation "${operation}" after the route, for example "ssh ${providerId}:k3s ${operationExample}" or "ssh ${providerId}:k3s:<namespace>:<workload> ${operationExample}" instead of "${target}"`;
|
|
}
|
|
|
|
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 optionalPositiveInt(value: string, option: string): string {
|
|
return String(positiveInt(value, option));
|
|
}
|
|
|
|
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 parseK3sRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
|
if (route.entry === null && route.namespace === null && route.resource === null) {
|
|
return parseK3sControlPlaneOperation(route, args);
|
|
}
|
|
if (route.namespace === null || route.resource === null) {
|
|
throw new Error("ssh k3s target route requires provider:k3s:<namespace>:<deployment|pod/resource>");
|
|
}
|
|
return parseK3sTargetOperation(route, args);
|
|
}
|
|
|
|
function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
|
const operation = args[0] ?? "guard";
|
|
if (operation === "apply-patch" || operation === "patch") {
|
|
throw new Error(`ssh ${route.providerId}:k3s apply-patch requires a workload route: ssh ${route.providerId}:k3s:<namespace>:<workload> apply-patch`);
|
|
}
|
|
if (operation === "script" || operation === "sh") {
|
|
return { remoteCommand: buildK3sScriptCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
|
}
|
|
if (operation === "guard") {
|
|
if (args.length > 1) throw new Error(`ssh ${route.providerId}:k3s guard does not accept extra arguments`);
|
|
return { remoteCommand: buildK3sGuardCommand(route.providerId), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
return { remoteCommand: buildK3sCommand(route.providerId, args), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
|
|
function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
|
const targetArgs = k3sRouteTargetArgs(route);
|
|
if (args.length === 0) {
|
|
return {
|
|
remoteCommand: buildK3sTargetObjectCommand("get", route, ["-o", "wide"]),
|
|
requiresStdin: false,
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
const operation = args[0] ?? "";
|
|
const operationArgs = args.slice(1);
|
|
if (operation === "apply-patch" || operation === "patch") return buildK3sApplyPatchCommand([...targetArgs, ...operationArgs]);
|
|
if (operation === "script") return { remoteCommand: buildK3sScriptCommand([...targetArgs, ...operationArgs]), requiresStdin: true, invocationKind: "helper" };
|
|
if (operation === "logs") return { remoteCommand: buildK3sLogsCommand([...targetArgs, ...operationArgs]), requiresStdin: false, invocationKind: "helper" };
|
|
if (operation === "argv") return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(operationArgs)]), requiresStdin: false, invocationKind: "argv" };
|
|
if (operation === "get" || operation === "describe") {
|
|
return { remoteCommand: buildK3sTargetObjectCommand(operation, route, operationArgs), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
if (operation === "kubectl") throw new Error(`ssh k3s kubectl is a control-plane operation; use ssh ${route.providerId}:k3s kubectl ...`);
|
|
if (operation === "exec") {
|
|
const execArgs = k3sRouteExecOperationArgs(operationArgs);
|
|
return {
|
|
remoteCommand: buildK3sExecCommand([...targetArgs, ...execArgs]),
|
|
requiresStdin: execArgs.includes("--stdin") || execArgs.includes("-i"),
|
|
invocationKind: "helper",
|
|
};
|
|
}
|
|
return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
|
|
}
|
|
|
|
function buildK3sTargetObjectCommand(action: "get" | "describe", route: ParsedSshRoute, args: string[]): string {
|
|
if (route.namespace === null || route.resource === null) throw new Error(`ssh k3s ${action} target requires namespace and workload route`);
|
|
if (args.includes("--follow") || args.includes("-f")) throw new Error(`ssh k3s target ${action} does not support follow mode`);
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", action, "-n", route.namespace, normalizeK3sRouteResource(route.resource), ...args]);
|
|
}
|
|
|
|
function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
|
|
if (route.namespace === null) throw new Error(`ssh route ${route.raw} requires a namespace segment`);
|
|
if (route.resource === null) throw new Error(`ssh route ${route.raw} requires a workload or pod segment`);
|
|
return [
|
|
"--namespace", route.namespace,
|
|
"--resource", normalizeK3sRouteResource(route.resource),
|
|
...(route.container === null ? [] : ["--container", route.container]),
|
|
...(route.workspace === null ? [] : ["--workdir", route.workspace]),
|
|
];
|
|
}
|
|
|
|
function k3sRouteCommandArgs(args: string[]): string[] {
|
|
if (args.length === 0) throw new Error("ssh k3s target route requires a command to exec");
|
|
return args[0] === "--" ? args : ["--", ...args];
|
|
}
|
|
|
|
function k3sRouteExecOperationArgs(args: string[]): string[] {
|
|
if (args.length === 0) throw new Error("ssh k3s target exec operation requires a command to exec");
|
|
const result: string[] = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "--") {
|
|
if (index === args.length - 1) throw new Error("ssh k3s target exec operation requires a command after --");
|
|
return [...result, ...args.slice(index)];
|
|
}
|
|
if (arg === "--stdin" || arg === "-i" || arg === "--tty" || arg === "-t") {
|
|
result.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "--container" || arg === "-c" || arg === "--workdir" || arg === "--cwd") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error(`ssh k3s target exec ${arg} requires a value`);
|
|
result.push(arg, value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
return [...result, "--", ...args.slice(index)];
|
|
}
|
|
throw new Error("ssh k3s target exec operation requires a command to exec");
|
|
}
|
|
|
|
function buildK3sCommand(providerId: string, args: string[]): string {
|
|
const action = args[0] ?? "";
|
|
if (action.length === 0 || action === "--help" || action === "-h" || action === "help") {
|
|
throw new Error("ssh k3s requires a subcommand: guard, kubectl, get, describe, logs or exec");
|
|
}
|
|
if (action === "guard") return buildK3sGuardCommand(providerId);
|
|
if (action === "exec") return buildK3sExecCommand(args.slice(1));
|
|
if (action === "script") return buildK3sScriptCommand(args.slice(1));
|
|
if (action === "logs") return buildK3sLogsCommand(args.slice(1));
|
|
if (action === "kubectl") {
|
|
const kubectlArgs = args.slice(1);
|
|
if (kubectlArgs.length === 0) throw new Error("ssh k3s kubectl requires kubectl arguments");
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
|
}
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...args]);
|
|
}
|
|
|
|
function buildK3sGuardCommand(providerId: string): string {
|
|
const provider = providerId.toUpperCase();
|
|
const providerNodeCheck = provider === "D601"
|
|
? "printf '%s\\n' \"$nodes\" | grep -Fx d601 >/dev/null || { printf 'native_k3s_guard=blocked provider=%s reason=d601-node-missing\\n' \"$UNIDESK_K3S_PROVIDER_ID\" >&2; exit 1; }"
|
|
: provider === "G14"
|
|
? "printf '%s\\n' \"$nodes\" | grep -Fx ubuntu-rog-zephyrus-g14-ga401iv-ga401iv >/dev/null || { printf 'native_k3s_guard=blocked provider=%s reason=g14-node-missing\\n' \"$UNIDESK_K3S_PROVIDER_ID\" >&2; exit 1; }"
|
|
: "[ -n \"$nodes\" ] || { printf 'native_k3s_guard=blocked provider=%s reason=node-list-empty\\n' \"$UNIDESK_K3S_PROVIDER_ID\" >&2; exit 1; }";
|
|
const script = [
|
|
"set -euo pipefail",
|
|
`export KUBECONFIG=${shellQuote(nativeK3sKubeconfig)}`,
|
|
`export UNIDESK_K3S_PROVIDER_ID=${shellQuote(providerId)}`,
|
|
"context=$(kubectl config current-context)",
|
|
"server=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')",
|
|
"nodes=$(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{\"\\n\"}{end}')",
|
|
"printf 'kubeconfig=%s\\n' \"$KUBECONFIG\"",
|
|
"printf 'provider=%s\\n' \"$UNIDESK_K3S_PROVIDER_ID\"",
|
|
"printf 'context=%s\\n' \"$context\"",
|
|
"printf 'server=%s\\n' \"$server\"",
|
|
"printf 'nodes=%s\\n' \"$(printf '%s' \"$nodes\" | tr '\\n' ' ')\"",
|
|
providerNodeCheck,
|
|
"case \"$server\" in *127.0.0.1:11700*|*docker-desktop*) printf 'native_k3s_guard=blocked reason=docker-desktop-context server=%s\\n' \"$server\" >&2; exit 1;; esac",
|
|
"printf 'native_k3s_guard=ok provider=%s\\n' \"$UNIDESK_K3S_PROVIDER_ID\"",
|
|
].join("; ");
|
|
return shellArgv(["bash", "-c", script]);
|
|
}
|
|
|
|
interface K3sTargetOptions {
|
|
namespace: string | null;
|
|
resource: string | null;
|
|
container: string | null;
|
|
workspace: string | null;
|
|
stdin: boolean;
|
|
tty: boolean;
|
|
shell: string | null;
|
|
command: string[];
|
|
kubectlOptions: string[];
|
|
}
|
|
|
|
interface ParseK3sTargetOptionsOptions {
|
|
requireCommand: boolean;
|
|
allowCommand?: boolean;
|
|
allowShell?: boolean;
|
|
}
|
|
|
|
function buildK3sExecCommand(args: string[]): string {
|
|
const parsed = parseK3sTargetOptions(args, "ssh k3s exec", { requireCommand: true });
|
|
if (parsed.namespace === null) throw new Error("ssh k3s exec requires --namespace <name>");
|
|
if (parsed.resource === null) throw new Error("ssh k3s exec requires --deployment <name>, --pod <name> or --resource <type/name>");
|
|
const kubectlArgs = [
|
|
"exec",
|
|
"-n", parsed.namespace,
|
|
parsed.resource,
|
|
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
|
...(parsed.stdin ? ["-i"] : []),
|
|
...(parsed.tty ? ["-t"] : []),
|
|
...parsed.kubectlOptions,
|
|
"--",
|
|
...withK3sWorkspace(parsed, parsed.command),
|
|
];
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
|
}
|
|
|
|
function buildK3sScriptCommand(args: string[]): string {
|
|
const parsed = parseK3sTargetOptions(args, "ssh k3s script", { requireCommand: false, allowCommand: true, allowShell: true });
|
|
if (parsed.namespace === null && parsed.resource === null) return buildK3sHostScriptCommand(parsed);
|
|
if (parsed.namespace === null) throw new Error("ssh k3s script target requires --namespace <name>");
|
|
if (parsed.resource === null) throw new Error("ssh k3s script target requires --deployment <name>, --pod <name> or --resource <type/name>");
|
|
if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body");
|
|
const shell = parsed.shell ?? "sh";
|
|
const kubectlArgs = [
|
|
"exec",
|
|
"-i",
|
|
"-n", parsed.namespace,
|
|
parsed.resource,
|
|
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
|
...parsed.kubectlOptions,
|
|
"--",
|
|
...withK3sWorkspace(parsed, [shell, "-s", "--", ...parsed.command]),
|
|
];
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
|
}
|
|
|
|
function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs {
|
|
const parsed = parseK3sTargetOptions(args, "ssh k3s apply-patch", { requireCommand: false, allowCommand: true });
|
|
if (parsed.namespace === null) throw new Error("ssh k3s apply-patch requires --namespace <name>");
|
|
if (parsed.resource === null) throw new Error("ssh k3s apply-patch requires --deployment <name>, --pod <name> or --resource <type/name>");
|
|
if (parsed.tty) throw new Error("ssh k3s apply-patch does not support --tty; stdin is reserved for the patch body");
|
|
if (parsed.stdin) throw new Error("ssh k3s apply-patch does not accept --stdin; stdin is always the patch body");
|
|
if (parsed.shell !== null) throw new Error("ssh k3s apply-patch does not accept --shell");
|
|
if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s apply-patch does not accept kubectl log options");
|
|
const kubectlArgs = [
|
|
"exec",
|
|
"-i",
|
|
"-n", parsed.namespace,
|
|
parsed.resource,
|
|
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
|
"--",
|
|
...withK3sWorkspace(parsed, ["sh", "-s", "--", ...parsed.command]),
|
|
];
|
|
const wrapper = podApplyPatchStdinWrapper();
|
|
return {
|
|
remoteCommand: shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]),
|
|
requiresStdin: true,
|
|
invocationKind: "helper",
|
|
stdinPrefix: wrapper.prefix,
|
|
stdinSuffix: wrapper.suffix,
|
|
};
|
|
}
|
|
|
|
function buildK3sHostScriptCommand(parsed: K3sTargetOptions): string {
|
|
if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body");
|
|
if (parsed.stdin) throw new Error("ssh k3s script does not accept --stdin; stdin is always the script body");
|
|
if (parsed.container !== null) throw new Error("ssh k3s script without a workload does not accept --container");
|
|
if (parsed.workspace !== null) throw new Error("ssh k3s script without a workload does not accept --workdir");
|
|
if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s script without a workload does not accept kubectl log options");
|
|
const shell = parsed.shell ?? "sh";
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, shell, "-s", "--", ...parsed.command]);
|
|
}
|
|
|
|
function buildK3sLogsCommand(args: string[]): string {
|
|
const parsed = parseK3sTargetOptions(args, "ssh k3s logs", { requireCommand: false });
|
|
if (parsed.namespace === null) throw new Error("ssh k3s logs requires --namespace <name>");
|
|
if (parsed.resource === null) throw new Error("ssh k3s logs requires --deployment <name>, --pod <name> or --resource <type/name>");
|
|
if (parsed.stdin || parsed.tty) throw new Error("ssh k3s logs does not support --stdin or --tty");
|
|
if (parsed.workspace !== null) throw new Error("ssh k3s logs does not accept --workdir");
|
|
const kubectlArgs = [
|
|
"logs",
|
|
"-n", parsed.namespace,
|
|
parsed.resource,
|
|
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
|
...parsed.kubectlOptions,
|
|
];
|
|
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
|
}
|
|
|
|
function parseK3sTargetOptions(args: string[], commandName: string, options: ParseK3sTargetOptionsOptions): K3sTargetOptions {
|
|
let namespace: string | null = null;
|
|
let resource: string | null = null;
|
|
let container: string | null = null;
|
|
let workspace: string | null = null;
|
|
let stdin = false;
|
|
let tty = false;
|
|
let shell: string | null = null;
|
|
const kubectlOptions: string[] = [];
|
|
const command: string[] = [];
|
|
let afterDoubleDash = false;
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (afterDoubleDash) {
|
|
command.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "--") {
|
|
afterDoubleDash = true;
|
|
continue;
|
|
}
|
|
if (arg === "--namespace" || arg === "-n") {
|
|
namespace = k3sOptionValue(args, index, `${commandName} ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--deployment" || arg === "--deploy") {
|
|
resource = `deployment/${k3sOptionValue(args, index, `${commandName} ${arg}`)}`;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--pod") {
|
|
resource = `pod/${k3sOptionValue(args, index, `${commandName} ${arg}`)}`;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--resource") {
|
|
resource = normalizeK3sResource(k3sOptionValue(args, index, `${commandName} ${arg}`));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--container" || arg === "-c") {
|
|
container = k3sOptionValue(args, index, `${commandName} ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--workdir" || arg === "--cwd") {
|
|
workspace = k3sWorkspaceValue(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--shell") {
|
|
if (!options.allowShell) throw new Error(`${commandName} does not support --shell`);
|
|
shell = k3sScriptShell(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--stdin" || arg === "-i") {
|
|
stdin = true;
|
|
continue;
|
|
}
|
|
if (arg === "--tty" || arg === "-t") {
|
|
tty = true;
|
|
continue;
|
|
}
|
|
if (arg === "--tail") {
|
|
kubectlOptions.push("--tail", optionalPositiveInt(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} --tail`));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--since" || arg === "--since-time" || arg === "--limit-bytes") {
|
|
kubectlOptions.push(arg, k3sOptionValue(args, index, `${commandName} ${arg}`));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--previous" || arg === "--timestamps" || arg === "--all-containers" || arg === "--prefix") {
|
|
kubectlOptions.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "--follow" || arg === "-f") {
|
|
throw new Error(`${commandName} does not support ${arg}; use a bounded logs command and poll again`);
|
|
}
|
|
if (arg.startsWith("-")) throw new Error(`unsupported ${commandName} option: ${arg}`);
|
|
if (resource === null) {
|
|
resource = normalizeK3sResource(arg);
|
|
continue;
|
|
}
|
|
throw new Error(`unexpected ${commandName} argument before --: ${arg}`);
|
|
}
|
|
|
|
if (options.requireCommand && command.length === 0) throw new Error(`${commandName} requires -- <command> [args...]`);
|
|
if (!options.requireCommand && options.allowCommand !== true && command.length > 0) throw new Error(`${commandName} does not accept a command after --`);
|
|
return { namespace, resource, container, workspace, stdin, tty, shell, command, kubectlOptions };
|
|
}
|
|
|
|
function k3sOptionValue(args: string[], index: number, option: string): string {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error(`${option} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function k3sWorkspaceValue(value: string, option: string): string {
|
|
if (!value.startsWith("/")) throw new Error(`${option} must be an absolute pod workspace path`);
|
|
return value;
|
|
}
|
|
|
|
function withK3sWorkspace(parsed: K3sTargetOptions, command: string[]): string[] {
|
|
if (parsed.workspace === null) return command;
|
|
if (command.length === 0) throw new Error("ssh k3s workspace route requires a command to execute");
|
|
return ["sh", "-c", 'cd "$1" || exit; shift; exec "$@"', "unidesk-cwd", parsed.workspace, ...command];
|
|
}
|
|
|
|
function normalizeK3sResource(value: string): string {
|
|
if (value.includes("/")) return value;
|
|
return `pod/${value}`;
|
|
}
|
|
|
|
function normalizeK3sRouteResource(value: string): string {
|
|
if (value.startsWith("deploy/")) return `deployment/${value.slice("deploy/".length)}`;
|
|
if (value.startsWith("po/")) return `pod/${value.slice("po/".length)}`;
|
|
if (value.includes("/")) return value;
|
|
return `deployment/${value}`;
|
|
}
|
|
|
|
function k3sScriptShell(value: string, option: string): string {
|
|
if (!/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${option} must be a shell executable name or path without whitespace`);
|
|
return value;
|
|
}
|
|
|
|
function buildShellCommand(args: string[]): ParsedSshArgs {
|
|
let shell = "sh";
|
|
const scriptArgs: string[] = [];
|
|
let afterDoubleDash = false;
|
|
let directArgvMode = false;
|
|
let shellOptionSeen = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (afterDoubleDash) {
|
|
scriptArgs.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "--") {
|
|
if (!shellOptionSeen && scriptArgs.length === 0) {
|
|
directArgvMode = true;
|
|
scriptArgs.push(...args.slice(index + 1));
|
|
break;
|
|
}
|
|
afterDoubleDash = true;
|
|
continue;
|
|
}
|
|
if (arg === "--shell") {
|
|
shell = k3sScriptShell(k3sOptionValue(args, index, "ssh script --shell"), "ssh script --shell");
|
|
shellOptionSeen = true;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-")) throw new Error(`unsupported ssh script option: ${arg}`);
|
|
scriptArgs.push(arg);
|
|
}
|
|
if (directArgvMode) {
|
|
if (scriptArgs.length === 0) throw new Error("ssh script -- requires a command");
|
|
return { remoteCommand: shellArgv(scriptArgs), requiresStdin: false, invocationKind: "argv" };
|
|
}
|
|
return { remoteCommand: shellArgv([shell, "-s", "--", ...scriptArgs]), requiresStdin: true, invocationKind: "helper" };
|
|
}
|
|
|
|
function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } {
|
|
const toolMarker = "__UNIDESK_APPLY_PATCH_TOOL__";
|
|
const patchMarker = "__UNIDESK_APPLY_PATCH_PAYLOAD__";
|
|
if (remoteApplyPatchSource.includes(toolMarker)) throw new Error("remote apply_patch source contains reserved heredoc marker");
|
|
return {
|
|
prefix: [
|
|
"set -eu",
|
|
'UNIDESK_SSH_TOOL_DIR="${UNIDESK_SSH_TOOL_DIR:-/tmp/unidesk-ssh-tools}"',
|
|
'mkdir -p "$UNIDESK_SSH_TOOL_DIR"',
|
|
`cat > "$UNIDESK_SSH_TOOL_DIR/apply_patch" <<'${toolMarker}'`,
|
|
remoteApplyPatchSource.trimEnd(),
|
|
toolMarker,
|
|
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"',
|
|
`"$UNIDESK_SSH_TOOL_DIR/apply_patch" "$@" <<'${patchMarker}'`,
|
|
].join("\n") + "\n",
|
|
suffix: `\n${patchMarker}\n`,
|
|
};
|
|
}
|
|
|
|
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("; ");
|
|
}
|
|
|
|
const remoteToolSources: Record<SshHelperName, string> = {
|
|
apply_patch: remoteApplyPatchSource,
|
|
glob: remoteGlobSource,
|
|
"skill-discover": remoteSkillDiscoverSource,
|
|
};
|
|
|
|
function remoteToolBootstrapCommand(helpers: readonly SshHelperName[] = []): string {
|
|
const uniqueHelpers = [...new Set(helpers)];
|
|
if (uniqueHelpers.length === 0) return "";
|
|
const commands = [
|
|
"UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools",
|
|
'mkdir -p "$UNIDESK_SSH_TOOL_DIR"',
|
|
];
|
|
for (const helper of uniqueHelpers) {
|
|
const source = remoteToolSources[helper];
|
|
const encoded = Buffer.from(source, "utf8").toString("base64");
|
|
commands.push(`printf %s ${shellQuote(encoded)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/${helper}"`);
|
|
commands.push(`chmod 700 "$UNIDESK_SSH_TOOL_DIR/${helper}"`);
|
|
}
|
|
commands.push('export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"');
|
|
return commands.join("; ");
|
|
}
|
|
|
|
export function wrapSshRemoteCommand(command: string | null, helpers: readonly SshHelperName[] = []): string {
|
|
const bootstrap = remoteToolBootstrapCommand(helpers);
|
|
const prefix = bootstrap.length > 0 ? `${bootstrap}; ` : "";
|
|
if (command === null) return `${prefix}exec "\${SHELL:-/bin/bash}" -l`;
|
|
return `${prefix}stty -echo 2>/dev/null || true; ${command}`;
|
|
}
|
|
|
|
function safeProviderId(providerId: string): string {
|
|
return /^[A-Za-z0-9_.-]{1,64}$/u.test(providerId) ? providerId : "<provider>";
|
|
}
|
|
|
|
function classifySshLikeFailure(exitCode: number, stderrText: string): SshFailureHint["trigger"] | null {
|
|
const normalized = stderrText.toLowerCase();
|
|
if (
|
|
normalized.includes("kex_exchange_identification")
|
|
|| normalized.includes("ssh_exchange_identification")
|
|
|| normalized.includes("connection closed by remote host")
|
|
|| normalized.includes("connection reset by peer")
|
|
|| normalized.includes("connection timed out")
|
|
|| normalized.includes("operation timed out")
|
|
|| normalized.includes("timed out waiting for provider session")
|
|
|| normalized.includes("the operation was aborted")
|
|
) {
|
|
return "timeout-or-kex";
|
|
}
|
|
return exitCode === 255 ? "exit-255" : null;
|
|
}
|
|
|
|
export function sshFailureHint(providerId: string, parsed: ParsedSshArgs, exitCode: number, stderrText: string): SshFailureHint | null {
|
|
if (parsed.invocationKind !== "ssh-like") return null;
|
|
const trigger = classifySshLikeFailure(exitCode, stderrText);
|
|
if (trigger === null) return null;
|
|
const shownProviderId = safeProviderId(providerId);
|
|
return {
|
|
code: "ssh-like-command-friction",
|
|
providerId: shownProviderId,
|
|
trigger,
|
|
exitCode,
|
|
message: "ssh-like remote command failed before proving Host SSH is globally unavailable; prefer structured argv or stdin script passthrough for non-interactive commands.",
|
|
try: `bun scripts/cli.ts ssh ${shownProviderId} script <<'SCRIPT'`,
|
|
triage: `bun scripts/cli.ts provider triage ${shownProviderId} --observed-scope ssh --observed-error '<ssh-like timeout or kex failure>'`,
|
|
note: "This hint intentionally does not echo the original remote command.",
|
|
};
|
|
}
|
|
|
|
export function formatSshFailureHint(hint: SshFailureHint): string {
|
|
return `UNIDESK_SSH_HINT ${JSON.stringify(hint)}\n`;
|
|
}
|
|
|
|
export function sshSlowWarningThresholdMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
const raw = env.UNIDESK_SSH_SLOW_WARNING_MS;
|
|
const parsed = raw === undefined ? NaN : Number(raw);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshSlowWarningMs;
|
|
return Math.max(1000, Math.trunc(parsed));
|
|
}
|
|
|
|
export function sshRuntimeTimingHint(options: {
|
|
invocation: ParsedSshInvocation;
|
|
transport: SshRuntimeTimingHint["transport"];
|
|
exitCode: number;
|
|
startedAtMs: number;
|
|
finishedAtMs?: number;
|
|
thresholdMs?: number;
|
|
}): SshRuntimeTimingHint {
|
|
const finishedAtMs = options.finishedAtMs ?? Date.now();
|
|
const thresholdMs = options.thresholdMs ?? sshSlowWarningThresholdMs();
|
|
const elapsedMs = Math.max(0, Math.round(finishedAtMs - options.startedAtMs));
|
|
const elapsedSeconds = Number((elapsedMs / 1000).toFixed(3));
|
|
const slow = elapsedMs > thresholdMs;
|
|
const thresholdSeconds = Number((thresholdMs / 1000).toFixed(3));
|
|
return {
|
|
code: "ssh-runtime-timing",
|
|
level: slow ? "warning" : "info",
|
|
providerId: safeProviderId(options.invocation.providerId),
|
|
route: options.invocation.route.raw,
|
|
transport: options.transport,
|
|
invocationKind: options.invocation.parsed.invocationKind,
|
|
exitCode: options.exitCode,
|
|
elapsedMs,
|
|
elapsedSeconds,
|
|
thresholdMs,
|
|
slow,
|
|
message: slow
|
|
? `ssh operation took ${elapsedSeconds}s, above the ${thresholdSeconds}s warning threshold; consider checking provider/session latency, remote command cost, helper bootstrap, or tran/apply-patch optimization before repeating high-frequency operations.`
|
|
: `ssh operation completed in ${elapsedSeconds}s.`,
|
|
note: "Timing hint is written to stderr and intentionally does not echo the original remote command.",
|
|
};
|
|
}
|
|
|
|
export function formatSshRuntimeTimingHint(hint: SshRuntimeTimingHint): string {
|
|
return `UNIDESK_SSH_TIMING ${JSON.stringify(hint)}\n`;
|
|
}
|
|
|
|
function brokerSource(): string {
|
|
return String.raw`
|
|
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
|
|
const token = process.env.PROVIDER_TOKEN || process.env.UNIDESK_PROVIDER_TOKEN || "";
|
|
const baseUrl = process.env.UNIDESK_SSH_BROKER_URL || "ws://backend-core:8080/ws/ssh";
|
|
const url = baseUrl + "?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> {
|
|
const invocation = parseSshInvocation(providerId, args);
|
|
const parsed = invocation.parsed;
|
|
const startedAtMs = Date.now();
|
|
const size = terminalSize();
|
|
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
|
|
const payload = {
|
|
providerId: invocation.providerId,
|
|
command: wrapSshRemoteCommand(parsed.remoteCommand, parsed.requiredHelpers),
|
|
cwd: invocation.route.plane === "host" ? invocation.route.workspace ?? undefined : undefined,
|
|
tty: parsed.remoteCommand === null,
|
|
stdinEotOnEnd: parsed.remoteCommand !== null,
|
|
openTimeoutMs,
|
|
cols: size.cols,
|
|
rows: size.rows,
|
|
};
|
|
const payloadJson = JSON.stringify(payload);
|
|
const encodedBrokerSource = Buffer.from(brokerSource(), "utf8").toString("base64");
|
|
const script = [
|
|
"set -eu",
|
|
`payload=${shellQuote(payloadJson)}`,
|
|
"if command -v backend-core >/dev/null 2>&1; then",
|
|
' exec backend-core --ssh-broker "$payload"',
|
|
"fi",
|
|
`export UNIDESK_SSH_BROKER_URL=${shellQuote("ws://127.0.0.1:8080/ws/ssh")}`,
|
|
`printf %s ${shellQuote(encodedBrokerSource)} | base64 -d >/tmp/unidesk-ssh-broker.js`,
|
|
"exec bun /tmp/unidesk-ssh-broker.js \"$payload\"",
|
|
].join("\n");
|
|
const child = spawn("docker", [
|
|
"exec",
|
|
"-i",
|
|
"unidesk-backend-core",
|
|
"sh",
|
|
"-c",
|
|
script,
|
|
], {
|
|
cwd: repoRoot,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
|
|
const rawMode = parsed.remoteCommand === null && process.stdin.isTTY;
|
|
if (rawMode) process.stdin.setRawMode(true);
|
|
process.stdin.resume();
|
|
if (parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined) {
|
|
if (parsed.stdinPrefix) child.stdin.write(parsed.stdinPrefix);
|
|
process.stdin.pipe(child.stdin, { end: false });
|
|
process.stdin.once("end", () => {
|
|
if (parsed.stdinSuffix) child.stdin.write(parsed.stdinSuffix);
|
|
child.stdin.end();
|
|
});
|
|
} else {
|
|
process.stdin.pipe(child.stdin);
|
|
}
|
|
let stderrTail = "";
|
|
const appendStderrTail = (chunk: Buffer | string): void => {
|
|
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
|
stderrTail = (stderrTail + text).slice(-16_384);
|
|
};
|
|
child.stdout.pipe(process.stdout);
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
appendStderrTail(chunk);
|
|
process.stderr.write(chunk);
|
|
});
|
|
|
|
return await new Promise<number>((resolve) => {
|
|
let settled = false;
|
|
const restore = (): void => {
|
|
process.stdin.unpipe(child.stdin);
|
|
if (rawMode) process.stdin.setRawMode(false);
|
|
};
|
|
const finish = (exitCode: number): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
restore();
|
|
const hint = sshFailureHint(invocation.providerId, parsed, exitCode, stderrTail);
|
|
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
|
|
process.stderr.write(formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
|
invocation,
|
|
transport: "backend-core-broker",
|
|
exitCode,
|
|
startedAtMs,
|
|
})));
|
|
resolve(exitCode);
|
|
};
|
|
child.on("error", (error) => {
|
|
const message = `unidesk ssh failed to start broker: ${error.message}\n`;
|
|
appendStderrTail(message);
|
|
process.stderr.write(message);
|
|
finish(255);
|
|
});
|
|
child.on("close", (code) => {
|
|
finish(code ?? 255);
|
|
});
|
|
});
|
|
}
|