fix(server): 正确编码 MCP 终端按键

send_terminal_keys 会将方向键、导航键和多数修饰键组合当作普通文字输入。
复用协议层编码器,并从终端前导序列恢复输入模式,保留 literal 和未知文本的原样发送行为。

补充 MCP 入口回归与真实 PTY 字节验证;MCP 文件 197 项测试通过,
最终终端回归 44 项通过,服务端类型、Lint 和格式检查通过。

Fixes #7337
This commit is contained in:
Aling33
2026-10-19 03:02:31 +08:00
parent aef7cc36a0
commit aab8250233
2 changed files with 171 additions and 29 deletions
@@ -64,7 +64,7 @@ import { createGitMutationService } from "../session/git-mutation/git-mutation-s
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
import type { ForgeService } from "../../services/forge-service.js";
import { areEquivalentPaths } from "../../utils/path.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
@@ -1251,6 +1251,134 @@ describe("browser MCP tools", () => {
describe("terminal MCP tools", () => {
const logger = createTestLogger();
it.each<[string, string, string, boolean]>([
["Down", "\x1b[B", "", false],
["Up", "\x1b[A", "", false],
["Left", "\x1b[D", "", false],
["Right", "\x1b[C", "", false],
["PageUp", "\x1b[5~", "", false],
["PageDown", "\x1b[6~", "", false],
["Home", "\x1b[H", "", false],
["End", "\x1b[F", "", false],
["Insert", "\x1b[2~", "", false],
["Delete", "\x1b[3~", "", false],
["F1", "\x1bOP", "", false],
["F5", "\x1b[15~", "", false],
["F12", "\x1b[24~", "", false],
["S-Tab", "\x1b[Z", "", false],
["C-r", "\x12", "", false],
["M-b", "\x1bb", "", false],
["C-M-Left", "\x1b[1;7D", "", false],
["C-S-Up", "\x1b[1;6A", "", false],
["C-Space", "\x00", "", false],
["Enter", "\r", "", false],
["Tab", "\t", "", false],
["Escape", "\x1b", "", false],
["Space", " ", "", false],
["BSpace", "\x7f", "", false],
["C-c", "\x03", "", false],
["C-d", "\x04", "", false],
["C-z", "\x1a", "", false],
["C-l", "\x0c", "", false],
["C-a", "\x01", "", false],
["C-e", "\x05", "", false],
["Down", "\x1bOB", "\x1b[?1h", false],
["S-Enter", "\x1b[13;2u", "\x1b[=1;1u", false],
["C-Enter", "\x1b[13;28;13;1;8;1_", "\x1b[?9001h", false],
["echo hello\r", "echo hello\r", "", false],
["\x1b[B", "\x1b[B", "", false],
["UnknownKey", "UnknownKey", "", false],
["constructor", "constructor", "", false],
["__proto__", "__proto__", "", false],
["", "", "", false],
["Down", "Down", "\x1b[?1h", true],
["C-r", "C-r", "", true],
["S-Tab", "S-Tab", "", true],
])(
"sends terminal token %j as %j (preamble=%j, literal=%s)",
async (keys, data, preamble, literal) => {
const { agentManager, agentStorage } = createTestDeps();
const send = vi.fn();
const terminalManager = createTerminalManagerStub({
getTerminal: vi.fn().mockReturnValue({
send,
getReplayPreamble: () => preamble,
}),
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createClaudeOnlyManager(),
terminalManager,
logger,
});
try {
const response = await registeredTool(server, "send_terminal_keys").handler({
terminalId: "term-1",
keys,
literal,
});
expect(response.structuredContent).toEqual({ success: true });
expect(send).toHaveBeenCalledExactlyOnceWith({ type: "input", data });
} finally {
await server.close();
}
},
);
it("delivers MCP key tokens to a real terminal in application cursor mode", async () => {
const { agentManager, agentStorage } = createTestDeps();
const terminalManager = createTerminalManager();
const terminal = await terminalManager.createTerminal({
cwd: process.cwd(),
workspaceId: "ws-terminal-keys",
command: process.execPath,
args: [
"-e",
`process.stdin.setRawMode(true);
process.stdout.write("\\x1b[?1hREADY\\r\\n");
process.stdin.on("data", data => {
process.stdout.write("HEX:" + data.toString("hex") + "\\r\\n");
});`,
],
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createClaudeOnlyManager(),
terminalManager,
logger,
});
try {
await vi.waitFor(
async () => {
const capture = await terminalManager.captureTerminal(terminal.id);
expect(capture.lines).toContain("READY");
},
{ timeout: 5000 },
);
const tool = registeredTool(server, "send_terminal_keys");
for (const [keys, hex] of [
["Down", "1b4f42"],
["PageDown", "1b5b367e"],
["S-Tab", "1b5b5a"],
["C-r", "12"],
["M-b", "1b62"],
]) {
await tool.handler({ terminalId: terminal.id, keys });
await vi.waitFor(async () => {
const capture = await terminalManager.captureTerminal(terminal.id);
expect(capture.lines).toContain(`HEX:${hex}`);
});
}
} finally {
await server.close();
await terminalManager.killTerminalAndWait(terminal.id);
}
});
it("captures terminal output through the terminal manager authority", async () => {
const { agentManager, agentStorage } = createTestDeps();
const captureTerminal = vi.fn().mockResolvedValue({
@@ -39,6 +39,8 @@ import type { FirstAgentContext } from "../../messages.js";
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
import { computeNextRunAt, validateScheduleCadence } from "../../schedule/cron.js";
import {
@@ -594,37 +596,44 @@ const TerminalSummarySchema = z.object({
cwd: z.string(),
});
function resolveTerminalKeyToken(key: string, literal: boolean): string {
interface TerminalKeyTokenInput {
key: string;
literal: boolean;
preamble: string;
}
function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInput): string {
if (literal) {
return key;
}
switch (key) {
case "Enter":
return "\r";
case "Tab":
return "\t";
case "Escape":
return "\u001b";
case "Space":
return " ";
case "BSpace":
return "\u007f";
case "C-c":
return "\u0003";
case "C-d":
return "\u0004";
case "C-z":
return "\u001a";
case "C-l":
return "\u000c";
case "C-a":
return "\u0001";
case "C-e":
return "\u0005";
default:
return key;
const match = /^((?:[CMS]-)*)(.+)$/.exec(key);
if (!match) {
return key;
}
const [, modifiers, token] = match;
const aliases: Record<string, string> = {
Up: "ArrowUp",
Down: "ArrowDown",
Left: "ArrowLeft",
Right: "ArrowRight",
BSpace: "Backspace",
Space: " ",
};
const inputMode = new TerminalInputModeTracker();
inputMode.feed(preamble);
const encodedKey = Object.hasOwn(aliases, token) ? aliases[token] : token;
return (
encodeTerminalKeyInput(
{
key: encodedKey,
ctrl: modifiers.includes("C-"),
alt: modifiers.includes("M-"),
shift: modifiers.includes("S-"),
},
{ inputMode: inputMode.getState() },
) || key
);
}
export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog {
@@ -2591,7 +2600,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
"send_terminal_keys",
{
title: "Send terminal keys",
description: "Send literal text or special key tokens to a terminal session.",
description:
"Send text or a key token: Enter, Tab, Escape, Space, BSpace, Up, Down, Left, Right, Home, End, Insert, Delete, PageUp, PageDown, or F1-F12. Combine C- (Ctrl), M- (Alt), and S- (Shift) prefixes, e.g. C-r, S-Tab, or C-M-Left. Set literal=true to send the text unchanged.",
inputSchema: {
terminalId: z.string(),
keys: z.string(),
@@ -2613,7 +2623,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
terminal.send({
type: "input",
data: resolveTerminalKeyToken(keys, literal),
data: resolveTerminalKeyToken({
key: keys,
literal,
preamble: terminal.getReplayPreamble(),
}),
});
return {