Files
oh-my-pi/packages/coding-agent/test/shell-snapshot.test.ts
T
roboomp 58fab73699 fix(coding-agent): filtered brush-incompatible aliases from shell snapshot
brush-core's alias expander resolves aliases via
`value.split_ascii_whitespace()` (`crates/brush-core-vendored/src/interp.rs:1500`,
upstream brush issue reubeno/brush#57): each whitespace piece is dropped
into argv as-is, completely bypassing the shell parser. Any alias body
containing `(`, `)`, `|`, `&`, `;`, `<`, `>`, or `\`` therefore
turns the first piece into the command name, so Fedora's default
`alias which='(alias; declare -f) | /usr/bin/which …'` produces
`error: command not found: (alias;` for every `which` invocation.

The user's shell snapshot is generated by sourcing their real rc-file
under `/bin/bash` or `/bin/zsh` (so we can capture functions, options,
PATH) and then sourced by brush per-session. `sanitizeSnapshotForBrush`
now scans the emitted `alias -- NAME='VALUE'` lines after generation,
drops any whose decoded body contains those metacharacters, and rewrites
the file in place before caching. Compatible aliases (`ll='ls -l'`,
`gc='git --color=auto commit'`, embedded-quote `say='echo '\\''hi'\\'''`)
are preserved untouched; dropped names are logged at debug. brush then
falls through to whatever lives on `PATH`, which is what the user
expected when they ran `which` in the first place.

Covered by unit tests for the sanitizer (Fedora-which case, every
incompatible-metachar shape, every preserve case) and an integration
test that loads a poisoned snapshot and verifies `which sh` now exits
`0` with a real path.

Fixes #3234
2026-06-22 06:19:04 +00:00

81 lines
3.2 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { sanitizeSnapshotForBrush } from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot";
// `sanitizeSnapshotForBrush` is the snapshot-side mitigation for brush's
// whitespace-only alias expander (`crates/brush-core-vendored/src/interp.rs:1500`,
// brush issue reubeno/brush#57). Aliases whose body needs real shell parsing
// must be dropped or brush turns the first whitespace piece into the command
// name and the user sees `error: command not found: (alias;` (issue #3234).
describe("sanitizeSnapshotForBrush", () => {
it("drops the Fedora `which` alias and reports the dropped name", () => {
const snapshot = [
"unalias -a 2>/dev/null || true",
"alias -- which='(alias; declare -f) | /usr/bin/which --tty-only --read-alias --show-dot --show-tilde'",
"alias -- ll='ls -l'",
"",
].join("\n");
const result = sanitizeSnapshotForBrush(snapshot);
expect(result.dropped).toEqual(["which"]);
expect(result.content).not.toContain("alias -- which=");
// Simple aliases must still pass through untouched.
expect(result.content).toContain("alias -- ll='ls -l'");
});
it.each([
["subshell", "alias -- a='(echo hi)'"],
["pipe", "alias -- a='cat /etc/hostname | head -n1'"],
["semicolon", "alias -- a='echo a; echo b'"],
["redirect-out", "alias -- a='tee >foo'"],
["redirect-in", "alias -- a='cat <foo'"],
["background", "alias -- a='sleep 1 &'"],
["command-substitution", "alias -- a='echo `date`'"],
// Embedded single-quote `'\''` → `'` decodes to a body with a literal
// single quote (and a pipe), so the filter still trips on the pipe.
["decoded-pipe", "alias -- a='cat '\\''one'\\'' | head'"],
])("drops aliases whose body needs shell parsing (%s)", (_label, line) => {
const result = sanitizeSnapshotForBrush(line + "\n");
expect(result.dropped).toEqual(["a"]);
expect(result.content).not.toContain("alias -- a=");
});
it.each([
["simple", "alias -- ll='ls -l'"],
["flag-with-equals", "alias -- gc='git --color=auto commit'"],
["multi-flag", "alias -- la='ls -lAh --group-directories-first'"],
// A plain single quote escape that decodes to a metachar-free body
// must survive — we only ban truly unparseable bodies.
["embedded-quote", "alias -- say='echo '\\''hello'\\'''"],
])("preserves aliases brush can handle by whitespace split (%s)", (_label, line) => {
const result = sanitizeSnapshotForBrush(line + "\n");
expect(result.dropped).toEqual([]);
expect(result.content).toContain(line);
});
it("leaves non-alias lines (functions, exports, unalias) untouched", () => {
const snapshot = [
"# Shell snapshot - generated by omp agent",
"unalias -a 2>/dev/null || true",
"my_fn () { echo hi; }",
"export PATH='/usr/bin:/bin'",
"shopt -s expand_aliases",
"alias -- which='(alias; declare -f) | /usr/bin/which'", // poisoned
].join("\n");
const result = sanitizeSnapshotForBrush(snapshot);
expect(result.dropped).toEqual(["which"]);
for (const keep of [
"# Shell snapshot - generated by omp agent",
"unalias -a 2>/dev/null || true",
"my_fn () { echo hi; }", // function body contains `;` but is not an alias line
"export PATH='/usr/bin:/bin'",
"shopt -s expand_aliases",
]) {
expect(result.content).toContain(keep);
}
});
});