Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions scripts/install-k8s.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4245,16 +4245,45 @@ function Print-CreateFailure {
}
}

# Strip ANSI CSI sequences (arrow keys, cursor moves), bracketed-paste markers,
# and C0 control characters from interactive input — they otherwise corrupt the
# name passed to `client create` into a garbage slug (mirrors common.sh's
# _strip_paste_garbage; customer-reported 2026-07-20 on the bash flow). UTF-8
# letters survive (only < 0x20 and DEL are dropped).
# Strip ANSI escape sequences (arrow keys, cursor moves, function keys),
# bracketed-paste markers, and C0 control characters from interactive input —
# they otherwise corrupt the name passed to `client create` into a garbage slug
# (mirrors common.sh's _strip_paste_garbage and cli/internal/cli/sanitize.go;
# customer-reported 2026-07-20 on the bash flow). Two shapes carry all of it:
# CSI ESC '[' <params in [0-9;]> <final in [A-Za-z~]>
# SS3 ESC 'O' <final in [A-Za-z~]> — ESC OA/OB/OC/OD, ESC OH/OF, ESC OP..OS
# SS3 is what the SAME keys emit in DECCKM application-cursor mode, the state
# vim/less/tmux leave behind on an unclean exit (cli#516) — the hole left by the
# CSI-only fix of 2026-07-21 (client#362 / cli#364). ESC is dropped as a control
# byte but 'O' and the final byte are printable, so ESC OD ESC OA survived as
# the plausible name "ODOA" and minted a permanent namespace, where CSI residue
# cleans to empty and re-prompts. UTF-8 letters survive (only < 0x20 and DEL are
# dropped). Change this, common.sh and sanitize.go together.
function ConvertTo-SanitizedInput {
param([string]$Value)
if (-not $Value) { return "" }
$s = $Value -replace "$([char]27)\[[0-9;]*[A-Za-z~]", ""
$esc = [char]27
$s = $Value -replace "$esc(\[[0-9;]*|O)[A-Za-z~]", ""
$s = $s.Replace("[200~", "").Replace("[201~", "")
# The floor. The strip above knows CSI, SS3 and the paste markers; it cannot
# know the escape family nobody has reported yet — and that is exactly how SS3
# got here, one rule hand-copied into three languages with only CSI ever
# tested. So if an ESC SURVIVED the strip, this value carries a shape we do not
# recognise and its printable bytes are not trustworthy content. Require one
# alphanumeric that did not come from an escape final byte, probing with ESC +
# intermediates + AT MOST TWO final-class bytes. Two, not one and not
# unbounded: one leaves the 'D' of an unrecognised SS3-shaped pair behind and
# the floor stops firing on the very shape this is about, while unbounded
# swallows a whole ASCII name (ESC N C h e l l o) yet spares a non-Latin one,
# making keep-vs-reject depend on the script the name is written in (Bugbot,
# #736). An escape final is one byte, an intro plus a final is two, and every
# keyboard-input escape family fits in that. The probe is a yes/no only — it is
# never returned. Nothing but residue => return empty, which callers already
# treat as "no answer" (re-prompt, or auto-name).
if ($s.Contains($esc)) {
$probe = $s -replace "$esc[^A-Za-z0-9~]*[A-Za-z~]{1,2}", ""
if ($probe -notmatch '[\p{L}\p{Nd}]') { return "" }
}
return (($s.ToCharArray() | Where-Object { [int]$_ -ge 32 -and [int]$_ -ne 127 }) -join "")
}

