feat(ai): improve agent workflows and persistence for v1.2.14
This commit is contained in:
@@ -5811,6 +5811,211 @@ async function readRemoteWorkspaceModuleLogs(
|
||||
}
|
||||
}
|
||||
|
||||
type AgentCommandResult = {
|
||||
ok: boolean
|
||||
output: string
|
||||
exitCode: number | null
|
||||
message?: string
|
||||
}
|
||||
|
||||
const allowedAgentExecutables = new Set([
|
||||
'bash', 'cat', 'cmake', 'colcon', 'find', 'git', 'grep', 'head', 'ls', 'make', 'ninja', 'node', 'npm', 'npx',
|
||||
'pnpm', 'printenv', 'pwd', 'python', 'python3', 'pytest', 'rg', 'ros2', 'sed', 'sh', 'tail', 'which', 'yarn'
|
||||
])
|
||||
|
||||
function validateAgentCommand(input: string): string {
|
||||
const command = input.trim()
|
||||
if (!command || command.length > 2000) throw new Error('명령 길이가 허용 범위를 벗어났습니다.')
|
||||
if (/[\0\r\n`;{}<>]|\$\(|\.\.\//.test(command)) throw new Error('명령 치환, 경로 이탈 또는 리다이렉션은 허용되지 않습니다.')
|
||||
if (/\b(?:sudo|su|shutdown|reboot|poweroff|halt|mkfs|fdisk|parted|mount|umount|passwd|useradd|userdel|chown)\b/i.test(command)) {
|
||||
throw new Error('시스템 권한 또는 설정을 변경하는 명령은 허용되지 않습니다.')
|
||||
}
|
||||
if (/\brm\b|\b(?:curl|wget)\b[^|]*\|/i.test(command)) throw new Error('삭제 또는 원격 스크립트 실행 명령은 허용되지 않습니다.')
|
||||
if (/(^|\s)(?:~\/(?:\.ssh|\.aws|\.config)|\/[^\s]*\/\.(?:ssh|aws)\/|\/etc\/|\/boot\/|\/proc\/|\/sys\/|\/dev\/|\.env(?:\s|$))/i.test(command)) {
|
||||
throw new Error('시스템 또는 자격증명 경로에는 접근할 수 없습니다.')
|
||||
}
|
||||
|
||||
const withoutAnd = command.replace(/&&|\|\|/g, '')
|
||||
if (/[&]/.test(withoutAnd)) throw new Error('백그라운드 명령은 허용되지 않습니다.')
|
||||
const segments = command.split(/&&|\|\||\|/).map((segment) => segment.trim()).filter(Boolean)
|
||||
for (const segment of segments) {
|
||||
const tokens = segment.split(/\s+/).filter(Boolean)
|
||||
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift()
|
||||
const executable = tokens[0]
|
||||
if (!executable) throw new Error('실행할 명령을 확인할 수 없습니다.')
|
||||
if (executable === 'source' || executable === '.') {
|
||||
const sourcePath = tokens[1] ?? ''
|
||||
if (!/^(?:\/opt\/ros\/[A-Za-z0-9._-]+\/setup\.bash|\.?\/?(?:install|build)\/setup\.bash)$/.test(sourcePath)) {
|
||||
throw new Error('허용되지 않은 환경 설정 파일입니다.')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (executable.startsWith('./')) {
|
||||
if (!/^\.\/[A-Za-z0-9_./-]+\.sh$/.test(executable)) throw new Error('모듈 내부의 셸 스크립트만 실행할 수 있습니다.')
|
||||
continue
|
||||
}
|
||||
if (!allowedAgentExecutables.has(executable)) throw new Error(`허용되지 않은 실행 파일입니다: ${executable}`)
|
||||
if (executable === 'bash' || executable === 'sh') {
|
||||
const scriptPath = tokens[1] ?? ''
|
||||
if (!/^(?:\.\/)?[A-Za-z0-9_./-]+\.sh$/.test(scriptPath)) {
|
||||
throw new Error('bash와 sh는 모듈 내부의 셸 스크립트만 실행할 수 있습니다.')
|
||||
}
|
||||
}
|
||||
if ((executable === 'python' || executable === 'python3') && tokens.some((token) => token === '-c')) {
|
||||
throw new Error('인라인 Python 코드는 실행할 수 없습니다.')
|
||||
}
|
||||
if (executable === 'node' && tokens.some((token) => token === '-e' || token === '--eval')) {
|
||||
throw new Error('인라인 Node.js 코드는 실행할 수 없습니다.')
|
||||
}
|
||||
if (executable === 'find' && tokens.some((token) => token === '-exec' || token === '-execdir' || token === '-delete')) {
|
||||
throw new Error('find의 실행 및 삭제 옵션은 허용되지 않습니다.')
|
||||
}
|
||||
if (executable === 'sed' && tokens.some((token) => token === '-i' || token.startsWith('-i'))) {
|
||||
throw new Error('명령을 통한 파일 직접 수정은 허용되지 않습니다.')
|
||||
}
|
||||
if (executable === 'git' && !/^(?:status|diff|log|show|rev-parse|branch)$/.test(tokens[1] ?? '')) {
|
||||
throw new Error('Agent는 Git 조회 명령만 실행할 수 있습니다.')
|
||||
}
|
||||
if (executable === 'ros2' && !/^(?:topic|node|interface|doctor|pkg|run|launch)$/.test(tokens[1] ?? '')) {
|
||||
throw new Error('허용되지 않은 ROS2 명령입니다.')
|
||||
}
|
||||
if (executable === 'ros2' && /\b(?:pub|call|send_goal)\b/.test(segment)) {
|
||||
throw new Error('ROS2 발행 및 제어 명령은 Agent에서 실행할 수 없습니다.')
|
||||
}
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function appendAgentCommandOutput(current: string, chunk: string): string {
|
||||
const next = current + chunk
|
||||
return next.length > 80_000 ? next.slice(-80_000) : next
|
||||
}
|
||||
|
||||
function isAgentRos2Command(command: string): boolean {
|
||||
return /(?:^|&&|\|\|?|;)\s*ros2\s+/.test(command)
|
||||
}
|
||||
|
||||
function isAgentRos2RuntimeCommand(command: string): boolean {
|
||||
return /(?:^|&&|\|\|?|;)\s*ros2\s+(?:run|launch)\b/.test(command)
|
||||
}
|
||||
|
||||
function agentContainerCommandArgs(container: { id: string; cwd: string }, command: string, observeRuntime: boolean): string[] {
|
||||
const init = [
|
||||
'if [ -n "$ROS_DISTRO" ] && [ -f "/opt/ros/$ROS_DISTRO/setup.bash" ]; then source "/opt/ros/$ROS_DISTRO/setup.bash"; fi',
|
||||
'if [ -f ./install/setup.bash ]; then source ./install/setup.bash; fi',
|
||||
command
|
||||
].join('; ')
|
||||
return [
|
||||
'exec', '-w', container.cwd, container.id,
|
||||
...(observeRuntime ? ['timeout', '--signal=INT', '--kill-after=5s', '20s'] : []),
|
||||
'bash', '--noprofile', '--norc', '-lc', init
|
||||
]
|
||||
}
|
||||
|
||||
async function resolveLocalAgentContainer(moduleRoot: string, folderName?: string | null): Promise<{ id: string; cwd: string }> {
|
||||
const info = parseModuleDockerInfo(folderName?.trim() || basename(moduleRoot))
|
||||
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('모듈 컨테이너 정보를 확인할 수 없습니다.')
|
||||
const inspect = await runCommandOutput('docker', ['inspect', info.containerKey], undefined, undefined, 10_000)
|
||||
return resolveContainerTerminal(inspect, moduleRoot)
|
||||
}
|
||||
|
||||
async function resolveRemoteAgentContainer(
|
||||
target: ReturnType<typeof normalizeRemoteTarget>,
|
||||
moduleRoot: string,
|
||||
folderName?: string | null
|
||||
): Promise<{ id: string; cwd: string }> {
|
||||
const info = parseModuleDockerInfo(folderName?.trim() || basename(moduleRoot))
|
||||
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('모듈 컨테이너 정보를 확인할 수 없습니다.')
|
||||
const inspect = await runSshOutput(target, `docker inspect ${quoteShellSingle(info.containerKey)}`, 10_000)
|
||||
return resolveContainerTerminal(inspect, moduleRoot)
|
||||
}
|
||||
|
||||
async function runLocalAgentCommand(moduleName: string, commandInput: string, workspaceRoot?: string | null, folderName?: string | null): Promise<AgentCommandResult> {
|
||||
const command = validateAgentCommand(commandInput)
|
||||
const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName)
|
||||
if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||
|
||||
const observeRuntime = isAgentRos2RuntimeCommand(command)
|
||||
const container = isAgentRos2Command(command) ? await resolveLocalAgentContainer(moduleRoot, folderName) : null
|
||||
const executable = container ? 'docker' : '/bin/bash'
|
||||
const args = container
|
||||
? agentContainerCommandArgs(container, command, observeRuntime)
|
||||
: ['--noprofile', '--norc', '-lc', command]
|
||||
|
||||
return new Promise<AgentCommandResult>((resolvePromise) => {
|
||||
let output = ''
|
||||
let timedOut = false
|
||||
const child = spawn(executable, args, {
|
||||
cwd: container ? undefined : moduleRoot,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
||||
})
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (process.platform !== 'win32' && child.pid) {
|
||||
try { process.kill(-child.pid, 'SIGTERM') } catch { /* already exited */ }
|
||||
} else child.kill('SIGTERM')
|
||||
}, observeRuntime ? 30_000 : 120_000)
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (data: string) => { output = appendAgentCommandOutput(output, data) })
|
||||
child.stderr.on('data', (data: string) => { output = appendAgentCommandOutput(output, data) })
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
resolvePromise({ ok: false, output, exitCode: null, message: error.message })
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeout)
|
||||
const exitCode = typeof code === 'number' ? code : null
|
||||
const observationCompleted = observeRuntime && exitCode === 124
|
||||
if (observationCompleted) {
|
||||
output = appendAgentCommandOutput(output, '\n[20초 동안 실행을 확인한 뒤 프로세스를 종료했습니다.]\n')
|
||||
}
|
||||
resolvePromise({
|
||||
ok: !timedOut && (exitCode === 0 || observationCompleted),
|
||||
output,
|
||||
exitCode: observationCompleted ? 0 : exitCode,
|
||||
message: timedOut ? `명령 실행 시간이 ${observeRuntime ? 30 : 120}초를 초과했습니다.` : undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleName: string, commandInput: string, folderName?: string | null): Promise<AgentCommandResult> {
|
||||
const command = validateAgentCommand(commandInput)
|
||||
const resolved = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName)
|
||||
const marker = '__AUTONOMY_AGENT_EXIT_CODE__:'
|
||||
try {
|
||||
const observeRuntime = isAgentRos2RuntimeCommand(command)
|
||||
const container = isAgentRos2Command(command)
|
||||
? await resolveRemoteAgentContainer(resolved.target, resolved.moduleRoot, folderName)
|
||||
: null
|
||||
const invocation = container
|
||||
? ['docker', ...agentContainerCommandArgs(container, command, observeRuntime)].map(quoteShellSingle).join(' ')
|
||||
: `bash --noprofile --norc -lc ${quoteShellSingle(command)}`
|
||||
const rawOutput = await runSshOutput(resolved.target, [
|
||||
`cd ${quoteShellSingle(resolved.moduleRoot)}`,
|
||||
`${invocation} 2>&1`,
|
||||
'CODE=$?',
|
||||
`printf '\\n${marker}%s\\n' "$CODE"`,
|
||||
].join('; '), observeRuntime ? 35_000 : 130_000)
|
||||
const markerIndex = rawOutput.lastIndexOf(marker)
|
||||
const exitCode = markerIndex >= 0 ? Number.parseInt(rawOutput.slice(markerIndex + marker.length).trim(), 10) : null
|
||||
const observationCompleted = observeRuntime && exitCode === 124
|
||||
let output = appendAgentCommandOutput('', markerIndex >= 0 ? rawOutput.slice(0, markerIndex).trimEnd() : rawOutput)
|
||||
if (observationCompleted) output = appendAgentCommandOutput(output, '\n[20초 동안 실행을 확인한 뒤 프로세스를 종료했습니다.]')
|
||||
return {
|
||||
ok: exitCode === 0 || observationCompleted,
|
||||
output,
|
||||
exitCode: observationCompleted ? 0 : exitCode,
|
||||
message: markerIndex < 0 ? '원격 명령의 종료 코드를 확인하지 못했습니다.' : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, output: '', exitCode: null, message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
|
||||
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
|
||||
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
|
||||
@@ -6475,6 +6680,7 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers())
|
||||
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
|
||||
ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:agent-command:run', (_event, moduleName: string, command: string, workspaceRoot?: string | null, folderName?: string | null) => runLocalAgentCommand(moduleName, command, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
|
||||
ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose))
|
||||
ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
|
||||
@@ -6539,6 +6745,7 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('remote-workspace:ros:reset-session', (_event, target: RemoteWorkspaceTarget) => resetRemoteRosSession(target))
|
||||
ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
|
||||
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
|
||||
ipcMain.handle('remote-workspace:agent-command:run', (_event, target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string | null) => runRemoteAgentCommand(target, moduleName, command, folderName))
|
||||
ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target))
|
||||
ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork)))
|
||||
ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target))
|
||||
|
||||
Reference in New Issue
Block a user