import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createWorkspace, createStore, DEFAULT_FILES, MAX_EDITOR_BYTES, WORKSPACE_DB_NAME, } from '../src/workspace.js'; const tool = (workspace, name) => workspace.tools.find(tool => tool.name === name); test('real shell combines the demo data with pipes, awk, jq, redirects, and subshells', async () => { const workspace = createWorkspace(); const response = await tool(workspace, 'bash').execute({ command: `cat sales.csv | tail -n +2 | awk -F, '{total += $2} END {print total}' > units.txt printf '%s\\n' "$(jq -r '.[0].product' products.json):$(cat units.txt)"`, }); assert.equal(response.content, 'Notebook:50\n\nExit code: 0'); assert.deepEqual(response.details, { exitCode: 0 }); assert.equal(await workspace.read('units.txt'), '50\n'); }); test('each command resets cwd and variables while keeping file changes', async () => { const workspace = createWorkspace(); const bash = tool(workspace, 'bash'); await bash.execute({ command: 'mkdir nested; cd nested; VALUE=private; echo saved > note.txt' }); const response = await bash.execute({ command: 'printf "%s:%s:%s" "$PWD" "${VALUE-unset}" "$(cat nested/note.txt)"' }); assert.equal(response.content, '/workspace:unset:saved\nExit code: 0'); }); test('file tools use plain JSON Schema and text results with exact replacement semantics', async () => { const workspace = createWorkspace(); for (const { parameters } of workspace.tools) { assert.equal(parameters.type, 'object'); assert.equal(parameters.additionalProperties, false); assert.doesNotThrow(() => JSON.stringify(parameters)); } await tool(workspace, 'write').execute({ path: 'notes/new.txt', content: 'hello 🌍\nhello again' }); const response = await tool(workspace, 'read').execute({ path: 'notes/new.txt' }); assert.equal(response.content, 'hello 🌍\nhello again'); assert.equal(response.details.path, '/workspace/notes/new.txt'); await assert.rejects(tool(workspace, 'edit').execute({ path: 'notes/new.txt', oldText: 'hello', newText: 'bad' }), /exactly once/); await assert.rejects(tool(workspace, 'edit').execute({ path: 'notes/new.txt', oldText: '', newText: 'bad' }), /exactly once/); await tool(workspace, 'edit').execute({ path: 'notes/new.txt', oldText: 'hello 🌍', newText: '$& is literal' }); assert.equal(await workspace.read('notes/new.txt'), '$& is literal\nhello again'); }); test('a failed command rejects with its exit code and useful output', async () => { const workspace = createWorkspace(); await assert.rejects(tool(workspace, 'bash').execute({ command: 'echo problem >&2; exit 7' }), error => { assert.equal(error.exitCode, 7); assert.match(error.message, /STDERR:\nproblem/); assert.match(error.message, /Exit code: 7/); return true; }); assert.equal((await tool(workspace, 'bash').execute({ command: 'echo recovered' })).details.exitCode, 0); }); test('editor limit counts UTF-8 bytes and tool output is truncated without changing the file', async () => { const workspace = createWorkspace(); await assert.rejects(workspace.write('large.txt', '🌍'.repeat(MAX_EDITOR_BYTES / 4 + 1)), /up to 1 MiB/); const original = 'x'.repeat(7_000); await workspace.write('large.txt', original); const response = await tool(workspace, 'read').execute({ path: 'large.txt' }); assert.match(response.content, /Output truncated at 6,000 characters/); assert.equal(await workspace.read('large.txt'), original); }); test('snapshot and serialization preserve default files and exclude generated command/device entries', async () => { const workspace = createWorkspace(); assert.deepEqual(await workspace.snapshot(), DEFAULT_FILES); const entries = await workspace.serialize(); assert.ok(entries.some(([path]) => path === '/workspace')); assert.ok(!entries.some(([path]) => /^\/(?:bin|usr\/bin|dev|proc)(?:\/|$)/.test(path))); const restored = createWorkspace(undefined, entries); assert.deepEqual(await restored.snapshot(), DEFAULT_FILES); }); test('binary contents, executable modes, empty directories, and symbolic links survive serialization', async () => { const workspace = createWorkspace({}); await workspace.ready; const binary = new Uint8Array([0, 255, 128, 65, 10, 0, 254]); await workspace.bash.fs.writeFile('/workspace/binary.dat', binary); await workspace.bash.fs.chmod('/workspace/binary.dat', 0o640); await workspace.bash.fs.mkdir('/workspace/empty'); await workspace.bash.fs.chmod('/workspace/empty', 0o750); await workspace.write('hello.sh', '#!/bin/bash\necho hello\n'); await workspace.bash.fs.chmod('/workspace/hello.sh', 0o755); await workspace.bash.fs.symlink('hello.sh', '/workspace/hello-link'); const restored = createWorkspace(undefined, await workspace.serialize()); await restored.ready; assert.deepEqual(await restored.bash.fs.readFileBuffer('/workspace/binary.dat'), binary); assert.equal((await restored.bash.fs.stat('/workspace/binary.dat')).mode & 0o777, 0o640); assert.equal((await restored.bash.fs.stat('/workspace/empty')).mode & 0o777, 0o750); assert.equal((await restored.bash.fs.stat('/workspace/hello.sh')).mode & 0o777, 0o755); assert.equal(await restored.bash.fs.readlink('/workspace/hello-link'), 'hello.sh'); assert.equal(await restored.read('hello-link'), '#!/bin/bash\necho hello\n'); assert.equal((await tool(restored, 'bash').execute({ command: 'bash hello-link' })).content, 'hello\n\nExit code: 0'); }); test('aborting a live shell interrupts it and later commands remain usable', async () => { const workspace = createWorkspace(); const controller = new AbortController(); const running = tool(workspace, 'bash').execute({ command: 'sleep 10; echo should-not-run' }, { signal: controller.signal }); const timer = setTimeout(() => controller.abort(new DOMException('Stopped by user', 'AbortError')), 20); try { await assert.rejects(running, /Stopped by user|abort/i); } finally { clearTimeout(timer); } assert.equal((await tool(workspace, 'bash').execute({ command: 'echo ready' })).content, 'ready\n\nExit code: 0'); }); test('pre-aborted file operations do not change files', async () => { const workspace = createWorkspace(); const controller = new AbortController(); controller.abort(new DOMException('Stopped', 'AbortError')); for (const [name, args] of [ ['write', { path: 'sales.csv', content: 'lost' }], ['edit', { path: 'sales.csv', oldText: 'Notebook', newText: 'lost' }], ['read', { path: 'sales.csv' }], ['bash', { command: 'echo lost > sales.csv' }], ]) await assert.rejects(tool(workspace, name).execute(args, { signal: controller.signal }), /Stopped/); assert.equal(await workspace.read('sales.csv'), DEFAULT_FILES['/workspace/sales.csv']); }); test('shell cannot use host binaries or network commands and does not call fetch', async () => { const workspace = createWorkspace(); const originalFetch = globalThis.fetch; let calls = 0; globalThis.fetch = async () => { calls++; throw new Error('Unexpected network call'); }; try { for (const command of ['curl https://example.com', 'wget https://example.com', 'node -e "process.exit(0)"', 'python3 -c "print(1)"', 'npm --version', 'js "console.log(1)"']) { await assert.rejects(tool(workspace, 'bash').execute({ command }), /command not (?:found|available)/); } assert.equal(calls, 0); } finally { globalThis.fetch = originalFetch; } }); test('shell absolute paths stay in the virtual filesystem', async () => { const temporary = await mkdtemp(join(tmpdir(), 'minicpm-workspace-test-')); const hostFile = join(temporary, 'private.txt'); try { await writeFile(hostFile, 'host-only-secret'); const workspace = createWorkspace(); await assert.rejects(tool(workspace, 'bash').execute({ command: `cat '${hostFile}'` }), /No such file|ENOENT|not found/); await tool(workspace, 'bash').execute({ command: `mkdir -p '${temporary}'; printf virtual > '${hostFile}'` }); assert.equal(await workspace.read(hostFile), 'virtual'); assert.equal(await readFile(hostFile, 'utf8'), 'host-only-secret'); } finally { await rm(temporary, { recursive: true, force: true }); } }); test('shell rejects oversized source and excessive virtual filesystem allocation', async () => { const workspace = createWorkspace({}); await assert.rejects(tool(workspace, 'bash').execute({ command: '#'.repeat(131_073) }), /script input size limit/); await workspace.ready; await assert.rejects(workspace.bash.fs.writeFile('/workspace/excessive.bin', new Uint8Array(16 * 1024 * 1024 + 1)), /ENOSPC|byte limit/); }); test('new storage name avoids legacy Pi message data and missing IndexedDB is explicit', async () => { assert.equal(WORKSPACE_DB_NAME, 'minicpm5-gguf-workspace'); await assert.rejects(createStore(null), /Browser storage is unavailable/); });