Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
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
2 changes: 1 addition & 1 deletion src/main/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export const LIFECYCLE_EVENTS = {
// Workspace events
export const WORKSPACE_EVENTS = {
PLAN_UPDATED: 'workspace:plan-updated', // Plan entries updated
TERMINAL_OUTPUT: 'workspace:terminal-output', // Terminal output snippet
TERMINAL_OUTPUT: 'workspace:terminal-output', // Terminal snippet update
FILES_CHANGED: 'workspace:files-changed' // File tree changed
}

Expand Down
6 changes: 6 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,15 @@ app.whenReady().then(async () => {
}
})

app.on('before-quit', () => {
if (!presenter) return
presenter.threadPresenter.clearCommandPermissionCache()
})

// Handle window-all-closed event
app.on('window-all-closed', () => {
if (!presenter) return
presenter.threadPresenter.clearCommandPermissionCache()

// Check if there are any non-floating-button windows
const mainWindows = presenter.windowPresenter.getAllWindows()
Expand Down
8 changes: 6 additions & 2 deletions src/main/presenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { CONFIG_EVENTS, WINDOW_EVENTS } from '@/events'
import { KnowledgePresenter } from './knowledgePresenter'
import { WorkspacePresenter } from './workspacePresenter'
import { ToolPresenter } from './toolPresenter'
import { CommandPermissionHandler } from './threadPresenter/handlers/commandPermissionHandler'

// IPC调用上下文接口
interface IPCCallContext {
Expand Down Expand Up @@ -102,11 +103,13 @@ export class Presenter implements IPresenter {
this.windowPresenter = new WindowPresenter(this.configPresenter)
this.tabPresenter = new TabPresenter(this.windowPresenter)
this.llmproviderPresenter = new LLMProviderPresenter(this.configPresenter, this.sqlitePresenter)
const commandPermissionHandler = new CommandPermissionHandler()
this.devicePresenter = new DevicePresenter()
this.threadPresenter = new ThreadPresenter(
this.sqlitePresenter,
this.llmproviderPresenter,
this.configPresenter
this.configPresenter,
commandPermissionHandler
)
this.mcpPresenter = new McpPresenter(this.configPresenter)
this.upgradePresenter = new UpgradePresenter(this.configPresenter)
Expand Down Expand Up @@ -136,7 +139,8 @@ export class Presenter implements IPresenter {
this.toolPresenter = new ToolPresenter({
mcpPresenter: this.mcpPresenter,
yoBrowserPresenter: this.yoBrowserPresenter,
configPresenter: this.configPresenter
configPresenter: this.configPresenter,
commandPermissionHandler
})

// this.llamaCppPresenter = new LlamaCppPresenter() // 保留原始注释
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import fs from 'fs/promises'
import path from 'path'
import os from 'os'
import { randomUUID } from 'crypto'
import { z } from 'zod'
import { minimatch } from 'minimatch'
import { createTwoFilesPatch } from 'diff'
import logger from '@shared/logger'
import { presenter } from '@/presenter'
import { validateGlobPattern, validateRegexPattern } from '@shared/regexValidator'
import { spawn } from 'child_process'
import { spawn, type ChildProcess } from 'child_process'
import { RuntimeHelper } from '../../../lib/runtimeHelper'
import {
CommandPermissionHandler,
CommandPermissionRequiredError
} from '../../threadPresenter/handlers/commandPermissionHandler'
import { getShellEnvironment } from './shellEnvHelper'
import { glob } from 'glob'
import { registerCommandProcess } from './commandProcessTracker'

const ReadFileArgsSchema = z.object({
paths: z.array(z.string()).min(1).describe('Array of file paths to read')
Expand Down Expand Up @@ -95,6 +103,17 @@ const GetFileInfoArgsSchema = z.object({
path: z.string()
})

const ExecuteCommandArgsSchema = z.object({
command: z.string().min(1),
timeout: z.number().min(100).optional(),
workdir: z.string().optional(),
description: z.string().min(5).max(100)
})

const COMMAND_MAX_OUTPUT_LENGTH = 30000
const COMMAND_DEFAULT_TIMEOUT_MS = 120000
const COMMAND_KILL_GRACE_MS = 5000

interface GrepMatch {
file: string
line: number
Expand Down Expand Up @@ -131,14 +150,16 @@ interface GlobMatch {

export class AgentFileSystemHandler {
private allowedDirectories: string[]
private readonly commandPermissionHandler?: CommandPermissionHandler

constructor(allowedDirectories: string[]) {
constructor(allowedDirectories: string[], commandPermissionHandler?: CommandPermissionHandler) {
if (allowedDirectories.length === 0) {
throw new Error('At least one allowed directory must be provided')
}
this.allowedDirectories = allowedDirectories.map((dir) =>
this.normalizePath(path.resolve(this.expandHome(dir)))
)
this.commandPermissionHandler = commandPermissionHandler
}

private normalizePath(p: string): string {
Expand Down Expand Up @@ -561,6 +582,143 @@ export class AgentFileSystemHandler {
return result
}

private getUserShell(): { shell: string; args: string[] } {
if (process.platform === 'win32') {
if (process.env.PSModulePath) {
return { shell: 'powershell.exe', args: ['-NoProfile', '-Command'] }
}
return { shell: 'cmd.exe', args: ['/c'] }
}
return { shell: process.env.SHELL || '/bin/bash', args: ['-c'] }
}

private async runShellProcess(
command: string,
cwd: string,
timeout: number,
options: {
onSpawn?: (child: ChildProcess, markAborted: () => void) => void
} = {}
): Promise<{
output: string
exitCode: number | null
timedOut: boolean
aborted: boolean
truncated: boolean
}> {
const { shell, args } = this.getUserShell()
const shellEnv = await getShellEnvironment()

return new Promise((resolve, reject) => {
const child = spawn(shell, [...args, command], {
cwd,
env: {
...process.env,
...shellEnv
},
stdio: ['ignore', 'pipe', 'pipe']
})

let output = ''
let truncated = false
let timedOut = false
let aborted = false
let exitCode: number | null = null
let timeoutId: NodeJS.Timeout | null = null
let killTimeoutId: NodeJS.Timeout | null = null
const markAborted = () => {
aborted = true
}

options.onSpawn?.(child, markAborted)

const appendOutput = (chunk: string) => {
if (truncated) return
const remaining = COMMAND_MAX_OUTPUT_LENGTH - output.length
if (remaining <= 0) {
truncated = true
return
}
if (chunk.length <= remaining) {
output += chunk
} else {
output += chunk.slice(0, remaining)
truncated = true
}
}

child.stdout?.setEncoding('utf-8')
child.stderr?.setEncoding('utf-8')

child.stdout?.on('data', (data: string) => {
appendOutput(data)
})

child.stderr?.on('data', (data: string) => {
appendOutput(data)
})

timeoutId = setTimeout(() => {
timedOut = true
try {
child.kill()
} catch {
// ignore kill errors
}
killTimeoutId = setTimeout(() => {
try {
child.kill('SIGKILL')
} catch {
// ignore kill errors
}
}, COMMAND_KILL_GRACE_MS)
}, timeout)
Comment thread
zerob13 marked this conversation as resolved.
Outdated

child.on('error', (error) => {
if (timeoutId) clearTimeout(timeoutId)
if (killTimeoutId) clearTimeout(killTimeoutId)
reject(error)
})

child.on('exit', (code, signal) => {
if (timeoutId) clearTimeout(timeoutId)
if (killTimeoutId) clearTimeout(killTimeoutId)
if (signal && timedOut) {
exitCode = null
} else {
exitCode = code ?? null
}
resolve({
output,
exitCode,
timedOut,
aborted,
truncated
})
})
})
}

private async emitTerminalSnippet(
conversationId: string | undefined,
snippet: {
id: string
status: 'running' | 'completed' | 'failed' | 'timed_out' | 'aborted'
command: string
cwd?: string
output: string
truncated: boolean
exitCode?: number | null
startedAt?: number
endedAt?: number
durationMs?: number
timestamp: number
}
): Promise<void> {
if (!conversationId || !presenter?.workspacePresenter) return
await presenter.workspacePresenter.emitTerminalSnippet(conversationId, snippet)
}

private async replaceTextInFile(
filePath: string,
pattern: string,
Expand Down Expand Up @@ -956,4 +1114,115 @@ export class AgentFileSystemHandler {
)
}
}

async executeCommand(
args: unknown,
options: { conversationId?: string; snippetId?: string } = {}
): Promise<string> {
const parsed = ExecuteCommandArgsSchema.safeParse(args)
if (!parsed.success) {
throw new Error(`Invalid arguments: ${parsed.error}`)
}

const { command, timeout, workdir } = parsed.data
if (this.commandPermissionHandler) {
const permissionCheck = this.commandPermissionHandler.checkPermission(
options.conversationId,
command
)
if (!permissionCheck.allowed) {
const commandInfo = this.commandPermissionHandler.buildCommandInfo(command)
const responseContent =
'components.messageBlockPermissionRequest.description.commandWithRisk'
throw new CommandPermissionRequiredError(responseContent, {
toolName: 'execute_command',
serverName: 'agent-filesystem',
permissionType: 'command',
description: 'Execute command requires approval.',
command,
commandSignature: commandInfo.signature,
commandInfo,
conversationId: options.conversationId
})
}
}
const cwd = workdir ? await this.validatePath(workdir) : this.allowedDirectories[0]
const startedAt = Date.now()
const snippetId = options.snippetId ?? randomUUID()

await this.emitTerminalSnippet(options.conversationId, {
id: snippetId,
status: 'running',
command,
cwd,
output: '',
truncated: false,
exitCode: null,
startedAt,
timestamp: startedAt
})

let result
const conversationId = options.conversationId
try {
result = await this.runShellProcess(command, cwd, timeout ?? COMMAND_DEFAULT_TIMEOUT_MS, {
onSpawn: (child, markAborted) => {
if (!conversationId) return
registerCommandProcess(conversationId, snippetId, child, markAborted)
}
})
} catch (error) {
const endedAt = Date.now()
await this.emitTerminalSnippet(options.conversationId, {
id: snippetId,
status: 'failed',
command,
cwd,
output: error instanceof Error ? error.message : String(error),
truncated: false,
exitCode: null,
startedAt,
endedAt,
durationMs: endedAt - startedAt,
timestamp: endedAt
})
throw error
}
Comment thread
zerob13 marked this conversation as resolved.
Outdated

const endedAt = Date.now()
const status = result.timedOut
? 'timed_out'
: result.aborted
? 'aborted'
: result.exitCode === 0
? 'completed'
: 'failed'

await this.emitTerminalSnippet(options.conversationId, {
id: snippetId,
status,
command,
cwd,
output: result.output,
truncated: result.truncated,
exitCode: result.exitCode,
startedAt,
endedAt,
durationMs: endedAt - startedAt,
timestamp: endedAt
})

const responseLines: string[] = []
if (result.output) {
responseLines.push(result.output.trimEnd())
}
responseLines.push(`Exit Code: ${result.exitCode ?? 'null'}`)
if (result.timedOut) {
responseLines.push('Timed out')
}
if (result.truncated) {
responseLines.push('Output truncated')
}
return responseLines.join('\n')
}
}
Loading