export const OSC7_MARKER = "Netcatty OSC 7 cwd tracking"; export const OSC7_SETUP_TARGETS = [ "~/.bashrc", "${ZDOTDIR:-~}/.zshrc", "~/.config/fish/config.fish", ] as const; export const OSC7_SETUP_SHELL_MARKER = "__NETCATTY_OSC7_SETUP_SHELL__="; export const OSC7_SETUP_CONFIG_MARKER = "__NETCATTY_OSC7_SETUP_CONFIG__="; // Emitted when the silent exec-channel setup finds the active terminal shell // owned by another user (after `su` / `sudo su`); the exec channel cannot // configure that shell, so the renderer retypes the setup inside the terminal // where it runs as the target user (#1942). export const OSC7_SETUP_OTHER_USER_MARKER = "__NETCATTY_OSC7_SETUP_OTHER_USER_SHELL__="; export type Osc7SetupActionContext = { protocol?: string; isLocalConnection?: boolean; isSerialConnection?: boolean; isNetworkDevice?: boolean; }; export type Osc7SetupShell = "bash" | "zsh" | "fish"; export type Osc7SetupMetadata = { shell: Osc7SetupShell; configPath: string; }; export type Osc7SetupRunResult = { success: boolean; pending?: boolean; stdout?: string; stderr?: string; code?: number | null; error?: string; reloadCommand?: string; /** Setup was retyped into the terminal for a user-switched (su/sudo) shell. */ sentToTerminal?: boolean; }; export type RunOsc7SetupActionOptions = { status: string; sessionId: string; setupCommand: string; setupOsc7Tracking?: ( sessionId: string, command: string, ) => Promise; writeToSession: ( sessionId: string, data: string, options?: { automated?: boolean; logRewrite?: { sentCommand: string; displayCommand: string }; }, ) => void; writeLocalTerminalData?: (data: string) => void; }; export const shouldOfferOsc7SetupAction = ({ protocol, isLocalConnection, isSerialConnection, isNetworkDevice, }: Osc7SetupActionContext): boolean => !isLocalConnection && !isSerialConnection && !isNetworkDevice && protocol !== "telnet"; const DOLLAR = "$"; const URL_PATH_AWK_SCRIPT = String.raw`BEGIN { for (i = 0; i < 256; i++) { c = sprintf("%c", i) ord[c] = i } } { if (NR > 1) encode("\n") for (i = 1; i <= length($0); i++) { encode(substr($0, i, 1)) } } function encode(c, o) { o = ord[c] if ((o >= 48 && o <= 57) || (o >= 65 && o <= 90) || (o >= 97 && o <= 122) || c == "/" || c == "-" || c == "." || c == "_" || c == "~") { printf "%s", c } else { printf "%%%02X", o } }`; const quoteForSingleQuotedShellString = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`; const URL_PATH_AWK_SCRIPT_QUOTED = quoteForSingleQuotedShellString(URL_PATH_AWK_SCRIPT); const BASH_DELETE_MARKED_HISTORY_COMMAND = String.raw`if test -n "${DOLLAR}{BASH_VERSION-}"; then __netcatty_osc7_history_cleanup_marker__=1; __netcatty_osc7_history_line=$(HISTTIMEFORMAT= builtin history 1 2>/dev/null) || __netcatty_osc7_history_line=""; case "$__netcatty_osc7_history_line" in *__netcatty_osc7_history_cleanup_marker__=1*) __netcatty_osc7_history_number=$(printf "%s\n" "$__netcatty_osc7_history_line" | sed "s/^ *\([0-9][0-9]*\).*/\1/"); case "$__netcatty_osc7_history_number" in ""|*[!0-9]*) ;; *) builtin history -d "$__netcatty_osc7_history_number" 2>/dev/null || true;; esac;; esac; unset __netcatty_osc7_history_cleanup_marker__ __netcatty_osc7_history_line __netcatty_osc7_history_number 2>/dev/null || true; fi`; const POSIX_SETUP_SCRIPT = String.raw`set -eu marker="# >>> Netcatty OSC 7 cwd tracking >>>" SELF=$$ expected_cwd="${DOLLAR}{NETCATTY_OSC7_EXPECTED_CWD:-}" forced_shell="${DOLLAR}{NETCATTY_OSC7_FORCE_SHELL:-}" find_login_shell() { _shell=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" ' $1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ { if ($3 != "?") { print $1; found=1; exit } if (any == "") any=$1 } END { if (!found && any != "") print any } ') [ -n "$_shell" ] && { echo "$_shell"; return; } [ -r "/proc/$SELF/environ" ] || return _conn=$(tr '\0' '\n' < "/proc/$SELF/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1) [ -z "$_conn" ] && return _any="" for _d in /proc/[0-9]*; do _pid=$(basename "$_d") [ "$_pid" = "$SELF" ] && continue [ -r "$_d/environ" ] || continue _conn2=$(tr '\0' '\n' < "$_d/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1) [ "$_conn2" = "$_conn" ] || continue _comm=$(cat "$_d/comm" 2>/dev/null) case "$_comm" in sh|bash|zsh|fish|ksh|dash|ash) ;; *) continue ;; esac _tty=$(ps -p "$_pid" -o tty= 2>/dev/null | tr -d '[:space:]') if [ "$_tty" != "?" ] && [ -n "$_tty" ]; then echo "$_pid" return fi [ -z "$_any" ] && _any="$_pid" done [ -n "$_any" ] && echo "$_any" } find_active_shell() { ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" ' { pp[$1]=$2; st[$1]=$3; cm[$1]=$4; ord[NR]=$1 } function isshell(c) { return c ~ /^-?(ba|z|fi|k|da|a)?sh$/ } function depth(p, d) { d=0; while (p != "" && d < 64) { if (p == start) return d; p=pp[p]; d++ } return -1 } END { best=-1; bp=""; for (i=1; i<=NR; i++) { p=ord[i]; if (!isshell(cm[p])) continue; if (index(st[p], "+") == 0) continue; d=depth(p); if (d < 0) continue; if (d > best) { best=d; bp=p } } print (bp != "" ? bp : start) } ' } read_proc_env_value() { [ -r "$1" ] || return 1 tr '\0' '\n' < "$1" 2>/dev/null | sed -n "s/^$2=//p" | head -n1 } active_shell_pid="" login_shell_pid="" if [ -z "$forced_shell" ] && [ -d /proc ]; then login_shell_pid=$(find_login_shell "$PPID" || true) if [ -n "$login_shell_pid" ]; then active_shell_pid=$(find_active_shell "$login_shell_pid" || true) [ -n "$active_shell_pid" ] || active_shell_pid="$login_shell_pid" fi fi # After su / sudo su the foreground shell belongs to another user. This exec # channel runs as the login user, so it can neither verify nor configure that # shell (ptrace-scoped /proc access). Report the target shell so the caller # can retype the setup inside the terminal, where it runs as that user (#1942). if [ -n "$active_shell_pid" ]; then active_uid=$(sed -n "s/^Uid:[[:space:]]*\([0-9]*\).*/\1/p" "/proc/$active_shell_pid/status" 2>/dev/null | head -n1 || true) self_uid=$(id -u 2>/dev/null || true) if [ -n "$active_uid" ] && [ -n "$self_uid" ] && [ "$active_uid" != "$self_uid" ]; then other_shell=$(cat "/proc/$active_shell_pid/comm" 2>/dev/null | sed "s/^-//" | tr -d "[:space:]" || true) printf '%s%s\n' '${OSC7_SETUP_OTHER_USER_MARKER}' "${DOLLAR}{other_shell:-unknown}" printf "Netcatty OSC 7 setup: the active terminal shell belongs to another user\n" >&2 exit 5 fi fi if [ -d /proc ] && [ -n "$expected_cwd" ]; then if [ -z "$active_shell_pid" ]; then printf "Netcatty OSC 7 setup: could not identify the active terminal shell\n" >&2 exit 4 fi active_cwd=$(readlink "/proc/$active_shell_pid/cwd" 2>/dev/null || true) if [ "$active_cwd" != "$expected_cwd" ]; then printf "Netcatty OSC 7 setup: active terminal shell did not match the current tab\n" >&2 exit 4 fi fi active_comm="" active_home="" active_shell_env="" active_zdotdir="" active_xdg_config_home="" if [ -n "$active_shell_pid" ]; then active_comm=$(cat "/proc/$active_shell_pid/comm" 2>/dev/null | sed "s/^-//" | tr -d "[:space:]") active_env_file="/proc/$active_shell_pid/environ" if [ -r "$active_env_file" ]; then active_home=$(read_proc_env_value "$active_env_file" HOME || true) active_shell_env=$(read_proc_env_value "$active_env_file" SHELL || true) active_zdotdir=$(read_proc_env_value "$active_env_file" ZDOTDIR || true) active_xdg_config_home=$(read_proc_env_value "$active_env_file" XDG_CONFIG_HOME || true) elif [ "$active_shell_pid" != "$login_shell_pid" ]; then printf "Netcatty OSC 7 setup: cannot silently configure an active shell owned by another user\n" >&2 exit 3 fi fi parent_shell=$(ps -p "$PPID" -o comm= 2>/dev/null | sed "s/^-//" | tr -d "[:space:]") login_shell=$(basename "${DOLLAR}{active_shell_env:-${DOLLAR}{SHELL:-sh}}" | sed "s/^-//") shell_name="$login_shell" case "$parent_shell" in bash|zsh|fish) shell_name="$parent_shell" ;; esac case "$active_comm" in bash|zsh|fish) shell_name="$active_comm" ;; esac case "$forced_shell" in bash|zsh|fish) shell_name="$forced_shell" ;; esac home_dir="${DOLLAR}{active_home:-$HOME}" zdotdir="${DOLLAR}{active_zdotdir:-${DOLLAR}{NETCATTY_ZDOTDIR:-${DOLLAR}{ZDOTDIR:-$home_dir}}}" xdg_config_home="${DOLLAR}{active_xdg_config_home:-${DOLLAR}{NETCATTY_XDG_CONFIG_HOME:-${DOLLAR}{XDG_CONFIG_HOME:-$home_dir/.config}}}" case "$shell_name" in bash) config="$home_dir/.bashrc" ;; zsh) config="$zdotdir/.zshrc" ;; fish) config="$xdg_config_home/fish/config.fish" ;; *) printf "Netcatty OSC 7 setup: unsupported shell %s\n" "$shell_name" >&2 printf "Supported shells: bash, zsh, fish\n" >&2 exit 2 ;; esac __netcatty_osc7_url_path() { printf "%s" "$1" | LC_ALL=C awk ${URL_PATH_AWK_SCRIPT_QUOTED} } mkdir -p "$(dirname "$config")" touch "$config" # Snippet v2: guarded prompt entry + unexport PROMPT_COMMAND so su without # login does not inherit a bare osc7_cwd call into another user's shell. snippet_version_marker="netcatty-osc7-version: 2" end_marker="# <<< Netcatty OSC 7 cwd tracking <<<" # Returns 0 when every start marker is closed by a matching end marker. # Markers must be whole lines (optional indent) so an echo of the marker text # does not count as a block boundary. netcatty_osc7_markers_balanced() { awk -v start="$marker" -v end="$end_marker" ' function trim(s) { sub(/^[ \t]+/, "", s) sub(/[ \t]+$/, "", s) return s } { t = trim($0) if (t == start) { if (skip) incomplete = 1 else skip = 1 next } if (t == end) { if (!skip) incomplete = 1 else skip = 0 next } } END { if (skip) incomplete = 1 exit incomplete ? 1 : 0 } ' "$1" } # Returns 0 when at least one contiguous start..end region contains the v2 # version marker line. Older malformed markers outside that region are ignored # so recovery appends stay idempotent. netcatty_osc7_has_complete_v2_block() { awk -v start="$marker" -v end="$end_marker" -v ver_line="# $snippet_version_marker" ' function trim(s) { sub(/^[ \t]+/, "", s) sub(/[ \t]+$/, "", s) return s } { t = trim($0) if (t == start) { skip = 1 has_ver = 0 next } if (skip && t == ver_line) has_ver = 1 if (t == end) { if (skip && has_ver) found = 1 skip = 0 has_ver = 0 next } } END { exit found ? 0 : 1 } ' "$1" } # Resolve config to a real path so upgrades rewrite the final target of a # symlink chain without replacing intermediate links. netcatty_osc7_resolve_path() { _path="$1" if command -v realpath >/dev/null 2>&1; then _resolved=$(realpath "$_path" 2>/dev/null || true) if [ -n "$_resolved" ]; then printf '%s\n' "$_resolved" return 0 fi fi _depth=0 while [ -L "$_path" ] && [ "$_depth" -lt 32 ]; do _link=$(readlink "$_path" 2>/dev/null || true) [ -n "$_link" ] || break case "$_link" in /*) _path="$_link" ;; *) _path="$(dirname "$_path")/$_link" ;; esac _depth=$((_depth + 1)) done printf '%s\n' "$_path" } # Best-effort portable mode bits for chmod after atomic replace. netcatty_osc7_file_mode() { if stat -c '%a' "$1" >/dev/null 2>&1; then stat -c '%a' "$1" elif stat -f '%OLp' "$1" >/dev/null 2>&1; then stat -f '%OLp' "$1" fi } # Best-effort portable owner:group for chown after atomic replace. netcatty_osc7_file_owner() { if stat -c '%u:%g' "$1" >/dev/null 2>&1; then stat -c '%u:%g' "$1" elif stat -f '%u:%g' "$1" >/dev/null 2>&1; then stat -f '%u:%g' "$1" fi } # Append the v2 snippet to the given file path ($1). netcatty_osc7_append_v2() { __netcatty_osc7_dest="$1" case "$shell_name" in bash) cat >> "$__netcatty_osc7_dest" <<'NETCATTY_OSC7_BASH' # >>> Netcatty OSC 7 cwd tracking >>> # netcatty-osc7-version: 2 __netcatty_osc7_url_path() { printf "%s" "$1" | LC_ALL=C awk '${URL_PATH_AWK_SCRIPT}' } osc7_cwd() { printf '\033]7;file://%s%s\a' "${DOLLAR}{HOSTNAME:-localhost}" "$(__netcatty_osc7_url_path "$PWD")" } # Safe prompt hook: no-op when helpers are missing (PROMPT_COMMAND string may # be inherited across su without this rc file). __netcatty_osc7_prompt() { if declare -F osc7_cwd >/dev/null 2>&1; then osc7_cwd fi } # Install/dedupe the guarded prompt hook for both scalar and array PROMPT_COMMAND. __netcatty_osc7_hook='declare -F __netcatty_osc7_prompt >/dev/null 2>&1 && __netcatty_osc7_prompt' # Match declare -a / -ax / -ar etc. (array flag may appear with other flags). if declare -p PROMPT_COMMAND 2>/dev/null | grep -Eq 'declare -[A-Za-z]*a'; then __netcatty_osc7_new=() __netcatty_osc7_has_hook=0 for __netcatty_osc7_el in "${DOLLAR}{PROMPT_COMMAND[@]}"; do case "${DOLLAR}{__netcatty_osc7_el}" in osc7_cwd|__netcatty_osc7_prompt) continue ;; *'declare -F __netcatty_osc7_prompt'*) if [ "${DOLLAR}{__netcatty_osc7_has_hook}" -eq 0 ]; then __netcatty_osc7_new+=("${DOLLAR}{__netcatty_osc7_hook}") __netcatty_osc7_has_hook=1 fi ;; *) __netcatty_osc7_new+=("${DOLLAR}{__netcatty_osc7_el}") ;; esac done if [ "${DOLLAR}{__netcatty_osc7_has_hook}" -eq 0 ]; then __netcatty_osc7_new+=("${DOLLAR}{__netcatty_osc7_hook}") fi PROMPT_COMMAND=("${DOLLAR}{__netcatty_osc7_new[@]}") unset __netcatty_osc7_new __netcatty_osc7_has_hook __netcatty_osc7_el 2>/dev/null || true else if [ -n "${DOLLAR}{PROMPT_COMMAND+x}" ]; then __netcatty_osc7_pc="" __netcatty_osc7_sep="" while IFS= read -r __netcatty_osc7_line || [ -n "${DOLLAR}{__netcatty_osc7_line}" ]; do case "${DOLLAR}{__netcatty_osc7_line}" in osc7_cwd|__netcatty_osc7_prompt) continue ;; *'declare -F __netcatty_osc7_prompt'*) continue ;; *) __netcatty_osc7_pc="${DOLLAR}{__netcatty_osc7_pc}${DOLLAR}{__netcatty_osc7_sep}${DOLLAR}{__netcatty_osc7_line}" __netcatty_osc7_sep=" " ;; esac done </dev/null || true else PROMPT_COMMAND="${DOLLAR}{__netcatty_osc7_hook}" fi fi unset __netcatty_osc7_hook 2>/dev/null || true # Do not force-unexport PROMPT_COMMAND: the guarded hook is safe if inherited # across non-login su (declare -F fails quietly), and users may intentionally # export PROMPT_COMMAND for child shells. # <<< Netcatty OSC 7 cwd tracking <<< NETCATTY_OSC7_BASH ;; zsh) cat >> "$__netcatty_osc7_dest" <<'NETCATTY_OSC7_ZSH' # >>> Netcatty OSC 7 cwd tracking >>> # netcatty-osc7-version: 2 __netcatty_osc7_url_path() { printf "%s" "$1" | LC_ALL=C awk '${URL_PATH_AWK_SCRIPT}' } osc7_cwd() { printf '\033]7;file://%s%s\a' "${DOLLAR}{HOST:-${DOLLAR}{HOSTNAME:-localhost}}" "$(__netcatty_osc7_url_path "$PWD")" } __netcatty_osc7_prompt() { if typeset -f osc7_cwd >/dev/null 2>&1; then osc7_cwd fi } if (( ${DOLLAR}{+precmd_functions} )); then precmd_functions=(${DOLLAR}{precmd_functions:#osc7_cwd}) case " ${DOLLAR}{precmd_functions[*]} " in *" __netcatty_osc7_prompt "*) ;; *) precmd_functions+=(__netcatty_osc7_prompt) ;; esac else precmd_functions=(__netcatty_osc7_prompt) fi # <<< Netcatty OSC 7 cwd tracking <<< NETCATTY_OSC7_ZSH ;; fish) cat >> "$__netcatty_osc7_dest" <<'NETCATTY_OSC7_FISH' # >>> Netcatty OSC 7 cwd tracking >>> # netcatty-osc7-version: 2 function __netcatty_osc7_url_path printf "%s" "$argv[1]" | LC_ALL=C awk '${URL_PATH_AWK_SCRIPT}' end function __netcatty_osc7_cwd --on-event fish_prompt printf '\033]7;file://%s%s\a' (hostname 2>/dev/null; or printf localhost) (__netcatty_osc7_url_path "$PWD") end # <<< Netcatty OSC 7 cwd tracking <<< NETCATTY_OSC7_FISH ;; esac } need_write=1 # Whole-line marker presence only (not substring matches inside echo etc.). if awk -v start="$marker" ' function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } trim($0) == start { found = 1; exit } END { exit found ? 0 : 1 } ' "$config"; then if netcatty_osc7_has_complete_v2_block "$config"; then # At least one complete v2 block exists (even if older junk markers remain). need_write=0 elif netcatty_osc7_markers_balanced "$config"; then # Complete balanced block without v2 (legacy). Replace the marked region # in place (not strip-then-append-at-EOF) so surrounding control flow such # as if/then/fi wrappers stay valid. Build the full file in a temp, then # atomically replace so read-only modes cannot lose the block without the # replacement. __netcatty_osc7_target=$(netcatty_osc7_resolve_path "$config") __netcatty_osc7_dir=$(dirname "$__netcatty_osc7_target") __netcatty_osc7_mode=$(netcatty_osc7_file_mode "$__netcatty_osc7_target" || true) __netcatty_osc7_owner=$(netcatty_osc7_file_owner "$__netcatty_osc7_target" || true) # Prefer same-dir atomic replace. If the directory is not writable # (managed homes), fall back to append without aborting setup. __netcatty_osc7_tmp=$(mktemp "$__netcatty_osc7_dir/.netcatty-osc7.XXXXXX" 2>/dev/null || true) __netcatty_osc7_snip=$(mktemp "$__netcatty_osc7_dir/.netcatty-osc7-snip.XXXXXX" 2>/dev/null || true) if [ -z "${DOLLAR}{__netcatty_osc7_tmp:-}" ] || [ -z "${DOLLAR}{__netcatty_osc7_snip:-}" ]; then rm -f "$__netcatty_osc7_tmp" "$__netcatty_osc7_snip" 2>/dev/null || true need_write=1 else : > "$__netcatty_osc7_snip" netcatty_osc7_append_v2 "$__netcatty_osc7_snip" if awk -v start="$marker" -v end="$end_marker" -v snip="$__netcatty_osc7_snip" ' function trim(s) { sub(/^[ \t]+/, "", s) sub(/[ \t]+$/, "", s) return s } { t = trim($0) if (t == start) { while ((getline line < snip) > 0) print line close(snip) skip = 1 next } if (t == end) { skip = 0 next } if (!skip) print } ' "$config" > "$__netcatty_osc7_tmp" then rm -f "$__netcatty_osc7_snip" if [ -n "${DOLLAR}{__netcatty_osc7_mode:-}" ]; then chmod "$__netcatty_osc7_mode" "$__netcatty_osc7_tmp" 2>/dev/null || true fi if [ -n "${DOLLAR}{__netcatty_osc7_owner:-}" ]; then # If we cannot restore ownership, leave the original file untouched. if ! chown "$__netcatty_osc7_owner" "$__netcatty_osc7_tmp" 2>/dev/null; then rm -f "$__netcatty_osc7_tmp" need_write=1 else mv -f "$__netcatty_osc7_tmp" "$__netcatty_osc7_target" need_write=0 fi else mv -f "$__netcatty_osc7_tmp" "$__netcatty_osc7_target" need_write=0 fi else rm -f "$__netcatty_osc7_tmp" "$__netcatty_osc7_snip" need_write=1 fi fi else # Incomplete or unbalanced markers and no complete v2 yet. # Never rewrite/truncate: append a complete v2 block only. need_write=1 fi fi if [ "$need_write" = 1 ]; then netcatty_osc7_append_v2 "$config" fi if [ -z "$forced_shell" ]; then printf '%s%s\n' '${OSC7_SETUP_SHELL_MARKER}' "$shell_name" printf '%s%s\n' '${OSC7_SETUP_CONFIG_MARKER}' "$config" fi host=$(hostname 2>/dev/null || printf localhost) printf '\033]7;file://%s%s\a' "$host" "$(__netcatty_osc7_url_path "$PWD")"`; export const buildOsc7SetupCommand = (): string => `set +u 2>/dev/null || true; printf "%s\\n" ${quoteForSingleQuotedShellString(POSIX_SETUP_SCRIPT)} | env NETCATTY_ZDOTDIR="$ZDOTDIR" NETCATTY_XDG_CONFIG_HOME="$XDG_CONFIG_HOME" sh\n`; export const buildOsc7SetupExecCommand = (expectedCwd?: string): string => { const envPrefix = expectedCwd ? `env NETCATTY_OSC7_EXPECTED_CWD=${quoteForSingleQuotedShellString(expectedCwd)} ` : ""; return `exec ${envPrefix}sh -c ${quoteForSingleQuotedShellString(POSIX_SETUP_SCRIPT)}\n`; }; export const OSC7_SETUP_STAGED_MARKER = "__NETCATTY_OSC7_SETUP_STAGED__="; /** Exact bytes the stage command writes (printf '%s\n' appends the newline). */ const STAGED_SETUP_SCRIPT_BYTES = `${POSIX_SETUP_SCRIPT}\n`; let stagedScriptSha256Promise: Promise | null = null; /** * SHA-256 (hex) of the staged setup script bytes, computed locally so the * typed runner can verify the remote file was not tampered with. */ export const getOsc7StagedScriptSha256 = (): Promise => { if (!stagedScriptSha256Promise) { stagedScriptSha256Promise = crypto.subtle .digest("SHA-256", new TextEncoder().encode(STAGED_SETUP_SCRIPT_BYTES)) .then((digest) => Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""), ); } return stagedScriptSha256Promise; }; /** * Exec-channel command that stages the setup script into a world-readable * remote temp file. Typed fallback (#1942) runs that file instead of pasting * the multi-line script into the terminal: the typed command stays a single * line, so it is one history entry that the bash cleanup reliably deletes. */ export const buildOsc7StageScriptCommand = (): string => { const stageScript = `set -eu umask 022 file=$(mktemp /tmp/.netcatty-osc7-setup.XXXXXX) printf '%s\\n' ${quoteForSingleQuotedShellString(POSIX_SETUP_SCRIPT)} > "$file" chmod 644 "$file" printf '%s%s\\n' '${OSC7_SETUP_STAGED_MARKER}' "$file"`; return `exec sh -c ${quoteForSingleQuotedShellString(stageScript)}\n`; }; /** * POSIX runner that executes the staged script without a TOCTOU window: it * reads the file into memory ONCE, deletes it, verifies the local SHA-256 of * the in-memory copy, and only then pipes that same copy to sh. The staged * file stays writable by the login user until this runs, so a same-uid * process could otherwise swap its contents and have the su-target shell * (e.g. root) execute them. */ const buildVerifiedStagedRunner = (contentSha256: string): string => `c=$(cat -- "$1" 2>/dev/null); rm -f -- "$1" 2>/dev/null; ` + `h=$(printf "%s\\n" "$c" | sha256sum 2>/dev/null | cut -d" " -f1); ` + `[ -n "$h" ] || h=$(printf "%s\\n" "$c" | shasum -a 256 2>/dev/null | cut -d" " -f1); ` + `[ -n "$h" ] || h=$(printf "%s\\n" "$c" | openssl dgst -sha256 2>/dev/null | sed "s/^.* //"); ` + `if [ "x$h" = "x${contentSha256}" ]; then printf "%s\\n" "$c" | sh; ` + `else printf "%s\\n" "Netcatty OSC 7 setup: staged script verification failed" >&2; fi`; /** * Setup command typed into the interactive terminal itself. Used when the * silent exec-channel setup cannot configure the active shell because it is * owned by another user (after `su` / `sudo su`, #1942): typed input runs as * that user, so the config lands in the target user's rc file and OSC 7 * reporting resumes for this and every future user-switched shell. * * The command hash-verifies and runs the staged script (see * buildOsc7StageScriptCommand / buildVerifiedStagedRunner) inside a POSIX * `sh -c` child, and forwards shell-local (possibly unexported) ZDOTDIR / * XDG_CONFIG_HOME via the NETCATTY_* overrides the setup script already * honors, mirroring buildOsc7SetupCommand. */ export const buildOsc7TypedSetupCommand = ( shell: Osc7SetupShell, scriptPath: string, contentSha256: string, ): string => { const quotedPath = quoteForSingleQuotedShellString(scriptPath); const quotedRunner = quoteForSingleQuotedShellString(buildVerifiedStagedRunner(contentSha256)); if (shell === "bash") { const run = `env NETCATTY_OSC7_FORCE_SHELL=bash sh -c ${quotedRunner} sh ${quotedPath}`; return `${run}; . "${DOLLAR}HOME/.bashrc" >/dev/null 2>&1; osc7_cwd 2>/dev/null; true; ${BASH_DELETE_MARKED_HISTORY_COMMAND}\r`; } if (shell === "zsh") { const run = `env NETCATTY_OSC7_FORCE_SHELL=zsh NETCATTY_ZDOTDIR="${DOLLAR}{ZDOTDIR:-}" sh -c ${quotedRunner} sh ${quotedPath}`; // Leading space keeps the command out of history when HIST_IGNORE_SPACE is set. return ` ${run}; . "${DOLLAR}{ZDOTDIR:-${DOLLAR}HOME}/.zshrc" >/dev/null 2>&1; osc7_cwd 2>/dev/null; true\r`; } const run = `env NETCATTY_OSC7_FORCE_SHELL=fish NETCATTY_XDG_CONFIG_HOME="${DOLLAR}XDG_CONFIG_HOME" sh -c ${quotedRunner} sh ${quotedPath}`; return ` ${run}; source (test -n "${DOLLAR}XDG_CONFIG_HOME"; and echo "${DOLLAR}XDG_CONFIG_HOME"; or echo "${DOLLAR}HOME/.config")/fish/config.fish >/dev/null 2>&1; __netcatty_osc7_cwd 2>/dev/null; true\r`; }; const isOsc7SetupShell = (value: string): value is Osc7SetupShell => value === "bash" || value === "zsh" || value === "fish"; const readMarkerLine = (stdout: string, marker: string): string | null => { const line = stdout.split(/\r?\n/).find((entry) => entry.startsWith(marker)); return line ? line.slice(marker.length).trim() : null; }; export const parseOsc7SetupMetadata = (stdout: string): Osc7SetupMetadata | null => { const shell = readMarkerLine(stdout, OSC7_SETUP_SHELL_MARKER); const configPath = readMarkerLine(stdout, OSC7_SETUP_CONFIG_MARKER); if (!shell || !isOsc7SetupShell(shell) || !configPath) return null; return { shell, configPath }; }; /** Shell of the other-user foreground shell reported by the setup script, if any. */ export const parseOsc7SetupOtherUserShell = (stdout: string): string | null => readMarkerLine(stdout, OSC7_SETUP_OTHER_USER_MARKER); /** Remote path of the staged setup script, if the stage command reported one. */ export const parseOsc7SetupStagedPath = (stdout: string): string | null => { const path = readMarkerLine(stdout, OSC7_SETUP_STAGED_MARKER); return path && path.startsWith("/") ? path : null; }; export const extractOsc7SetupTerminalData = (stdout: string): string => { const escape = String.fromCharCode(0x1b); const bell = String.fromCharCode(0x07); const prefix = `${escape}]7;`; let offset = 0; let output = ""; while (offset < stdout.length) { const start = stdout.indexOf(prefix, offset); if (start < 0) break; const bodyStart = start + prefix.length; const bellEnd = stdout.indexOf(bell, bodyStart); const stEnd = stdout.indexOf(`${escape}\\`, bodyStart); const hasBellEnd = bellEnd >= 0; const hasStEnd = stEnd >= 0; if (!hasBellEnd && !hasStEnd) break; const useBell = hasBellEnd && (!hasStEnd || bellEnd < stEnd); const end = useBell ? bellEnd : stEnd; const terminatorLength = useBell ? 1 : 2; output += stdout.slice(start, end + terminatorLength); offset = end + terminatorLength; } return output; }; export const buildOsc7ReloadCommand = (metadata: Osc7SetupMetadata | null): string | null => { if (!metadata) return null; const sourceCommand = `source ${quoteForSingleQuotedShellString(metadata.configPath)} >/dev/null 2>&1`; const emitCommand = metadata.shell === "fish" ? "__netcatty_osc7_cwd" : "osc7_cwd"; if (metadata.shell === "bash") { return `${sourceCommand}; ${emitCommand} 2>/dev/null; true; ${BASH_DELETE_MARKED_HISTORY_COMMAND}\r`; } return ` ${sourceCommand}; ${emitCommand} 2>/dev/null; true\r`; }; export const runOsc7SetupAction = async ({ status, sessionId, setupCommand, setupOsc7Tracking, writeToSession, writeLocalTerminalData, }: RunOsc7SetupActionOptions): Promise => { if (status !== "connected") { return { success: false, error: "Terminal is not connected" }; } if (!setupOsc7Tracking) { return { success: false, error: "Directory tracking setup is unavailable" }; } const result = await setupOsc7Tracking(sessionId, setupCommand); // The exec channel cannot configure a shell owned by another user (after // su / sudo su). Stage the setup script into a remote temp file over the // exec channel, then type a short single-line command into the terminal to // run it as that user (#1942). const otherUserShell = parseOsc7SetupOtherUserShell(result.stdout || ""); if (otherUserShell) { if (!isOsc7SetupShell(otherUserShell)) { return { ...result, success: false, error: `Directory tracking does not support the current shell (${otherUserShell})`, }; } const stageResult = await setupOsc7Tracking(sessionId, buildOsc7StageScriptCommand()); const stagedPath = stageResult.success && (typeof stageResult.code !== "number" || stageResult.code === 0) ? parseOsc7SetupStagedPath(stageResult.stdout || "") : null; if (!stagedPath) { return { ...stageResult, success: false, error: stageResult.error || stageResult.stderr?.trim() || "Directory tracking setup failed", }; } const contentSha256 = await getOsc7StagedScriptSha256(); const typedCommand = buildOsc7TypedSetupCommand(otherUserShell, stagedPath, contentSha256); writeToSession(sessionId, typedCommand, { automated: true, logRewrite: { sentCommand: typedCommand, displayCommand: "" }, }); return { ...result, success: true, sentToTerminal: true }; } if (!result.success || (typeof result.code === "number" && result.code !== 0)) { return { ...result, success: false, error: result.error || result.stderr?.trim() || "Directory tracking setup failed", }; } const metadata = parseOsc7SetupMetadata(result.stdout || ""); const reloadCommand = buildOsc7ReloadCommand(metadata); if (!reloadCommand) { return { ...result, success: false, error: "Directory tracking setup did not return reload metadata", }; } const terminalData = extractOsc7SetupTerminalData(result.stdout || ""); if (terminalData) { writeLocalTerminalData?.(terminalData); } writeToSession(sessionId, reloadCommand, { automated: true, logRewrite: { sentCommand: reloadCommand, displayCommand: "" }, }); return { ...result, success: true, reloadCommand }; };