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
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/specs/terminal-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions docs/specs/terminal-state.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>` is a job, not a session.
Expand Down
51 changes: 51 additions & 0 deletions standalone/sidecar/shell-integration-checkout.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading