const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs/promises') const path = require('node:path') const os = require('node:os') const { spawnSync } = require('node:child_process') const ts = require('typescript') require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(require('node:fs').readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, filename) const { validateAgentTool, validateAgentOperation, safeAgentPath } = require('../src/shared/agentTools.ts') const { runAgentFileTool, applyAgentFiles, agentFileHash } = require('../src/main/agentFileTools.ts') const { agentRemoteFilesScript } = require('../src/main/agentRemoteFiles.ts') const { inspectAgentRuntime, agentRosProbePython } = require('../src/main/agentRuntimeTools.ts') const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts') const plain = value => JSON.parse(JSON.stringify(value)) const request = (name, args = {}) => ({ id: 'query-1', name, moduleName: 'NAV2-MKMini', args }) function remote(input) { const result = spawnSync('bash', ['--noprofile', '--norc', '-s'], { input: agentRemoteFilesScript(input), encoding: 'utf8', maxBuffer: 4_000_000, timeout: 10_000 }) if (result.error || result.status !== 0) throw new Error(result.stderr || result.error?.message) return JSON.parse(result.stdout) } async function fixture(t) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-agent-tools-')) t.after(() => fs.rm(root, { recursive: true, force: true })) await fs.mkdir(path.join(root, 'config')) await fs.writeFile(path.join(root, 'config', 'sensor.yaml'), 'host_ip: 192.168.2.88\n조향: ackermann\n') await fs.writeFile(path.join(root, 'CMakeLists.txt'), 'project(vehicle)\n') return root } test('tool requests reject writes, extra shell arguments, unsafe paths and unbounded observations', () => { for (const name of ['shell', 'files.write', 'network.set', 'ros.publish']) assert.throws(() => validateAgentTool(request(name))) for (const name of ['files.list', 'files.read', 'files.search']) { for (const p of ['../secret', '/etc/passwd', '.env', 'config/.env', 'build/secret.py', 'config/id_rsa', 'config/key.pem', 'config\\x.py', 'a//b']) assert.throws(() => validateAgentTool(request(name, { path: p, ...(name === 'files.search' ? { query: 'ip' } : {}) }))) } assert.throws(() => validateAgentTool(request('network.inspect', { command: 'ip addr replace' }))) assert.throws(() => validateAgentTool(request('ros.inspect', { topics: ['/scan; reboot'] }))) assert.throws(() => validateAgentTool(request('files.read', { path: 'a.py', lineCount: 401 }))) assert.throws(() => validateAgentOperation({ name: 'modules.stop' })) assert.throws(() => validateAgentOperation({ name: 'practice.prepare', profile: 'guess' })) assert.equal(validateAgentOperation({ name: 'practice.prepare', profile: 'mkmini-3d' }).name, 'practice.prepare') assert.equal(safeAgentPath('src/launch/vehicle.launch.py'), 'src/launch/vehicle.launch.py') }) for (const backend of ['local', 'remote']) { const read = async (root, tool) => backend === 'local' ? runAgentFileTool(root, validateAgentTool(tool)) : remote({ root, request: validateAgentTool(tool) }) const apply = changes => backend === 'local' ? applyAgentFiles(changes) : Promise.resolve().then(() => remote({ changes })) test(`${backend}: lists and searches sources without hidden, generated or symlinked data`, async t => { const root = await fixture(t) await fs.writeFile(path.join(root, '.env'), 'host_ip: PRIVATE') await fs.mkdir(path.join(root, 'build')) await fs.writeFile(path.join(root, 'build', 'generated.yaml'), 'host_ip: PRIVATE') await fs.symlink('/etc/passwd', path.join(root, 'config', 'outside.txt')) const listing = await read(root, request('files.list')) assert.deepEqual([...listing.data.paths].sort(), ['CMakeLists.txt', 'config/sensor.yaml']) const search = await read(root, request('files.search', { query: 'HOST_IP' })) assert.equal(search.data.matches.length, 1) assert.equal(search.data.matches[0].path, 'config/sensor.yaml') assert.equal(search.data.matches[0].line, 1) await assert.rejects(read(root, request('files.read', { path: 'config/outside.txt' }))) }) test(`${backend}: full file reads include a reproducible hash, partial and missing reads are explicit`, async t => { const root = await fixture(t) const full = await read(root, request('files.read', { path: 'config/sensor.yaml' })) assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8'))) assert.equal(full.data.complete, true) const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 })) assert.equal(partial.data.content, '조향: ackermann') assert.equal(partial.data.complete, false) assert.equal(partial.truncated, true) const missing = await read(root, request('files.read', { path: 'config/new.yaml' })) assert.equal(missing.data.exists, false) assert.equal(missing.data.hash, null) await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' }))) }) test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => { const root = await fixture(t) await fs.mkdir(path.join(root, 'code')) for (let i = 0; i < 5; i++) await fs.writeFile(path.join(root, 'code', `large${i}.py`), 'x'.repeat(900_000)) const result = await read(root, request('files.search', { query: 'host_ip', path: 'config' })) assert.equal(result.truncated, false) assert.equal(result.data.matches[0].path, 'config/sensor.yaml') }) test(`${backend}: validates every original before changing any file and preserves permissions`, async t => { const root = await fixture(t) const original = await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8') const first = { root, moduleName: 'NAV2-MKMini', path: 'config/sensor.yaml', content: 'host_ip: 192.168.1.88\n', expectedHash: agentFileHash(original) } const stale = { root, moduleName: 'NAV2-MKMini', path: 'CMakeLists.txt', content: 'changed', expectedHash: '0'.repeat(64) } await assert.rejects(apply([first, stale])) assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), original) await fs.chmod(path.join(root, first.path), 0o664) await apply([first, { root, moduleName: 'NAV2-MKMini', path: 'config/new.yaml', content: 'created: true', expectedHash: null }]) assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), first.content) assert.equal((await fs.stat(path.join(root, first.path))).mode & 0o777, 0o664) assert.equal(await fs.readFile(path.join(root, 'config/new.yaml'), 'utf8'), 'created: true') await assert.rejects(apply([first])) }) } test('remote batch streams large content rather than putting it into command arguments', async t => { const root = await fixture(t) const content = 'large YAML value '.repeat(11_000) remote({ changes: [{ root, moduleName: 'NAV2-MKMini', path: 'config/large.yaml', expectedHash: null, content }] }) assert.equal(await fs.readFile(path.join(root, 'config/large.yaml'), 'utf8'), content) }) test('local batch rolls back earlier replacements if a later replacement fails', async t => { const root = await fixture(t) const rename = fs.rename let calls = 0 fs.rename = async (...args) => { if (++calls === 2) throw new Error('injected rename error'); return rename(...args) } try { await assert.rejects(applyAgentFiles([ { root, moduleName: 'M', path: 'config/new.yaml', expectedHash: null, content: 'new' }, { root, moduleName: 'M', path: 'CMakeLists.txt', expectedHash: agentFileHash('project(vehicle)\n'), content: 'changed' } ]), /injected/) await assert.rejects(fs.stat(path.join(root, 'config/new.yaml')), { code: 'ENOENT' }) assert.equal(await fs.readFile(path.join(root, 'CMakeLists.txt'), 'utf8'), 'project(vehicle)\n') } finally { fs.rename = rename } }) test('network inspection keeps real host and container observations separate', async () => { const calls = [] const result = await inspectAgentRuntime(request('network.inspect'), async (executable, args) => { calls.push([executable, args]); return '[{"ifname":"eth0"}]' }, async command => { calls.push(command); throw new Error('container is stopped') }) assert.equal(result.host.interfaces[0].ifname, 'eth0') assert.match(result.container.error, /stopped/) assert.equal(calls[0][0], 'ip') assert.equal(calls[1][0], 'ip') assert.equal(calls[2], 'ip -j address show') }) test('ROS probe samples retained maps with matching QoS and distinguishes empty sensor payloads', () => { const preamble = String.raw` import sys, types, time NS = types.SimpleNamespace tick = [0] def monotonic(): tick[0] += 1 return tick[0] time.monotonic = monotonic class Node: def get_topic_names_and_types(self): return [(topic, ['test/Message']) for topic in ['/map', '/scan', '/tf_static']] def get_publishers_info_by_topic(self, topic): return [NS(qos_profile=NS(durability='retained' if topic in ['/map', '/tf_static'] else 'volatile'))] def create_subscription(self, message_type, topic, callback, qos): if topic in ['/map', '/tf_static'] and qos.durability != 'retained': raise ValueError('wrong durability') if topic == '/map': callback(NS(info=NS(width=10, height=10), data=[])) elif topic == '/scan': callback(NS(ranges=[1, 2, 3])) else: callback(NS(transforms=[NS(header=NS(frame_id='map'), child_frame_id='odom')])) return object() def count_publishers(self, topic): return 1 def get_node_names(self): return ['probe'] def destroy_node(self): pass sys.modules['rclpy'] = NS(init=lambda **kw: None, create_node=lambda name: Node(), spin_once=lambda *a, **kw: None, shutdown=lambda: None) sys.modules['rclpy.qos'] = NS(QoSProfile=lambda **kw: NS(**kw), ReliabilityPolicy=NS(BEST_EFFORT='best-effort'), DurabilityPolicy=NS(TRANSIENT_LOCAL='retained', VOLATILE='volatile')) sys.modules['rosidl_runtime_py'] = types.ModuleType('rosidl_runtime_py') sys.modules['rosidl_runtime_py.utilities'] = NS(get_message=lambda name: object) ` const run = spawnSync('python3', ['-c', `${preamble}\nexec(${JSON.stringify(agentRosProbePython)})`, Buffer.from(JSON.stringify(['/map', '/scan', '/tf_static'])).toString('base64')], { encoding: 'utf8', timeout: 5000 }) assert.equal(run.status, 0, run.stderr) const data = JSON.parse(run.stdout) assert.equal(data.topics.find(topic => topic.name === '/map').samples, 1) assert.equal(data.topics.find(topic => topic.name === '/map').validSamples, 0) assert.equal(data.topics.find(topic => topic.name === '/scan').validSamples, 1) assert.deepEqual(data.tfEdges, [{ parent: 'map', child: 'odom' }]) }) test('restoring an interrupted action does not leave history permanently running', () => { const { recoverInterruptedAgentMessages } = require('../src/renderer/src/features/workspace/agentContext.ts') const messages = [{ id: 'reply', role: 'assistant', content: 'result', applyState: 'applying', commandExecutions: { start: { status: 'running', output: 'started one module' }, build: { status: 'completed' } } }] const restored = recoverInterruptedAgentMessages(messages) assert.equal(restored[0].applyState, 'error') assert.equal(restored[0].commandExecutions.start.status, 'failed') assert.equal(restored[0].commandExecutions.start.output, 'started one module') assert.equal(restored[0].commandExecutions.build.status, 'completed') assert.equal(messages[0].commandExecutions.start.status, 'running') }) test('tool loop sends actual evidence, deduplicates requests despite new IDs and never runs actions', async () => { let calls = 0 const executed = [] const result = await resolveAgentContext({ initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal, executeTool: async tool => { executed.push(tool); return { id: tool.id, name: tool.name, status: 'ok', target: 'test', observedAt: new Date().toISOString(), data: '{"paths":["config/a.yaml"]}', truncated: false } }, requestPlan: async (context, finalize) => { calls++ if (calls === 2) assert.equal(context.toolResults.length, 1) return { provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'explanation', requestedModules: [], requestedLogs: [], actionPlan: [], commands: finalize ? [{ operation: { name: 'modules.start' } }] : [], changes: [], createModule: null, toolRequests: [request('files.list', {})] } } } }) assert.equal(executed.length, 1) assert.equal(calls, 3) assert.deepEqual(plain(result.plan.commands), []) assert.deepEqual(plain(result.plan.toolRequests), []) assert.match(result.plan.explanation, /files.list: ok/) }) test('backend and desktop share exactly the same tool validation contract', async () => { assert.equal(await fs.readFile(path.resolve(__dirname, '../../lms-be/src/ai/agent-tools.ts'), 'utf8'), await fs.readFile(path.resolve(__dirname, '../src/shared/agentTools.ts'), 'utf8')) })