From bac738b2e5cadd0766c45f3fd952a25e69c3015d Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:23:45 +0000 Subject: [PATCH 1/2] Fold an array PROMPT_COMMAND into the bash integration's prompt hook systemd's 80-systemd-osc-context.sh, sourced from /etc/profile on Ubuntu 26.04, turns PROMPT_COMMAND into an array and appends its own hook. The bash integration captured and replaced only element 0, so on bash 5.1+ the hook ran after ours re-armed the DEBUG trap and every line reported the hook's name as its command. Fold every element into the user's hook and unset the array before installing ours. --- standalone/sidecar/shell-integration.test.js | 35 ++++++++++++++++--- .../bash/shellIntegration.bash | 11 ++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/standalone/sidecar/shell-integration.test.js b/standalone/sidecar/shell-integration.test.js index 0c44eb88b..099c4b94c 100644 --- a/standalone/sidecar/shell-integration.test.js +++ b/standalone/sidecar/shell-integration.test.js @@ -8,7 +8,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { execFileSync, spawnSync } = require('node:child_process'); -const { existsSync, mkdtempSync, rmSync } = require('node:fs'); +const { existsSync, mkdtempSync, rmSync, writeFileSync } = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -119,17 +119,19 @@ for (const [name, bin] of shells) { }); } -// bash 4.0 is where a `bind -x` command runs with READLINE_LINE bound. -const BASH_MAJOR = Number(execFileSync(BASH, ['-c', 'echo "${BASH_VERSINFO[0]}"'], { encoding: 'utf8' }).trim()); +// bash 4.0 is where a `bind -x` command runs with READLINE_LINE bound; 5.1 +// is where every element of an array PROMPT_COMMAND runs. +const [BASH_MAJOR, BASH_MINOR] = execFileSync(BASH, ['-c', 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"'], { encoding: 'utf8' }).trim().split(' ').map(Number); /** * Drive the real bash integration on a PTY, one submitted line at a time. * `run` resolves with every `E` the line emitted (decoded as the parser decodes * it) and the text the shell printed, once the next prompt is drawn. */ -function interactiveBash() { +function interactiveBash(profile) { const pty = require('node-pty'); const home = mkdtempSync(path.join(os.tmpdir(), 'dormouse-bash-')); + if (profile !== undefined) writeFileSync(path.join(home, '.profile'), profile); const shell = pty.spawn(BASH, ['--init-file', path.join(dir, 'shell-integration/bash/shellIntegration.bash')], { cols: 200, rows: 50, @@ -256,3 +258,28 @@ test('bash: E is the whole submitted line, and never a stale history entry', { t await bash.close(); } }); + +// systemd's /etc/profile.d/80-systemd-osc-context.sh (Ubuntu 26.04) turns +// PROMPT_COMMAND into an array with an empty first slot and appends its hook. +// Startup replaces only element 0, so the hook ran after ours re-armed the trap +// and every line reported the hook's name instead of itself. +test('bash: an array PROMPT_COMMAND from the profile keeps E the submitted line', { timeout: 60_000, skip: BASH_MAJOR < 5 || (BASH_MAJOR === 5 && BASH_MINOR < 1) }, async () => { + const bash = interactiveBash([ + '__sfx_ran() { __sfx_count=$((${__sfx_count:-0} + 1)); }', + 'PROMPT_COMMAND=( "${PROMPT_COMMAND:-}" )', + 'PROMPT_COMMAND+=(__sfx_ran)', + '', + ].join('\n')); + try { + for (const line of ['true', 'echo p && echo q']) { + const { commands, starts } = await bash.run(line); + assert.deepEqual(commands, [line], `bash reported ${JSON.stringify(commands)} for ${JSON.stringify(line)}`); + assert.equal(starts, 1); + } + // The profile's hook still runs once per prompt: the first, then two lines. + const { printed } = await bash.run('echo "n=$__sfx_count"'); + assert.match(printed, /n=3/); + } finally { + await bash.close(); + } +}); diff --git a/standalone/sidecar/shell-integration/bash/shellIntegration.bash b/standalone/sidecar/shell-integration/bash/shellIntegration.bash index 34d0eecc7..001c7898e 100644 --- a/standalone/sidecar/shell-integration/bash/shellIntegration.bash +++ b/standalone/sidecar/shell-integration/bash/shellIntegration.bash @@ -68,6 +68,16 @@ __dormouse_633_safe_cwd() { __dormouse_633_armed= # set at the END of the prompt hook: "the next command is the user's" __dormouse_633_ran= # a command actually executed since the last prompt __dormouse_633_user_pc="$PROMPT_COMMAND" # preserve the user's PROMPT_COMMAND +# bash 5.1+ runs every element of an array PROMPT_COMMAND, which systemd's +# 80-systemd-osc-context.sh makes it; one left past ours would run after the +# re-arm and report as the command. Fold them all into the user's hook. +if [ "${BASH_VERSINFO[0]}" -gt 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 5 ] && [ "${BASH_VERSINFO[1]}" -ge 1 ]; }; then + __dormouse_633_user_pc= + for __dormouse_pc in "${PROMPT_COMMAND[@]}"; do + [ -n "$__dormouse_pc" ] && __dormouse_633_user_pc+="${__dormouse_633_user_pc:+$'\n'}$__dormouse_pc" + done + unset __dormouse_pc +fi # precmd: runs via PROMPT_COMMAND just before each prompt. Reports the previous # command's exit (D), the cwd (P), and the prompt start (A). Disarms first so its @@ -140,6 +150,7 @@ __dormouse_633_preexec() { } trap '__dormouse_633_preexec "$_"' DEBUG +unset PROMPT_COMMAND # an array keeps its other elements through a plain assignment PROMPT_COMMAND='__dormouse_633_prompt' # Prompt-end / input-start (B) at the tail of PS1, wrapped in \[ \] so bash counts # it as zero width. Best-effort: a prompt rebuilt every render loses B, but From ceb602ec4d162186146e736d526a18c1ad742f9f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 27 Oct 2026 08:58:20 -0700 Subject: [PATCH 2/2] Give each array PROMPT_COMMAND element the command's exit status Joining the elements into one eval'd string handed each later element the status of the one before it, while bash 5.1+ gives every element the command's $?. A legacy `PROMPT_COMMAND=...` assigns element 0, so systemd's OSC 3008 hook reported every command as a success. Keep the elements as an array and set $? before each, through a function return instead of a fork. The test now uses systemd's own lines plus a legacy assignment, and checks the status the appended hook sees. Co-Authored-By: Claude Opus 5.5 --- standalone/sidecar/shell-integration.test.js | 21 +++++++----- .../bash/shellIntegration.bash | 34 ++++++++++--------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/standalone/sidecar/shell-integration.test.js b/standalone/sidecar/shell-integration.test.js index 099c4b94c..796f2cc7e 100644 --- a/standalone/sidecar/shell-integration.test.js +++ b/standalone/sidecar/shell-integration.test.js @@ -260,14 +260,17 @@ test('bash: E is the whole submitted line, and never a stale history entry', { t }); // systemd's /etc/profile.d/80-systemd-osc-context.sh (Ubuntu 26.04) turns -// PROMPT_COMMAND into an array with an empty first slot and appends its hook. -// Startup replaces only element 0, so the hook ran after ours re-armed the trap -// and every line reported the hook's name instead of itself. +// PROMPT_COMMAND into an array with an empty first slot and appends its hook; +// a legacy rc file then assigns element 0. Startup replaced only element 0, so +// the hook ran after ours re-armed the trap and every line reported the hook's +// name instead of itself. test('bash: an array PROMPT_COMMAND from the profile keeps E the submitted line', { timeout: 60_000, skip: BASH_MAJOR < 5 || (BASH_MAJOR === 5 && BASH_MINOR < 1) }, async () => { const bash = interactiveBash([ - '__sfx_ran() { __sfx_count=$((${__sfx_count:-0} + 1)); }', - 'PROMPT_COMMAND=( "${PROMPT_COMMAND:-}" )', + '__sfx_ran() { __sfx_status=$?; __sfx_count=$((${__sfx_count:-0} + 1)); }', + // systemd's own two lines, verbatim. + '[ -n "$(declare -p PROMPT_COMMAND 2>/dev/null)" ] || PROMPT_COMMAND+=(\'\')', 'PROMPT_COMMAND+=(__sfx_ran)', + 'PROMPT_COMMAND=false', '', ].join('\n')); try { @@ -276,9 +279,11 @@ test('bash: an array PROMPT_COMMAND from the profile keeps E the submitted line' assert.deepEqual(commands, [line], `bash reported ${JSON.stringify(commands)} for ${JSON.stringify(line)}`); assert.equal(starts, 1); } - // The profile's hook still runs once per prompt: the first, then two lines. - const { printed } = await bash.run('echo "n=$__sfx_count"'); - assert.match(printed, /n=3/); + // Each element sees the command's $?, not the status of the element before it. + await bash.run('(exit 7)'); + // The profile's hook still runs once per prompt: the first, then three lines. + const { printed } = await bash.run('echo "n=$__sfx_count s=$__sfx_status"'); + assert.match(printed, /n=4 s=7/); } finally { await bash.close(); } diff --git a/standalone/sidecar/shell-integration/bash/shellIntegration.bash b/standalone/sidecar/shell-integration/bash/shellIntegration.bash index 001c7898e..a2b099a25 100644 --- a/standalone/sidecar/shell-integration/bash/shellIntegration.bash +++ b/standalone/sidecar/shell-integration/bash/shellIntegration.bash @@ -8,8 +8,8 @@ # the OSC 633 prompt/command hooks. # # Written for bash 3.2 (the macOS system bash) and newer: a DEBUG trap for -# command-start and a string PROMPT_COMMAND for the prompt — no PS0 (4.4+) and no -# array PROMPT_COMMAND (5.1+). +# command-start and a string PROMPT_COMMAND for the prompt — no PS0 (4.4+), and an +# array PROMPT_COMMAND (5.1+) is only read, never installed. # --- Replicate login-shell startup (we are spawned without --login) ---------- if [ -r /etc/profile ]; then . /etc/profile; fi @@ -67,18 +67,18 @@ __dormouse_633_safe_cwd() { __dormouse_633_armed= # set at the END of the prompt hook: "the next command is the user's" __dormouse_633_ran= # a command actually executed since the last prompt -__dormouse_633_user_pc="$PROMPT_COMMAND" # preserve the user's PROMPT_COMMAND -# bash 5.1+ runs every element of an array PROMPT_COMMAND, which systemd's -# 80-systemd-osc-context.sh makes it; one left past ours would run after the -# re-arm and report as the command. Fold them all into the user's hook. -if [ "${BASH_VERSINFO[0]}" -gt 5 ] || { [ "${BASH_VERSINFO[0]}" -eq 5 ] && [ "${BASH_VERSINFO[1]}" -ge 1 ]; }; then - __dormouse_633_user_pc= - for __dormouse_pc in "${PROMPT_COMMAND[@]}"; do - [ -n "$__dormouse_pc" ] && __dormouse_633_user_pc+="${__dormouse_633_user_pc:+$'\n'}$__dormouse_pc" - done - unset __dormouse_pc +# Preserve the user's PROMPT_COMMAND, one entry per element. bash 5.1+ runs every +# element of an array PROMPT_COMMAND, which systemd's 80-systemd-osc-context.sh +# makes it; one left past ours would run after the re-arm and report as the +# command. Older bash runs only element 0. +__dormouse_633_user_pcs=("$PROMPT_COMMAND") +if (( BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 501 )); then + __dormouse_633_user_pcs=("${PROMPT_COMMAND[@]}") fi +# Set $? to $1 without the fork of `( exit "$1" )`. +__dormouse_633_status() { return "$1"; } + # precmd: runs via PROMPT_COMMAND just before each prompt. Reports the previous # command's exit (D), the cwd (P), and the prompt start (A). Disarms first so its # own commands — and the user's PROMPT_COMMAND — don't trip the preexec trap, and @@ -93,10 +93,12 @@ __dormouse_633_prompt() { __dormouse_633_safe_cwd "$PWD" printf '\033]633;P;Cwd=%s\007' "$__dormouse_633_out" printf '\033]633;A\007' - if [ -n "$__dormouse_633_user_pc" ]; then - ( exit "$exit_code" ) # restore $? for the user's PROMPT_COMMAND - eval "$__dormouse_633_user_pc" - fi + local __dormouse_633_pc + for __dormouse_633_pc in "${__dormouse_633_user_pcs[@]}"; do + [ -n "$__dormouse_633_pc" ] || continue + __dormouse_633_status "$exit_code" # each element sees the command's $?, as bash gives it + eval "$__dormouse_633_pc" + done __dormouse_633_armed=1 }