fix: guard remote apply-patch context
This commit is contained in:
+2
-1
@@ -148,7 +148,7 @@ export function sshHelp(): unknown {
|
||||
usage: [
|
||||
"bun scripts/cli.ts ssh <route>",
|
||||
"bun scripts/cli.ts ssh <providerId> argv <command> [args...]",
|
||||
"bun scripts/cli.ts ssh <providerId> apply-patch < patch.diff",
|
||||
"bun scripts/cli.ts ssh <providerId> apply-patch [--allow-loose] < patch.diff",
|
||||
"bun scripts/cli.ts ssh <providerId> py [script-args...] < script.py",
|
||||
"bun scripts/cli.ts ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT'",
|
||||
"bun scripts/cli.ts ssh <providerId> skills [--scope all|wsl|windows] [--limit N]",
|
||||
@@ -169,6 +169,7 @@ export function sshHelp(): unknown {
|
||||
notes: [
|
||||
"ssh --help and ssh <route> --help print this JSON help and never open an interactive session.",
|
||||
"For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.",
|
||||
"apply-patch rejects low-context update hunks by default, reports the matched file:line for each hunk on stderr, and only accepts --allow-loose when the caller has manually reviewed an intentionally ambiguous insertion.",
|
||||
"script defaults to target /bin/sh and inherits provider proxy variables such as HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY; use --shell bash only for bash syntax such as pipefail, arrays, or [[ ... ]], not as a proxy workaround.",
|
||||
"Route syntax is `{provider}:{plane}[:{scope...}] {operation} [operation-args...]`: the first argv token locates a distributed target only, and every following token belongs to the operation parser. Host workspace routes use `<provider>:/absolute/workspace`; native k3s providers such as D601 and G14 use <provider>:k3s for the control plane, <provider>:k3s:<namespace>:<workload> for a workload, and <provider>:k3s:<namespace>:<workload>/<pod-workspace> for a pod workspace.",
|
||||
"Do not put operation names in any colon route segment, including nested k3s namespace/workload/container segments.",
|
||||
|
||||
+66
-152
@@ -62,152 +62,11 @@ const legacyK3sOperationRouteSegments = new Set([
|
||||
"api-versions",
|
||||
]);
|
||||
|
||||
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 remoteShApplyPatchSource = String.raw`#!/bin/sh
|
||||
export const remoteApplyPatchSource = String.raw`#!/bin/sh
|
||||
set -eu
|
||||
|
||||
allow_loose=0
|
||||
|
||||
die() {
|
||||
printf 'apply_patch: %s\n' "$*" >&2
|
||||
exit 1
|
||||
@@ -240,10 +99,16 @@ write_file() {
|
||||
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() {
|
||||
target=$1
|
||||
search_file=$2
|
||||
replace_file=$3
|
||||
hunk_id=$4
|
||||
[ -e "$target" ] || die "file not found: $target"
|
||||
|
||||
marker="__UNIDESK_APPLY_PATCH_EOF_$$__"
|
||||
@@ -255,16 +120,24 @@ replace_once() {
|
||||
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 context not found in $target"
|
||||
die "hunk $hunk_id context not found in $target"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
@@ -282,10 +155,25 @@ apply_update() {
|
||||
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
|
||||
replace_once "$target" "$search_file" "$replace_file"
|
||||
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=
|
||||
@@ -302,6 +190,13 @@ apply_update() {
|
||||
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
|
||||
;;
|
||||
@@ -312,17 +207,25 @@ apply_update() {
|
||||
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"
|
||||
;;
|
||||
@@ -432,10 +335,21 @@ parse_update_file() {
|
||||
}
|
||||
|
||||
main() {
|
||||
if [ "${"$"}{1:-}" = "-h" ] || [ "${"$"}{1:-}" = "--help" ]; then
|
||||
printf 'apply_patch: sh-only helper; reads *** Begin Patch format from stdin\n'
|
||||
return 0
|
||||
fi
|
||||
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
|
||||
@@ -1440,14 +1354,14 @@ function buildShellStdinCommand(args: string[]): string {
|
||||
function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } {
|
||||
const toolMarker = "__UNIDESK_APPLY_PATCH_TOOL__";
|
||||
const patchMarker = "__UNIDESK_APPLY_PATCH_PAYLOAD__";
|
||||
if (remoteShApplyPatchSource.includes(toolMarker)) throw new Error("remote apply_patch source contains reserved heredoc marker");
|
||||
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}'`,
|
||||
remoteShApplyPatchSource.trimEnd(),
|
||||
remoteApplyPatchSource.trimEnd(),
|
||||
toolMarker,
|
||||
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"',
|
||||
`"$UNIDESK_SSH_TOOL_DIR/apply_patch" "$@" <<'${patchMarker}'`,
|
||||
|
||||
Reference in New Issue
Block a user