diff --git a/index.js b/index.js index 981bd89..a821990 100644 --- a/index.js +++ b/index.js @@ -17,0 +18 @@ export function apply(ctx) { + const BASH_EXE = 'C:\\Program Files\\Git\\bin\\bash.exe' @@ -18,0 +20 @@ export function apply(ctx) { + const PYTHON_CMD = '"$(if [ -n "${MIMO_PYTHON:-}" ]; then printf \'%s\' "$MIMO_PYTHON"; elif python3 -c \'import sys\' >/dev/null 2>&1; then command -v python3; elif [ -x \'/c/Program Files/LibreOffice/program/python.exe\' ]; then printf \'%s\' \'/c/Program Files/LibreOffice/program/python.exe\'; else printf \'%s\' python3; fi)"' @@ -34,0 +37,3 @@ export function apply(ctx) { + function psq(s) { + return "'" + String(s).replace(/'/g, "''") + "'" + } @@ -36 +41,8 @@ export function apply(ctx) { - return /^([A-Za-z]):[\\/]/.test(path) ? '/mnt/' + path[0].toLowerCase() + path.slice(2).replace(/\\/g, '/') : String(path).replace(/\\/g, '/') + return /^([A-Za-z]):[\\/]/.test(path) ? '/' + path[0].toLowerCase() + path.slice(2).replace(/\\/g, '/') : String(path).replace(/\\/g, '/') + } + function nativePathOf(path) { + const value = String(path) + if (/^[A-Za-z]:[\\/]/.test(value)) return value + const match = value.match(/^\/(?:mnt\/)?([A-Za-z])(?:\/(.*))?$/) + if (match === null) return value + return match[1].toUpperCase() + ':\\' + (match[2] ?? '').replace(/\//g, '\\') @@ -57 +69 @@ export function apply(ctx) { - command, + command: `& ${psq(BASH_EXE)} -lc ${psq(command)}`, @@ -82,5 +94,12 @@ export function apply(ctx) { - const cmd = `printf '%s' ${shq(b64(specJson))} | base64 -d > ${shq(specFile)} && python3 ${DRIVER_SPEC} ${shq(specFile)} ${shq(respFile)}` - const r = await run(cmd, exec, { timeoutMs: timeoutMs + 20000 }) - const status = r.stdout.text.trim() - let respText = null - try { respText = (await run(`cat ${shq(respFile)}`, exec, { timeoutMs: 10000 })).stdout.text } catch {} + const cmd = `printf '%s' ${shq(b64(specJson))} | base64 -d > ${shq(specFile)} && ${PYTHON_CMD} ${DRIVER_SPEC} ${shq(specFile)} ${shq(respFile)}` + const driverRun = await run(cmd, exec, { timeoutMs: timeoutMs + 20000 }) + const driverExitCode = driverRun.exitCode + const driverStderr = driverRun.stderr.text.trim() + const status = driverRun.stdout.text.trim() + let respText = '' + let responseReadExitCode = null + try { + const responseRead = await run(`cat ${shq(respFile)}`, exec, { timeoutMs: 10000 }) + responseReadExitCode = responseRead.exitCode + if (responseRead.exitCode === 0) respText = responseRead.stdout.text + } catch {} @@ -87,0 +107,7 @@ export function apply(ctx) { + const t = respText.trim() + if (!t && driverExitCode !== 0) { + return { ok: false, error: (driverStderr || `driver exited ${driverExitCode}`).slice(0, 400) } + } + if (responseReadExitCode !== null && responseReadExitCode !== 0) { + return { ok: false, error: 'response read failed' } + } @@ -99 +124,0 @@ export function apply(ctx) { - const t = (respText || '').trim() @@ -149,2 +174,8 @@ export function apply(ctx) { - const cmd = `curl -s --max-time 60 --location '${BASE_URL}/chat/completions' -H 'api-key: ${key}' -H 'Content-Type: application/json' -d ${shq(json)}` - const r = await run(cmd, exec, { timeoutMs: 70000 }) + const bodyFile = `${TMP_ROOT}/mimo_search_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.json` + const cmd = `printf '%s' ${shq(b64(json))} | base64 -d > ${shq(bodyFile)} && curl -s --max-time 60 --location '${BASE_URL}/chat/completions' -H 'api-key: ${key}' -H 'Content-Type: application/json' --data-binary ${shq('@' + bodyFile)}` + let r + try { + r = await run(cmd, exec, { timeoutMs: 70000 }) + } finally { + await run(`rm -f ${shq(bodyFile)}`, exec, { timeoutMs: 5000 }).catch(() => {}) + } @@ -154,2 +185,6 @@ export function apply(ctx) { - if (out) { try { data = JSON.parse(out) } catch { parseError = out.slice(0, 400) } } - if (data === null) return { ok: false, error: parseError || 'no response', stderr: r.stderr.text.trim().slice(0, 400) } + if (out) { try { data = JSON.parse(out) } catch { parseError = out.replaceAll(key, '[redacted]').slice(0, 400) } } + if (data === null) { + const stderr = r.stderr.text.trim().replaceAll(key, '[redacted]').slice(0, 400) + const error = parseError || (r.exitCode !== 0 ? stderr || `curl exited ${r.exitCode}` : 'no response') + return { ok: false, error, stderr } + } @@ -231 +266 @@ export function apply(ctx) { - else locals.push({ kind: 'image', mime: mimeOf(img), path: wslPathOf(img) }) + else locals.push({ kind: 'image', mime: mimeOf(img), path: nativePathOf(img) }) @@ -251 +286 @@ export function apply(ctx) { - files: isUrl ? [] : [{ kind: 'audio', mime: mimeOf(args.audio), path: wslPathOf(args.audio) }], + files: isUrl ? [] : [{ kind: 'audio', mime: mimeOf(args.audio), path: nativePathOf(args.audio) }], @@ -267 +302 @@ export function apply(ctx) { - media_resolution: { type: 'string', description: 'Resolution sent to the model (e.g. "default", "480p", "720p"). Optional.' } + media_resolution: { type: 'string', description: 'Resolution sent to the model. Allowed values: default or max. Optional.' } @@ -270,0 +306,3 @@ export function apply(ctx) { + if (args.media_resolution !== undefined && args.media_resolution !== 'default' && args.media_resolution !== 'max') { + return { ok: false, error: 'media_resolution must be "default" or "max"' } + } @@ -274 +312 @@ export function apply(ctx) { - files: isUrl ? [] : [{ kind: 'video', mime: mimeOf(args.url), path: wslPathOf(args.url) }], + files: isUrl ? [] : [{ kind: 'video', mime: mimeOf(args.url), path: nativePathOf(args.url) }], @@ -297 +335 @@ export function apply(ctx) { - files: isUrl ? [] : [{ kind: 'audio', mime: mimeOf(args.audio), path: wslPathOf(args.audio) }], + files: isUrl ? [] : [{ kind: 'audio', mime: mimeOf(args.audio), path: nativePathOf(args.audio) }], @@ -322 +360 @@ export function apply(ctx) { - const outWsl = '/mnt/c/Windows/Temp/' + outPath.split(/[\\/]/).pop() + const outWsl = wslPathOf(outPath) @@ -355 +393 @@ export function apply(ctx) { - const pyCmd = `python3 -c 'import base64,sys; open(sys.argv[1],"wb").write(base64.b64decode(sys.stdin.read()))' ${shq(tmp + '.json')} <<'DSH_EOF'\n${b64(body)}\nDSH_EOF\nprintf '%s' ${py} | base64 -d | python3 - ${shq(tmp + '.json')} ${shq(BASE_URL + '/chat/completions')} ${shq(key)} ${shq(tmp + '.resp')}` + const pyCmd = `${PYTHON_CMD} -c 'import base64,sys; open(sys.argv[1],"wb").write(base64.b64decode(sys.stdin.read()))' ${shq(tmp + '.json')} <<'DSH_EOF'\n${b64(body)}\nDSH_EOF\nprintf '%s' ${py} | base64 -d | ${PYTHON_CMD} - ${shq(tmp + '.json')} ${shq(BASE_URL + '/chat/completions')} ${shq(key)} ${shq(tmp + '.resp')}` @@ -368 +406 @@ export function apply(ctx) { - const decRun = await run(`printf '%s' ${dec} | base64 -d | python3 - ${shq(tmp + '.resp')} ${shq(outWsl)}`, exec, { timeoutMs: 30000 }) + const decRun = await run(`printf '%s' ${dec} | base64 -d | ${PYTHON_CMD} - ${shq(tmp + '.resp')} ${shq(outWsl)}`, exec, { timeoutMs: 30000 }) @@ -390 +428 @@ export function apply(ctx) { - const outWsl = '/mnt/c/Windows/Temp/' + outPath.split(/[\\/]/).pop() + const outWsl = wslPathOf(outPath) @@ -421 +459 @@ export function apply(ctx) { - const pyCmd = `printf '%s' ${py} | base64 -d | python3 - ${shq(isUrl ? ref : refWsl)} ${shq(args.text)} ${shq(refMime)} ${shq(fmt)} ${shq(key)} ${shq(BASE_URL + '/chat/completions')} ${shq(tmp + '.resp')}` + const pyCmd = `printf '%s' ${py} | base64 -d | ${PYTHON_CMD} - ${shq(isUrl ? ref : refWsl)} ${shq(args.text)} ${shq(refMime)} ${shq(fmt)} ${shq(key)} ${shq(BASE_URL + '/chat/completions')} ${shq(tmp + '.resp')}` @@ -433 +471 @@ export function apply(ctx) { - const errRun = await run(`printf '%s' ${errPy} | base64 -d | python3 - ${shq(tmp + '.resp')}`, exec, { timeoutMs: 10000 }) + const errRun = await run(`printf '%s' ${errPy} | base64 -d | ${PYTHON_CMD} - ${shq(tmp + '.resp')}`, exec, { timeoutMs: 10000 }) @@ -451 +489 @@ export function apply(ctx) { - const extractRun = await run(`printf '%s' ${extractPy} | base64 -d | python3 - ${shq(tmp + '.resp')} ${shq(outWsl)}`, exec, { timeoutMs: 30000 }) + const extractRun = await run(`printf '%s' ${extractPy} | base64 -d | ${PYTHON_CMD} - ${shq(tmp + '.resp')} ${shq(outWsl)}`, exec, { timeoutMs: 30000 }) @@ -473,0 +512 @@ export function apply(ctx) { + source: 'runtime', diff --git a/tests/tools.test.mjs b/tests/tools.test.mjs index 3d69309..6fee5bd 100644 --- a/tests/tools.test.mjs +++ b/tests/tools.test.mjs @@ -1,2 +1,2 @@ -// Static registration tests for dsh-mimo-agent-tools index.js — no network, -// no real ctx: we fake the minimal ctx surface (tools.register, skills.register, +// Registration and local integration tests for dsh-mimo-agent-tools index.js — +// no MiMo access or real ctx: we fake the minimal ctx surface where needed, @@ -7,0 +8,2 @@ import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' @@ -10,0 +13,26 @@ import { apply, name, inject } from '../index.js' +const BASH_EXE = 'C:\\Program Files\\Git\\bin\\bash.exe' +const source = readFileSync(new URL('../index.js', import.meta.url), 'utf8') + +function psq(s) { + return "'" + String(s).replace(/'/g, "''") + "'" +} + +function wrapBash(command) { + return `& ${psq(BASH_EXE)} -lc ${psq(command)}` +} + +function runWrappedBash(command, timeout = 15000) { + return spawnSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', wrapBash(command)], { + encoding: 'utf8', + timeout, + }) +} + +function unwrapBash(command) { + const prefix = `& ${psq(BASH_EXE)} -lc ` + assert.ok(command.startsWith(prefix), 'command should use the PowerShell to Git Bash boundary') + const arg = command.slice(prefix.length) + assert.ok(arg.startsWith("'") && arg.endsWith("'"), 'bash command should be one PowerShell argument') + return arg.slice(1, -1).replace(/''/g, "'") +} + @@ -36,0 +65 @@ test('registers all 9 tools', () => { + assert.equal(registered.length, 9) @@ -39,0 +69,119 @@ test('registers all 9 tools', () => { +test('all shell execution crosses the PowerShell to Git Bash boundary', () => { + assert.equal((source.match(/shell\.run\(/g) ?? []).length, 1) + assert.ok(source.includes('command: `& ${psq(BASH_EXE)} -lc ${psq(command)}`')) +}) + +test('driver media paths are native while TTS and voiceclone paths stay Git Bash paths', () => { + assert.equal((source.match(/path: nativePathOf\(/g) ?? []).length, 4) + assert.equal((source.match(/path: wslPathOf\(/g) ?? []).length, 0) + assert.ok(source.includes('const outWsl = wslPathOf(outPath)')) + assert.ok(source.includes('const refWsl = wslPathOf(ref)')) +}) + +test('wrapped driver specs use native Windows paths without changing URLs', async () => { + const commands = [] + const registeredTools = [] + const shell = { + resolve(request) { + commands.push(request.command) + return request + }, + async run(request) { + const command = unwrapBash(request.command) + if (command.startsWith('cat ')) return { exitCode: 0, stdout: { text: 'NET_ERR:offline' }, stderr: { text: '' } } + if (command.startsWith('rm -f ')) return { exitCode: 0, stdout: { text: '' }, stderr: { text: '' } } + return { exitCode: 0, stdout: { text: 'FAIL' }, stderr: { text: '' } } + }, + } + const ctx = { + get(service) { + if (service === 'shell') return shell + if (service === 'credentials') return { resolve: async () => ({ value: 'dummy-local-test' }) } + return undefined + }, + tools: { register(tool) { registeredTools.push(tool) } }, + skills: { register() {} }, + logger: { warn() {} }, + } + apply(ctx) + const tools = Object.fromEntries(registeredTools.map((tool) => [tool.name, tool])) + await tools.mimo_vision.execute({ images: ['/mnt/c/media/image.png', 'https://example.invalid/image.png'], prompt: 'test' }) + await tools.mimo_audio.execute({ audio: '/d/audio/test.mp3' }) + await tools.mimo_video.execute({ url: 'E:\\clips\\test.mp4' }) + await tools.mimo_asr.execute({ audio: '/home/test.wav' }) + + const specs = commands + .map(unwrapBash) + .filter((command) => command.startsWith("printf '%s' ")) + .map((command) => { + const match = command.match(/^printf '%s' '([A-Za-z0-9+/=]+)'/) + assert.ok(match, 'driver command should contain a base64 spec') + return JSON.parse(Buffer.from(match[1], 'base64').toString('utf8')) + }) + assert.equal(specs.length, 4) + assert.equal(specs[0].files[0].path, 'C:\\media\\image.png') + assert.deepEqual(specs[0].urls, ['https://example.invalid/image.png']) + assert.equal(specs[1].files[0].path, 'D:\\audio\\test.mp3') + assert.equal(specs[2].files[0].path, 'E:\\clips\\test.mp4') + assert.equal(specs[3].files[0].path, '/home/test.wav') +}) + +test('runDriver prefers driver stderr when the response cannot be read', async () => { + const registeredTools = [] + let call = 0 + const shell = { + resolve(request) { return request }, + async run() { + call += 1 + if (call === 1) return { exitCode: 7, stdout: { text: '' }, stderr: { text: 'driver failed locally' } } + if (call === 2) return { exitCode: 1, stdout: { text: '' }, stderr: { text: 'missing response' } } + return { exitCode: 0, stdout: { text: '' }, stderr: { text: '' } } + }, + } + const ctx = { + get(service) { + if (service === 'shell') return shell + if (service === 'credentials') return { resolve: async () => ({ value: 'dummy-local-test' }) } + return undefined + }, + tools: { register(tool) { registeredTools.push(tool) } }, + skills: { register() {} }, + logger: { warn() {} }, + } + apply(ctx) + const think = registeredTools.find((tool) => tool.name === 'mimo_think') + const result = await think.execute({ prompt: 'offline' }) + assert.deepEqual(result, { ok: false, error: 'driver failed locally' }) +}) + +test('PowerShell wrapper preserves single quotes, dollars, newlines, and heredocs', () => { + const command = [ + "cat <<'DSH_TEST_EOF'", + "single ' quote", + 'dollar$sign', + 'line two', + 'DSH_TEST_EOF', + ].join('\n') + const result = runWrappedBash(command) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.stderr, '') + assert.equal(result.stdout.replace(/\r\n/g, '\n'), "single ' quote\ndollar$sign\nline two\n") +}) + +test('default driver starts through PowerShell and writes NET_ERR offline', () => { + const python = '"$(if [ -n "${MIMO_PYTHON:-}" ]; then printf \'%s\' "$MIMO_PYTHON"; elif python3 -c \'import sys\' >/dev/null 2>&1; then command -v python3; elif [ -x \'/c/Program Files/LibreOffice/program/python.exe\' ]; then printf \'%s\' \'/c/Program Files/LibreOffice/program/python.exe\'; else printf \'%s\' python3; fi)"' + const command = [ + 'tmp=$(mktemp -d)', + 'trap \'rm -rf "$tmp"\' EXIT', + 'cat > "$tmp/spec.json" <<\'DSH_SPEC\'', + '{"url":"http://127.0.0.1:1","key":"dummy-local-test","timeout":2,"model":"mimo-v2.5-pro","kind":"think","prompt":"offline integration test"}', + 'DSH_SPEC', + python + ' ${MIMO_DRIVER:-$HOME/.local/lib/mimo-agent-tools/driver/mimo_driver.py} "$tmp/spec.json" "$tmp/response.txt"', + 'cat "$tmp/response.txt"', + ].join('\n') + const result = runWrappedBash(command) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /NET_ERR:/) + assert.doesNotMatch(result.stdout + result.stderr, /ParserError|no response/) +}) + @@ -60,0 +209,106 @@ test('mimo_search exposes force_search + user_location', () => { +test('mimo_search sends exact UTF-8 JSON through a temporary body file and cleans up', async () => { + const commands = [] + const registeredTools = [] + const shell = { + resolve(request) { return request }, + async run(request) { + const command = unwrapBash(request.command) + commands.push(command) + if (command.startsWith('rm -f ')) return { exitCode: 0, stdout: { text: '' }, stderr: { text: '' } } + return { + exitCode: 0, + stdout: { text: JSON.stringify({ choices: [{ message: { content: 'ok', annotations: [] } }], usage: null }) }, + stderr: { text: '' }, + } + }, + } + const ctx = { + get(service) { + if (service === 'shell') return shell + if (service === 'credentials') return { resolve: async () => ({ value: 'dummy-local-test' }) } + return undefined + }, + tools: { register(tool) { registeredTools.push(tool) } }, + skills: { register() {} }, + logger: { warn() {} }, + } + apply(ctx) + const search = registeredTools.find((tool) => tool.name === 'mimo_search') + const userLocation = { type: 'approximate', country: '中国', region: '湖北', city: '武汉' } + const result = await search.execute({ + query: "武汉's weather", + max_keyword: 4, + limit: 7, + force_search: false, + user_location: userLocation, + }) + + assert.equal(result.ok, true) + assert.equal(commands.length, 2) + const requestCommand = commands[0] + const match = requestCommand.match(/^printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d > '([^']+)' && curl /) + assert.ok(match, 'search command should decode a base64 request body into a file') + const bodyFile = match[2] + assert.match(bodyFile, /^\/tmp\/mimo_search_\d+_[a-z0-9]+\.json$/) + const decodedJson = Buffer.from(match[1], 'base64').toString('utf8') + const decodedBody = JSON.parse(decodedJson) + assert.deepEqual(decodedBody, { + model: 'mimo-v2.5-pro', + messages: [{ role: 'user', content: "Perform a web search for the query: 武汉's weather" }], + max_completion_tokens: 1024, + stream: false, + thinking: { type: 'disabled' }, + tools: [{ type: 'web_search', max_keyword: 4, force_search: false, limit: 7, user_location: userLocation }], + }) + const curlCommand = requestCommand.slice(requestCommand.indexOf('&& curl')) + assert.ok(curlCommand.includes(`--data-binary '@${bodyFile}'`)) + assert.doesNotMatch(curlCommand, /(?:^|\s)-d(?:\s|$)/) + assert.ok(!curlCommand.includes(decodedJson), 'curl command must not contain raw JSON') + assert.equal(commands[1], `rm -f '${bodyFile}'`) +}) + +test('mimo_search reports curl failures without no response and still cleans up', async () => { + async function executeFailure(stderrText) { + const commands = [] + const registeredTools = [] + const shell = { + resolve(request) { return request }, + async run(request) { + const command = unwrapBash(request.command) + commands.push(command) + if (command.startsWith('rm -f ')) return { exitCode: 0, stdout: { text: '' }, stderr: { text: '' } } + return { exitCode: 28, stdout: { text: '' }, stderr: { text: stderrText } } + }, + } + const ctx = { + get(service) { + if (service === 'shell') return shell + if (service === 'credentials') return { resolve: async () => ({ value: 'dummy-local-test' }) } + return undefined + }, + tools: { register(tool) { registeredTools.push(tool) } }, + skills: { register() {} }, + logger: { warn() {} }, + } + apply(ctx) + const search = registeredTools.find((tool) => tool.name === 'mimo_search') + const result = await search.execute({ query: 'offline', user_location: { type: 'approximate', country: 'China', region: 'Hubei', city: 'Wuhan' } }) + return { commands, result } + } + + const withStderr = await executeFailure('curl failed locally: dummy-local-test') + assert.equal(withStderr.result.error, 'curl failed locally: [redacted]') + assert.equal(withStderr.result.stderr, 'curl failed locally: [redacted]') + assert.match(withStderr.commands.at(-1), /^rm -f '\/tmp\/mimo_search_.*\.json'$/) + assert.ok(!withStderr.result.error.includes('dummy-local-test')) + + const withoutStderr = await executeFailure('') + assert.equal(withoutStderr.result.error, 'curl exited 28') + assert.notEqual(withoutStderr.result.error, 'no response') + assert.match(withoutStderr.commands.at(-1), /^rm -f '\/tmp\/mimo_search_.*\.json'$/) + + const truncated = await executeFailure('x'.repeat(500)) + assert.equal(truncated.result.error.length, 400) + assert.equal(truncated.result.stderr.length, 400) +}) + @@ -64,0 +319 @@ test('mimo_video exposes fps + media_resolution and accepts local files', () => + assert.ok(p.media_resolution.description.includes('Allowed values: default or max')) @@ -67,0 +323,20 @@ test('mimo_video exposes fps + media_resolution and accepts local files', () => +test('mimo_video rejects invalid media_resolution before accessing driver services', async () => { + const registeredTools = [] + let serviceAccesses = 0 + const ctx = { + get(service) { + serviceAccesses += 1 + throw new Error(`unexpected ctx service access: ${service}`) + }, + tools: { register(tool) { registeredTools.push(tool) } }, + skills: { register() {} }, + logger: { warn() {} }, + } + apply(ctx) + const video = registeredTools.find((tool) => tool.name === 'mimo_video') + const result = await video.execute({ url: 'https://example.invalid/video.mp4', media_resolution: '720p' }) + + assert.deepEqual(result, { ok: false, error: 'media_resolution must be "default" or "max"' }) + assert.equal(serviceAccesses, 0) +}) + @@ -88,0 +364,3 @@ test('registers the audio-tools skill with Dean (no Dea)', () => { + assert.equal(typeof skill.source, 'string') + assert.equal(skill.source, 'runtime') + assert.equal(typeof skill.content, 'string')