mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
fix(server): 正确编码 MCP 终端按键
send_terminal_keys 会将方向键、导航键和多数修饰键组合当作普通文字输入。 复用协议层编码器,并从终端前导序列恢复输入模式,保留 literal 和未知文本的原样发送行为。 补充 MCP 入口回归与真实 PTY 字节验证;MCP 文件 197 项测试通过, 最终终端回归 44 项通过,服务端类型、Lint 和格式检查通过。 Fixes #7337
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user