feat(ai): improve agent workflows and persistence for v1.2.14

This commit is contained in:
2026-10-15 14:39:01 +09:00
parent 53518d1114
commit 1c0d861051
19 changed files with 1384 additions and 145 deletions
+207
View File
@@ -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))