fix: store slash commands in input history (#3148)

Previously only 4 commands (/plan, /goal, /mcp, /ssh) stored their text
in history via per-handler addToHistory calls. All other built-in slash
commands were silently skipped because executeBuiltinSlashCommand returned
true before the input controller's addToHistory was reached.

- Centralize history recording in the input controller after successful
  slash command dispatch, for both Enter and Ctrl+Enter submit paths.
- Remove all 10 per-command addToHistory calls from slash command handlers
  to prevent duplicates.
- Add shouldSkipHistory() security filter to exclude commands that may
  carry secrets: /login <url> (OAuth callback with code=/state= params)
  and /mcp add --token <token> (bearer token).
- Add regression tests for the security filter (8 cases).
- Update 7 existing test files to remove handler-level addToHistory
  assertions (now the input controller's responsibility).
This commit is contained in:
oldschoola
2026-06-23 14:11:12 -07:00
parent 93d730c01d
commit 715eb0792c
11 changed files with 79 additions and 57 deletions
@@ -27,6 +27,27 @@ import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } fr
import { resizeImage } from "../../utils/image-resize";
import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
/**
* Slash commands that may carry secrets in their arguments should never be
* persisted to history. /login <url> receives an OAuth callback URL containing
* code= and state= params. /mcp add --token <token> receives a bearer token.
*/
export function shouldSkipHistory(slashText: string): boolean {
if (!slashText.startsWith("/")) return false;
const name = slashText.slice(1).split(/\s+/, 1)[0];
// /login <url> — the redirect URL carries OAuth code= and state= params.
// /login <provider> (e.g. "anthropic") is safe — it just opens the selector.
if (name === "login" && slashText.length > "/login".length) {
const arg = slashText.slice("/login".length).trim();
return arg.includes("://") || arg.startsWith("http");
}
if (name === "mcp") {
const args = slashText.slice("/mcp".length).trim();
return args.startsWith("add") && /--token\s/.test(args);
}
return false;
}
interface Expandable {
setExpanded(expanded: boolean): void;
}
@@ -572,6 +593,7 @@ export class InputController {
ctx: this.ctx,
});
if (slashResult === true) {
if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
return;
}
if (typeof slashResult === "string") {
@@ -1011,6 +1033,7 @@ export class InputController {
ctx: this.ctx,
});
if (slashResult === true) {
if (!shouldSkipHistory(text)) this.ctx.editor.addToHistory(text);
return;
}
if (typeof slashResult === "string") {