Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add terminal command execution tool with user approval #4398

Merged
merged 6 commits into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions packages/ai-native/src/browser/mcp/tools/components/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { memo, useCallback, useMemo, useState } from 'react';

import { useInjectable } from '@opensumi/ide-core-browser';
import { Button, Icon } from '@opensumi/ide-core-browser/lib/components';

import { IMCPServerToolComponentProps } from '../../../types';
import { RunCommandHandler } from '../handlers/RunCommand';

import styles from './index.module.less';

function getResult(raw: string) {
const result: {
isError?: boolean;
text?: string;
} = {};

try {
const data = JSON.parse(raw);
if (data.isError) {
result.isError = data.isError;
}

if (data.content) {
result.text = data.content;
}

return result;
} catch {
return null;
}
}

export const TerminalToolComponent = memo((props: IMCPServerToolComponentProps) => {
const { args, toolCallId } = props;
const handler = useInjectable<RunCommandHandler>(RunCommandHandler);
const [disabled, toggleDisabled] = useState(false);

const handleClick = useCallback((approval: boolean) => {
handler.handleApproval(toolCallId!, approval);
toggleDisabled(true);
}, []);

const output = useMemo(() => {
if (props.result) {
return getResult(props.result);
}
return null;
}, [props]);

return (
<div className={styles.run_cmd_tool}>
{props.state === 'result' && (
<div>
<div className={styles.command_title}>
<Icon icon='terminal' />
<span>输出</span>
</div>
{output ? <div className={styles.command_content}>{output.text}</div> : ''}
</div>
)}

{props.state === 'complete' && args?.require_user_approval && (
<div>
<div className={styles.command_title}>
<Icon icon='terminal' />
<span>是否允许运行命令?</span>
</div>
<p className={styles.command_content}>{args.command}</p>
<p className={styles.comand_description}>{args.explanation}</p>
<div className={styles.cmmand_footer}>
<Button type='link' size='small' disabled={disabled} onClick={() => handleClick(true)}>
允许
</Button>
<Button type='link' size='small' disabled={disabled} onClick={() => handleClick(false)}>
不允许
</Button>
</div>
</div>
)}
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,33 @@
flex-basis: 0px;
flex-grow: 1;
}

.run_cmd_tool {
.command_title {
display: flex;
align-items: center;
span {
margin-left: 5px;
}
}

.command_content {
padding: 4px;
font-size: 12px;
color: var(--design-text-foreground);
margin: 0px;
background-color: var(--terminal-background);
margin: 10px 0px;
border-radius: 4px;
}

.comand_description {
font-size: 11px;
color: var(--descriptionForeground);
}

.cmmand_footer {
display: flex;
justify-content: flex-end;
}
}
107 changes: 107 additions & 0 deletions packages/ai-native/src/browser/mcp/tools/handlers/RunCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import z from 'zod';

import { Autowired, Injectable } from '@opensumi/di';
import { AppConfig } from '@opensumi/ide-core-browser';
import { ITerminalController, ITerminalGroupViewService } from '@opensumi/ide-terminal-next';
import { Deferred } from '@opensumi/ide-utils/lib/promises';

import { MCPLogger } from '../../../types';

const color = {
italic: '\x1b[3m',
reset: '\x1b[0m',
};

export const inputSchema = z.object({
command: z.string().describe('The terminal command to execute'),
is_background: z.boolean().describe('Whether the command should be run in the background'),
explanation: z
.string()
.describe('One sentence explanation as to why this command needs to be run and how it contributes to the goal.'),
require_user_approval: z
.boolean()
.describe(
"Whether the user must approve the command before it is executed. Only set this to false if the command is safe and if it matches the user's requirements for commands that should be executed automatically.",
),
});

@Injectable()
export class RunCommandHandler {
@Autowired(ITerminalController)
protected readonly terminalController: ITerminalController;

@Autowired(AppConfig)
protected readonly appConfig: AppConfig;

@Autowired(ITerminalGroupViewService)
protected readonly terminalView: ITerminalGroupViewService;

private approvalDeferredMap = new Map<string, Deferred<boolean>>();

private terminalId = 0;

getShellLaunchConfig(command: string) {
return {
name: `MCP:Terminal_${this.terminalId++}`,
cwd: this.appConfig.workspaceDir,
args: ['-c', command],
};
}

async handler(args: z.infer<typeof inputSchema> & { toolCallId: string }, logger: MCPLogger) {
if (args.require_user_approval) {
const def = new Deferred<boolean>();
this.approvalDeferredMap.set(args.toolCallId, def);
const approval = await def.promise;
if (!approval) {
return {
isError: false,
content: 'User rejection',
};
}
}
const terminalClient = await this.terminalController.createTerminalWithWidget({
config: this.getShellLaunchConfig(args.command),
closeWhenExited: false,
});

this.terminalController.showTerminalPanel();

const result: string[] = [];
const def = new Deferred<{ isError?: boolean; content: string[] }>();

terminalClient.onOutput((e) => {
result.push(e.data.toString());
});

terminalClient.onExit((e) => {
const isError = e.code !== 0;
def.resolve({
isError,
content: result,
});

terminalClient.term.writeln(
`\n${color.italic}> Command ${args.command} executed successfully. Terminal will close in ${
3000 / 1000
} seconds.${color.reset}\n`,
);

setTimeout(() => {
terminalClient.dispose();
this.terminalView.removeWidget(terminalClient.id);
}, 3000);
});

return def.promise;
}

handleApproval(callId: string, approval: boolean) {
if (!this.approvalDeferredMap.has(callId)) {
return;
}

const def = this.approvalDeferredMap.get(callId);
def?.resolve(approval);
}
}
75 changes: 7 additions & 68 deletions packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,8 @@

import { IMCPServerRegistry, MCPLogger, MCPServerContribution, MCPToolDefinition } from '../../types';

const color = {
italic: '\x1b[3m',
reset: '\x1b[0m',
};

const inputSchema = z.object({
command: z.string().describe('The terminal command to execute'),
is_background: z.boolean().describe('Whether the command should be run in the background'),
explanation: z
.string()
.describe('One sentence explanation as to why this command needs to be run and how it contributes to the goal.'),
require_user_approval: z
.boolean()
.describe(
"Whether the user must approve the command before it is executed. Only set this to false if the command is safe and if it matches the user's requirements for commands that should be executed automatically.",
),
});
import { TerminalToolComponent } from './components/Terminal';
import { RunCommandHandler, inputSchema } from './handlers/RunCommand';

@Domain(MCPServerContribution)
export class RunTerminalCommandTool implements MCPServerContribution {
Expand All @@ -36,10 +21,12 @@
@Autowired(ITerminalGroupViewService)
protected readonly terminalView: ITerminalGroupViewService;

private terminalId = 0;
@Autowired(RunCommandHandler)
private readonly runCommandHandler: RunCommandHandler;

registerMCPServer(registry: IMCPServerRegistry): void {
registry.registerMCPTool(this.getToolDefinition());
registry.registerToolComponent('run_terminal_cmd', TerminalToolComponent);
}

getToolDefinition(): MCPToolDefinition {
Expand All @@ -48,59 +35,11 @@
description:
"PROPOSE a command to run on behalf of the user.\nIf you have this tool, note that you DO have the ability to run commands directly on the USER's system.\n\nAdhere to these rules:\n1. Based on the contents of the conversation, you will be told if you are in the same shell as a previous step or a new shell.\n2. If in a new shell, you should `cd` to the right directory and do necessary setup in addition to running the command.\n3. If in the same shell, the state will persist, no need to do things like `cd` to the same directory.\n4. For ANY commands that would use a pager, you should append ` | cat` to the command (or whatever is appropriate). You MUST do this for: git, less, head, tail, more, etc.\n5. For commands that are long running/expected to run indefinitely until interruption, please run them in the background. To run jobs in the background, set `is_background` to true rather than changing the details of the command.\n6. Dont include any newlines in the command.",
inputSchema,
handler: this.handler.bind(this),

Check failure on line 38 in packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts

View workflow job for this annotation

GitHub Actions / build-windows

Type '(args: { command: string; explanation: string; is_background: boolean; require_user_approval: boolean; } & { toolCallId: string; }, logger: MCPLogger) => Promise<{ isError?: boolean | undefined; content: string[]; } | { ...; }>' is not assignable to type '(args: any, logger: MCPLogger) => Promise<{ content: { type: string; text: string; }[]; isError?: boolean | undefined; }>'.

Check failure on line 38 in packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts

View workflow job for this annotation

GitHub Actions / unittest (macos-latest, 18.x, node)

Type '(args: { command: string; explanation: string; is_background: boolean; require_user_approval: boolean; } & { toolCallId: string; }, logger: MCPLogger) => Promise<{ isError?: boolean | undefined; content: string[]; } | { ...; }>' is not assignable to type '(args: any, logger: MCPLogger) => Promise<{ content: { type: string; text: string; }[]; isError?: boolean | undefined; }>'.

Check failure on line 38 in packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts

View workflow job for this annotation

GitHub Actions / ubuntu-latest, Node.js 20.x

Type '(args: { command: string; explanation: string; is_background: boolean; require_user_approval: boolean; } & { toolCallId: string; }, logger: MCPLogger) => Promise<{ isError?: boolean | undefined; content: string[]; } | { ...; }>' is not assignable to type '(args: any, logger: MCPLogger) => Promise<{ content: { type: string; text: string; }[]; isError?: boolean | undefined; }>'.

Check failure on line 38 in packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts

View workflow job for this annotation

GitHub Actions / build (macos-latest, 20.x)

Type '(args: { command: string; explanation: string; is_background: boolean; require_user_approval: boolean; } & { toolCallId: string; }, logger: MCPLogger) => Promise<{ isError?: boolean | undefined; content: string[]; } | { ...; }>' is not assignable to type '(args: any, logger: MCPLogger) => Promise<{ content: { type: string; text: string; }[]; isError?: boolean | undefined; }>'.

Check failure on line 38 in packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.x)

Type '(args: { command: string; explanation: string; is_background: boolean; require_user_approval: boolean; } & { toolCallId: string; }, logger: MCPLogger) => Promise<{ isError?: boolean | undefined; content: string[]; } | { ...; }>' is not assignable to type '(args: any, logger: MCPLogger) => Promise<{ content: { type: string; text: string; }[]; isError?: boolean | undefined; }>'.

Check failure on line 38 in packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts

View workflow job for this annotation

GitHub Actions / unittest (ubuntu-latest, 18.x, node)

Type '(args: { command: string; explanation: string; is_background: boolean; require_user_approval: boolean; } & { toolCallId: string; }, logger: MCPLogger) => Promise<{ isError?: boolean | undefined; content: string[]; } | { ...; }>' is not assignable to type '(args: any, logger: MCPLogger) => Promise<{ content: { type: string; text: string; }[]; isError?: boolean | undefined; }>'.

Check failure on line 38 in packages/ai-native/src/browser/mcp/tools/runTerminalCmd.ts

View workflow job for this annotation

GitHub Actions / unittest (ubuntu-latest, 18.x, jsdom)

Type '(args: { command: string; explanation: string; is_background: boolean; require_user_approval: boolean; } & { toolCallId: string; }, logger: MCPLogger) => Promise<{ isError?: boolean | undefined; content: string[]; } | { ...; }>' is not assignable to type '(args: any, logger: MCPLogger) => Promise<{ content: { type: string; text: string; }[]; isError?: boolean | undefined; }>'.
};
}

getShellLaunchConfig(command: string) {
return {
name: `MCP:Terminal_${this.terminalId++}`,
cwd: this.appConfig.workspaceDir,
args: ['-c', command],
};
}

private async handler(args: z.infer<typeof inputSchema>, logger: MCPLogger) {
if (args.require_user_approval) {
// FIXME: support approval
}

const terminalClient = await this.terminalController.createTerminalWithWidget({
config: this.getShellLaunchConfig(args.command),
closeWhenExited: false,
});

this.terminalController.showTerminalPanel();

const result: { type: string; text: string }[] = [];
const def = new Deferred<{ isError?: boolean; content: { type: string; text: string }[] }>();

terminalClient.onOutput((e) => {
result.push({
type: 'output',
text: e.data.toString(),
});
});

terminalClient.onExit((e) => {
const isError = e.code !== 0;
def.resolve({
isError,
content: result,
});

terminalClient.term.writeln(
`\n${color.italic}> Command ${args.command} executed successfully. Terminal will close in ${
3000 / 1000
} seconds.${color.reset}\n`,
);

setTimeout(() => {
terminalClient.dispose();
this.terminalView.removeWidget(terminalClient.id);
}, 3000);
});

return def.promise;
private async handler(args: z.infer<typeof inputSchema> & { toolCallId: string }, logger: MCPLogger) {
return this.runCommandHandler.handler(args, logger);
}
}
Loading