Expand Down
57 changes: 52 additions & 5 deletions scripts/lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,16 @@ tb_minutes_or() {
# • bracketed-paste wrappers: ESC[200~ ... ESC[201~
# • arrow keys / cursor moves: ESC[A/B/C/D, ESC[1;5C, ESC[3~ (Delete), …
# • function keys, modifier combos, mode-switch sequences
# All follow the ANSI CSI shape: ESC '[' <params> <final-byte>
# where params ∈ [0-9;] and final ∈ [A-Za-z~]. Strip them iteratively to
# handle consecutive sequences (e.g. paste-wrappers).
# Two shapes carry all of those:
# CSI ESC '[' <params ∈ [0-9;]> <final ∈ [A-Za-z~]>
# SS3 ESC 'O' <final ∈ [A-Za-z~]> — ESC OA/OB/OC/OD, ESC OH/OF, ESC OP…OS
# SS3 is what the SAME keys emit once the terminal is in DECCKM
# application-cursor mode, the state vim/less/tmux leave behind on an unclean
# exit (cli#516). It was the hole left by the CSI-only fix of 2026-07-21
# (client#362 / cli#364): ESC is dropped as a C0 byte but 'O' and the final byte
# are printable, so ESC OD ESC OA survived as the plausible name "ODOA" and
# minted a permanent namespace — where CSI residue cleans to empty and re-prompts.
# Strip iteratively to handle consecutive sequences (e.g. paste-wrappers).
#
# Also handles the post-corruption case where ESC was stripped by an earlier
# (buggy) sanitizer but the literal `[200~`/`[201~` markers survived. Only
Expand All @@ -372,15 +379,55 @@ tb_minutes_or() {
# UTF-8 bytes (0x80+) preserved so international characters survive. Lives here
# (shared) so BOTH the credential path (install-client-helm.sh) and the client-
# name prompt (provision.sh) sanitize identically (customer-reported 2026-07-20).
# Mirrored by cli/internal/cli/sanitize.go and install-k8s.ps1's
# ConvertTo-SanitizedInput — change all three together.
_strip_paste_garbage() {
local s="$1"
local esc=$'\e'
local csi_pattern="${esc}\\[[0-9;]*[A-Za-z~]"
while [[ "$s" =~ $csi_pattern ]]; do
local esc_pattern="${esc}(\\[[0-9;]*|O)[A-Za-z~]"
while [[ "$s" =~ $esc_pattern ]]; do
s="${s/${BASH_REMATCH[0]}/}"
done
s="${s//\[200\~/}"
s="${s//\[201\~/}"
# The floor. The loop above knows CSI, SS3 and the paste markers; it cannot
# know the escape family nobody has reported yet — and that is exactly how SS3
# got here, one rule hand-copied into three languages with only CSI ever
# tested. So if an ESC SURVIVED the loop, this value carries a shape we do not
# recognise and its printable bytes are not trustworthy content. Require one
# alphanumeric that did not come from an escape final byte, probing with ESC +
# intermediates + AT MOST TWO final-class bytes. Two, not one and not
# unbounded: one leaves the 'D' of an unrecognised SS3-shaped pair behind and
# the floor stops firing on the very shape this is about, while unbounded
# swallows a whole ASCII name (ESC N C h e l l o) yet spares a non-Latin one,
# making keep-vs-reject depend on the script the name is written in (Bugbot,
# #736). An escape final is one byte, an intro plus a final is two, and every
# keyboard-input escape family fits in that. The probe's output is only a
# yes/no — it is never returned. Nothing but residue ⇒ emit empty, which every
# caller already treats as "no answer" (re-prompt, or auto-name in the CLI).
if [[ "$s" == *"$esc"* ]]; then
# `sed`, NOT the `while [[ =~ ]]; do s="${s/$BASH_REMATCH/}"` loop the strip
# above uses. Pattern substitution treats BASH_REMATCH as a GLOB, and this
# pattern — unlike the CSI one, whose match can never contain `]` — can match
# a complete bracket expression: on ESC [ ; ] A the regex matches the whole
# thing, the glob `<ESC>[;]A` then means ESC ';' 'A', which is NOT in the
# string, the substitution removes nothing, and the loop never terminates.
# That is a hang at the installer's name prompt. One sed pass has no glob
# semantics and no loop.
#
# And "alphanumeric" via tr, not `=~ [[:alnum:]]`: bash's regex engine is
# locale-dependent, and under the C locale the installer often runs in,
# [[:alnum:]] does not match a UTF-8 letter — which would auto-name a
# perfectly good "日本" the moment an unknown escape sat next to it. Keeping
# every byte >= 0x80 makes "is there real content here" locale-independent
# and matches what the strip itself already preserves.
local probe
probe="$(printf '%s' "$s" | LC_ALL=C sed -E "s/${esc}[^A-Za-z0-9~]*[A-Za-z~]{1,2}//g" | LC_ALL=C tr -dc '0-9A-Za-z\200-\377')"
if [[ -z "$probe" ]]; then
printf ''
return 0
Comment thread
cursor[bot] marked this conversation as resolved.
fi
fi
printf '%s' "$s" | tr -d '\000-\037\177'
}

Expand Down
4 changes: 2 additions & 2 deletions scripts/manifest.sha256
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
7d4d98379601ee2f243e5889e9f718ef8e581e4a3c5ed49ade6c3b0e7f939a82 scripts/install-k8s.sh
f6bcbeaf3d10847c5b5ecfb464c5f0cd052d521d155dea061365de01a93ba293 scripts/lib/common.sh
66ea6f8d54db2168c58e438e908c707b7f4f6c2b3131872d784508e537c2d920 scripts/lib/common.sh
673683811b2b6cec2d243561a317cc265da2b603fe36698fbceb9ce6c635f3ae scripts/lib/preflight.sh
c6bf113c00d68fb94f7654f2fb34db296160acd990c6a612ed02ae30076f2fe3 scripts/lib/detect-gpu.sh
d8c29bc8bd1f4633300940894da0f6527ca0a1dd7a3cfcbc80aad19dfd4d88cb scripts/lib/gpu-nvidia.sh
Expand All @@ -15,4 +15,4 @@ b0bf0a4966461e4257b6765dc4f12877762eee1e72f60ed4b2e322cb6291315a scripts/lib/pr
911fd0714b17357bb205fc8a8fa8e13eedc1a9632a2f63d4ead9f8d8c7ee546f scripts/lib/probe.sh
a9bb43caa3e7d156d48e7f9f4473c22da0b3b42c25d84fd49f35349b25956132 scripts/lib/summary.sh
77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh
cc1314f51de8ffe40529f7d3f2aa9d9debfdfb639074b104bd938a7dd52f07ec scripts/install-k8s.ps1
478da84ef9be6cf62c3348c86ebcef53dc9e9ac9286d1a5631fbaa4a071c5207 scripts/install-k8s.ps1
91 changes: 91 additions & 0 deletions scripts/tests/install-client-helm.bats
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,97 @@ setup() {
[ "$output" = "abcd" ] || return 1
}

# ── SS3 (cli#516) ──────────────────────────────────────────────────────────
# The CSI-only strip landed 2026-07-21 (client#362 / cli#364) and is not in
# question here. SS3 — ESC 'O' <final> — is what the SAME keys emit once the
# terminal is in DECCKM application-cursor mode, the state vim/less/tmux leave
# behind on an unclean exit. It was worse than the CSI case it was missed
# alongside: CSI residue cleans to empty and re-prompts, while ESC OD ESC OA
# left the non-empty, plausible "ODOA" and minted a permanent namespace.
@test "_strip_paste_garbage: strips SS3 escapes around real content" {
run _strip_paste_garbage "$(printf 'na\eODme')"
[ "$output" = "name" ] || return 1
}

@test "_strip_paste_garbage: SS3 arrows only -> empty (caller re-prompts)" {
run _strip_paste_garbage "$(printf '\eOD\eOD\eOD\eOA\eOA\eOA')"
[ "$output" = "" ] || return 1
}

@test "_strip_paste_garbage: SS3 Home/End and F1/F2 -> empty" {
run _strip_paste_garbage "$(printf '\eOH\eOF')"
[ "$output" = "" ] || return 1
run _strip_paste_garbage "$(printf '\eOP\eOQ')"
[ "$output" = "" ] || return 1
}

@test "_strip_paste_garbage: SS3 and CSI mixed in one value" {
run _strip_paste_garbage "$(printf 'a\eODb\e[Dc')"
[ "$output" = "abc" ] || return 1
}

@test "_strip_paste_garbage: a bare O is not an escape" {
run _strip_paste_garbage "OPTIMUS-01"
[ "$output" = "OPTIMUS-01" ] || return 1
}

# ── the post-sanitise floor ────────────────────────────────────────────────
# An ESC that SURVIVES the strip means an escape family this helper does not
# know — which is exactly how SS3 got here. SS2 (ESC 'N' <final>) stands in for
# "the next family": it is not stripped above, so these exercise the floor and
# nothing else.
@test "_strip_paste_garbage: unknown escape family, residue only -> empty" {
run _strip_paste_garbage "$(printf '\eNB\eNC')"
[ "$output" = "" ] || return 1
}

@test "_strip_paste_garbage: unknown escape family beside real content is kept" {
run _strip_paste_garbage "$(printf 'box\eNC')"
[ "$output" = "boxNC" ] || return 1
}

@test "_strip_paste_garbage: the floor counts non-Latin letters as real content" {
run _strip_paste_garbage "$(printf '\eNC日本')"
[ "$output" = "NC日本" ] || return 1
}

# The probe's final-byte run is bounded at two, so an ASCII name after an unknown
# escape is kept just like a non-Latin one. Keep-vs-reject must not depend on the
# script the name is written in — with an unbounded `+` the whole word was
# swallowed and this input was refused while the 日本 case above was not (Bugbot).
@test "_strip_paste_garbage: the floor keeps an ASCII name after an unknown escape" {
run _strip_paste_garbage "$(printf '\eNChello')"
[ "$output" = "NChello" ] || return 1
}

@test "_strip_paste_garbage: truncated SS3 (ESC O, no final) -> empty" {
run _strip_paste_garbage "$(printf '\eO')"
[ "$output" = "" ] || return 1
}

# ESC [ ; ] A is the shape that made the floor's first draft HANG, and it is why
# the floor uses one sed pass rather than the `while [[ =~ ]]; do
# s="${s/${BASH_REMATCH[0]}/}"; done` loop the strip above uses. Pattern
# substitution treats BASH_REMATCH as a GLOB: the residue regex matches
# ESC [ ; ] A whole, but the glob `<ESC>[;]A` means ESC ';' 'A' — not in the
# string — so the substitution removed nothing and the loop spun forever, at the
# installer's name prompt. (The CSI loop is safe by construction: its match can
# never contain a `]`, so it can never form a complete bracket expression.)
# Bounded like common.bats bounds its recursion guard — macOS ships no
# timeout(1), so Linux CI is the authority on the hang half.
@test "_strip_paste_garbage: ESC [ ; ] A terminates and is refused (glob-substitution hang)" {
local _to=""
command -v timeout >/dev/null 2>&1 && _to=timeout
command -v gtimeout >/dev/null 2>&1 && _to=gtimeout
if [ -n "$_to" ]; then
run "$_to" 10 bash -c 'source "$1/common.sh"; _strip_paste_garbage "$(printf "\e[;]A")"' _ "$LIB_DIR"
else
run bash -c 'source "$1/common.sh"; _strip_paste_garbage "$(printf "\e[;]A")"' _ "$LIB_DIR"
fi
[ "$status" -eq 0 ] || return 1
[ "$output" = "" ] || return 1
}

@test "_sanitize_workspace_name: lowercases + dashes" {
run _sanitize_workspace_name "My Team_1"
[ "$output" = "my-team-1" ] || return 1
Expand Down
Loading