fix(coding-agent): run fish user shell interactively instead of as a login shell

The interactive !/!! shortcut wrapped commands as `fish -l -c '…'`:
resolveUserShellConfig swaps in $SHELL but inherits the bash-oriented
["-l", "-c"] args, and ensureInteractiveShellArgs injected -i only
for zsh. A login fish fires `status is-login` blocks in user config
(agent/keychain setup, PATH mutation) on every command.

fish sources the same config.fish/conf.d files for interactive shells
as for login shells, so give fish -i and strip the inherited -l: user
aliases and functions (#1816) keep working without login-shell side
effects. zsh keeps -l -i since .zprofile is login-only.
This commit is contained in:
iacore
2026-07-19 14:34:10 +08:00
parent 9fd6e97113
commit eb6ba80420
3 changed files with 79 additions and 9 deletions
@@ -276,8 +276,10 @@ exit 64
expect(result.cancelled).toBe(false);
expect(result.exitCode).toBe(0);
expect(result.output.trim()).toBe("env-shell-ok");
expect(fs.readFileSync(marker, "utf8")).toContain("-l -c");
expect(fs.readFileSync(marker, "utf8")).not.toContain("-i");
// fish gets `-i` (interactive loads config.fish too) instead of `-l`,
// so `status is-login` blocks in user config don't fire on `!` commands.
expect(fs.readFileSync(marker, "utf8")).toContain("-i -c");
expect(fs.readFileSync(marker, "utf8")).not.toContain("-l");
} finally {
if (originalShell === undefined) {
delete Bun.env.SHELL;
@@ -330,6 +332,58 @@ exit 64
}
});
it("runs fish user-shell commands without login-shell side effects", async () => {
if (process.platform === "win32") {
return;
}
const fishPath = ["/usr/bin/fish", "/bin/fish", "/usr/local/bin/fish", "/opt/homebrew/bin/fish"].find(
candidate => fs.existsSync(candidate),
);
if (!fishPath) {
return;
}
const shellDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-fish-shellpath-"));
const configDir = path.join(shellDir, ".config", "fish");
fs.mkdirSync(path.join(configDir, "conf.d"), { recursive: true });
fs.writeFileSync(path.join(configDir, "config.fish"), "function pi_fish_fn; echo fish-fn-ok; end\n");
// Login-gated snippet: fires only when the spawned fish is a login shell.
fs.writeFileSync(
path.join(configDir, "conf.d", "pi-login.fish"),
"if status is-login; echo fish-login-side-effect; end\n",
);
Settings.instance.set("shellPath", fishPath);
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
shell: fishPath,
args: ["-l", "-c"],
env: {
PATH: Bun.env.PATH ?? "",
HOME: shellDir,
},
prefix: undefined,
});
try {
const result = await executeBash("pi_fish_fn", {
cwd: tempDir,
timeout: 5000,
sessionKey: "fish-shell-path",
useUserShell: true,
});
expect(result.cancelled).toBe(false);
expect(result.exitCode).toBe(0);
// config.fish must still load (#1816 contract)…
expect(result.output).toContain("fish-fn-ok");
// …but the shell must not be a login shell.
expect(result.output).not.toContain("fish-login-side-effect");
} finally {
removeSyncWithRetries(shellDir);
}
});
it("invokes onChunk with command output", async () => {
let seenChunk: string | null = null;
const result = await executeBash("echo hello", {