From ca0a28e562e50046c43dcf4cc79c6e5780219afd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 15:43:30 -0700 Subject: [PATCH] Keep Unix shell integration LF in Windows packages --- .gitattributes | 5 ++ docs/specs/terminal-state.md | 4 +- docs/specs/terminal-state.rationale.md | 2 + .../shell-integration-checkout.test.js | 51 +++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 standalone/sidecar/shell-integration-checkout.test.js diff --git a/.gitattributes b/.gitattributes index 4c3a7c55a..ef2de8104 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,11 @@ # The POSIX `dor` launcher (no extension) must stay LF. dor/bin/dor text eol=lf +# Windows bundles also supply these scripts to WSL and Git Bash. +# CRLF breaks parsing before the login profile can restore the user's PATH. +standalone/sidecar/shell-integration/bash/** text eol=lf +standalone/sidecar/shell-integration/zsh/** text eol=lf + # Markdown merges sentence by sentence once `pnpm setup:git` defines this # driver (scripts/md-merge.mjs); until then Git merges these files by line. *.md merge=md-sentences diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 0c35533d2..a9a31ee21 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -79,7 +79,9 @@ Source of truth: `TerminalProtocolParser` / `commandLineEvents` in `lib/src/lib/ **bash's `E` is the submitted line, read back from history only when the last entry provably is it, else its first simple command** (`$BASH_COMMAND`) (rationale). -**Both distributions ship the scripts**: standalone per `docs/specs/standalone.md` -> "Build and development", the VS Code build into `dist/shell-integration`, which the host names in `DORMOUSE_SHELL_INTEGRATION_DIR`. +**Both distributions ship scripts**: standalone via sidecar resources (`docs/specs/standalone.md` -> "Build and development"), VS Code in `dist/shell-integration`, located through `DORMOUSE_SHELL_INTEGRATION_DIR`. + +**Must ship bash/zsh scripts as LF, including Windows packages** (rationale). **Emitted fields must be filtered before they are written — a security boundary.** An attacker-chosen directory name or command can carry an OSC terminator (BEL, `ESC \`, or the C1 ST `U+009C`) that ends the `633` sequence early, so the remainder arrives as a fresh, fully-trusted OSC. **The parser cannot defend against this** — it scans raw bytes (rationale). The field grammar the parser decodes: diff --git a/docs/specs/terminal-state.rationale.md b/docs/specs/terminal-state.rationale.md index bf66016fb..851932037 100644 --- a/docs/specs/terminal-state.rationale.md +++ b/docs/specs/terminal-state.rationale.md @@ -14,6 +14,8 @@ Native path payloads are not URLs: decoding `%20` or trimming edge spaces change ## Shell-integration injection +**Why Unix integration scripts stay LF in Windows builds.** A Windows checkout with `core.autocrlf=true` converted the bundled Bash script to CRLF; WSL Bash failed on `do\r` before sourcing the user profile, so commands installed through that profile disappeared from PATH (observed on WSL2, 2026-10). `.gitattributes` keeps the packaged source valid for Unix shells. + **Why the mechanism cannot be uniform.** One env var guarantees a `PATH` binary is *found*, but no shell has an env var for *run our hook code on every prompt* — hence a per-shell mechanism, and hence the Channel column: an env-var channel is as reliable as the `PATH` prepend, while a `shellArgs` channel only fires for the launch shapes Dormouse recognizes. **Why bash injection keys on the launch args.** `--init-file` and login mode are mutually exclusive, so the script has to replace login-profile sourcing itself, which is only safe when the launch was a plain interactive/login shell — Git Bash's `--login -i` is why login flags stay in the allowed set, while anything with a specific `-c ` is a job, not a session. diff --git a/standalone/sidecar/shell-integration-checkout.test.js b/standalone/sidecar/shell-integration-checkout.test.js new file mode 100644 index 000000000..b5254841b --- /dev/null +++ b/standalone/sidecar/shell-integration-checkout.test.js @@ -0,0 +1,51 @@ +// Exercise the bytes a Windows checkout packages, even when CI runs on Linux. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +test('Windows checkout preserves executable Unix shell integration and login-profile startup', (t) => { + const root = path.resolve(__dirname, '../..'); + const fixture = mkdtempSync(path.join(os.tmpdir(), 'dormouse-shell-checkout-')); + t.after(() => rmSync(fixture, { recursive: true, force: true })); + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))); + env.GIT_CONFIG_GLOBAL = os.devNull; + env.GIT_CONFIG_NOSYSTEM = '1'; + const git = (...args) => execFileSync('git', [ + '-c', 'core.autocrlf=true', '-c', 'core.safecrlf=false', + '-c', `core.attributesFile=${os.devNull}`, ...args, + ], { cwd: fixture, env, stdio: 'pipe' }); + const relative = 'standalone/sidecar/shell-integration'; + cpSync(path.join(root, '.gitattributes'), path.join(fixture, '.gitattributes')); + cpSync(path.join(root, relative), path.join(fixture, relative), { recursive: true }); + git('init', '-q'); + git('add', '.gitattributes', relative); + const checkout = path.join(fixture, 'Windows checkout'); + mkdirSync(checkout); + git('checkout-index', '--all', `--prefix=${checkout}${path.sep}`); + + for (const shell of ['bash', 'zsh']) { + const directory = path.join(checkout, relative, shell); + for (const file of readdirSync(directory).filter((name) => name !== '.gitignore')) { + assert.ok(!readFileSync(path.join(directory, file)).includes(Buffer.from('\r\n')), + `${shell}/${file} must remain LF in a Windows checkout`); + } + } + + const home = path.join(fixture, 'home'); + mkdirSync(home); + writeFileSync(path.join(home, '.bash_profile'), 'export DORMOUSE_TEST_PROFILE=loaded\n'); + const result = spawnSync('bash', [ + '--init-file', path.join(checkout, relative, 'bash/shellIntegration.bash'), '-ic', + 'test "$DORMOUSE_TEST_PROFILE" = loaded && test "$__dormouse_633_installed" = 1', + ], { + cwd: home, + env: { PATH: process.env.PATH, HOME: home, TERM: 'xterm' }, + encoding: 'utf8', + timeout: 10000, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); +});