File size: 9,074 Bytes
ea7122c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | 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/);
});
|