229 lines
12 KiB
JavaScript
229 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { EventEmitter } from 'node:events';
|
|
import { mkdtemp, mkdir, writeFile, readFile, symlink, rm } from 'node:fs/promises';
|
|
import os, { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { createRequire } from 'node:module';
|
|
import { ComputerUseSession } from '../computer-use/session.js';
|
|
import { ComputerActuator } from '../computer-use/actuator.js';
|
|
import { PortalInputBackend } from '../computer-use/portal-input.js';
|
|
import { createComputerObserveTools } from '../skills/computer-observe.js';
|
|
import { createComputerActTools } from '../skills/computer-act.js';
|
|
import { createPhase2Tools, filesystemRoots } from '../skills/phase2-tools.js';
|
|
import { VoiceStateMachine } from '../daemon/voice-state.js';
|
|
import { assertLocalEndpoint } from '../daemon/network-policy.js';
|
|
const require = createRequire(import.meta.url);
|
|
const custom = require('../vendor/agent-harness/agent/custom-tools.js');
|
|
const sandbox = require('../vendor/agent-harness/agent/sandbox.js');
|
|
|
|
test('custom tool permissions survive registration and default to confirmation', () => {
|
|
const id = 'review-permissions';
|
|
try {
|
|
custom.register(id, ['read', 'write', 'dangerous', 'computer-use', undefined].map((permission, i) => ({ name: `review_${i}`, permission })));
|
|
assert.equal(custom.needsPermission(id, 'review_0', 'ask'), false);
|
|
for (let i = 1; i < 5; i++) {
|
|
assert.equal(custom.needsPermission(id, `review_${i}`, 'ask'), true);
|
|
assert.equal(custom.needsPermission(id, `review_${i}`, 'always-approve'), false);
|
|
}
|
|
custom.unregister(id, 'review_1');
|
|
assert.equal(custom.needsPermission(id, 'review_1', 'ask'), false);
|
|
} finally { custom.clear(id); }
|
|
});
|
|
|
|
test('filesystem access lets path tools reach home while workspace stays jailed', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-fs-access-'));
|
|
const workspace = path.join(dir, 'workspace'); await mkdir(workspace);
|
|
const homeProbe = await mkdtemp(path.join(os.homedir(), '.jarvis-fs-test-'));
|
|
try {
|
|
await writeFile(path.join(homeProbe, 'note.txt'), 'from-home');
|
|
const jailed = Object.fromEntries(createPhase2Tools({ cwd: workspace, roots: filesystemRoots('workspace', workspace) }).map(t => [t.name, t]));
|
|
await assert.rejects(jailed.fs_read.execute({ file: path.join(homeProbe, 'note.txt') }), /outside/);
|
|
const opened = Object.fromEntries(createPhase2Tools({ cwd: workspace, roots: filesystemRoots('filesystem', workspace) }).map(t => [t.name, t]));
|
|
assert.equal(await opened.fs_read.execute({ file: path.join(homeProbe, 'note.txt') }), 'from-home');
|
|
const home = Object.fromEntries(createPhase2Tools({ cwd: workspace, roots: filesystemRoots('home', workspace) }).map(t => [t.name, t]));
|
|
assert.equal(await home.fs_read.execute({ file: path.join(homeProbe, 'note.txt') }), 'from-home');
|
|
sandbox.setGrants({ roots: [path.resolve(workspace)] });
|
|
assert.equal(sandbox.isAllowed('jarvis-qvac', path.join(homeProbe, 'note.txt')), false);
|
|
sandbox.setGrants({ roots: [path.resolve(workspace), '/'] });
|
|
assert.equal(sandbox.isAllowed('jarvis-qvac', path.join(homeProbe, 'note.txt')), true);
|
|
} finally {
|
|
await rm(homeProbe, { recursive: true, force: true });
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('workspace tools reject outside roots and symlink escapes and count UTF-8 bytes', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-path-test-'));
|
|
const root = path.join(dir, 'workspace'); await mkdir(root);
|
|
const outside = path.join(dir, 'outside'); await mkdir(outside);
|
|
await writeFile(path.join(outside, 'secret'), 'private');
|
|
await symlink(outside, path.join(root, 'link'));
|
|
await symlink(path.join(outside, 'missing'), path.join(root, 'dangling'));
|
|
const tools = Object.fromEntries(createPhase2Tools({ cwd: root }).map(t => [t.name, t]));
|
|
await assert.rejects(tools.fs_read.execute({ file: 'link/secret' }), /outside/);
|
|
await assert.rejects(tools.fs_write.execute({ file: 'link/new', contents: 'x', confirmed: true }), /outside/);
|
|
await assert.rejects(tools.fs_write.execute({ file: 'dangling', contents: 'x', confirmed: true }), /symlink/);
|
|
await assert.rejects(tools.fs_search.execute({ root: outside, query: 'secret' }), /outside/);
|
|
const result = await tools.fs_write.execute({ file: 'new', contents: 'é', confirmed: true });
|
|
assert.equal(result.bytes, 2);
|
|
assert.equal(await readFile(path.join(root, 'new'), 'utf8'), 'é');
|
|
});
|
|
|
|
test('coordinate click uses portal input even when accessibility actions exist', async () => {
|
|
const session = new ComputerUseSession(); session.grant(); const sent = [];
|
|
const actuator = new ComputerActuator({ session, input: { send: a => sent.push(a) }, atspiAction: () => assert.fail('unexpected semantic click'), sleep: async () => {} });
|
|
await actuator.click({ x: 20, y: 30 });
|
|
assert.equal(sent[0].x, 20);
|
|
});
|
|
|
|
test('stale semantic refs fail before input and revocation during preview prevents action', async () => {
|
|
const session = new ComputerUseSession(); session.grant();
|
|
const actuator = new ComputerActuator({ session, find: async () => [], input: { send: () => assert.fail('unexpected input') }, highlight: async () => session.revoke() });
|
|
await assert.rejects(actuator.type({ ref: 'missing', text: 'secret' }), /stale/);
|
|
await assert.rejects(actuator.click({ x: 1, y: 1 }), /inactive/);
|
|
});
|
|
|
|
test('desktop observation and actuation do not wait for a second Allow after Grant desktop', () => {
|
|
const id = 'cu-observe-permission';
|
|
try {
|
|
custom.register(id, [
|
|
...createComputerObserveTools({ computer: { status: () => ({ active: true }) }, observer: {} }),
|
|
...createComputerActTools({ actuator: {} }),
|
|
]);
|
|
assert.equal(custom.needsPermission(id, 'cu_observe', 'ask'), false);
|
|
assert.equal(custom.needsPermission(id, 'cu_click', 'ask'), false);
|
|
assert.equal(custom.needsPermission(id, 'cu_type', 'ask'), false);
|
|
} finally { custom.clear(id); }
|
|
});
|
|
|
|
test('expired grants prevent desktop observation', async () => {
|
|
let now = 0; const computer = new ComputerUseSession({ clock: () => now }); computer.grant(); now = 180001;
|
|
const [observe] = createComputerObserveTools({ computer, observer: { observe: () => assert.fail('expired observation') } });
|
|
await assert.rejects(observe.execute(), /grant/);
|
|
});
|
|
|
|
test('observe tools return compact tree nodes without accessibility state dumps', async () => {
|
|
const computer = { status: () => ({ active: true }) };
|
|
const observer = {
|
|
observe: async () => ({
|
|
focused: { name: 'Discord' },
|
|
windows: [{ id: 1 }],
|
|
tree: [{ ref: 'r1', role: 'frame', name: 'Discord', rect: [0, 0, 10, 10], state: ['1', '8', '24'] }],
|
|
frame_source: 'pipewire',
|
|
screenshot_path: '/tmp/x.webp',
|
|
unavailable: [],
|
|
}),
|
|
find: async () => [{ ref: 'r1', role: 'entry', name: 'Message', rect: [1, 2, 3, 4], score: 90, state: ['focused'] }],
|
|
};
|
|
const tools = Object.fromEntries(createComputerObserveTools({ computer, observer }).map((tool) => [tool.name, tool]));
|
|
const seen = await tools.cu_observe.execute({});
|
|
assert.equal(seen.tree[0].ref, 'r1');
|
|
assert.equal(seen.tree[0].state, undefined);
|
|
assert.match(seen.hint, /Do not paste/);
|
|
const hits = await tools.cu_find.execute({ query: 'message' });
|
|
assert.equal(hits[0].score, 90);
|
|
assert.equal(hits[0].state, undefined);
|
|
});
|
|
|
|
function helper() {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.stdin = new EventEmitter(); child.stdin.writable = true; child.stdin.write = () => {};
|
|
child.kill = () => { child.killed = true; };
|
|
return child;
|
|
}
|
|
|
|
test('portal helper reports a notify fallback as ready', async () => {
|
|
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
|
|
const grant = input.grant();
|
|
child.stdout.emit('data', '{"type":"ready","restore_token_present":true,"backend":"portal-notify"}\n');
|
|
assert.equal((await grant).backend, 'portal-notify');
|
|
});
|
|
|
|
test('Grant desktop starts a fresh helper and asks GNOME instead of restoring a share', async () => {
|
|
let env;
|
|
const child = helper();
|
|
const input = new PortalInputBackend({ spawnImpl: (_cmd, _args, opts) => { env = opts.env; return child; } });
|
|
const grant = input.grant({ persist: false, mode: 'act' });
|
|
assert.equal(env.JARVIS_CU_PERSIST, '0');
|
|
assert.equal(env.JARVIS_CU_MODE, 'act');
|
|
const source = await import('node:fs/promises').then((fs) => fs.readFile(new URL('../computer-use/py/portal_remote_desktop.py', import.meta.url), 'utf8'));
|
|
assert.match(source, /PERSIST_MODE = 2 if PERSIST else 0/);
|
|
assert.match(source, /request\(screencast, 'SelectSources', '\(oa\{sv\}\)', \(session,/);
|
|
assert.match(source, /results = request\(remote, 'Start'/);
|
|
input.revoke();
|
|
await assert.rejects(grant, /revoked/);
|
|
});
|
|
|
|
test('revoke then Grant desktop starts a new portal helper', async () => {
|
|
const children = [];
|
|
const input = new PortalInputBackend({ spawnImpl: () => { const child = helper(); children.push(child); return child; } });
|
|
const first = input.grant({ persist: false });
|
|
children[0].stdout.emit('data', '{"type":"ready","backend":"portal-ei","screen":true}\n');
|
|
await first;
|
|
input.revoke();
|
|
assert.equal(children[0].killed, true);
|
|
const second = input.grant({ persist: false });
|
|
assert.equal(children.length, 2);
|
|
children[1].stdout.emit('data', '{"type":"ready","backend":"portal-ei","screen":true}\n');
|
|
assert.equal((await second).screen, true);
|
|
});
|
|
|
|
test('portal helper returns a PipeWire frame without ending the grant', async () => {
|
|
const child = helper(); let written = '';
|
|
child.stdin.write = (chunk) => { written += String(chunk); };
|
|
const input = new PortalInputBackend({ spawnImpl: () => child });
|
|
const grant = input.grant();
|
|
child.stdout.emit('data', '{"type":"ready","backend":"portal-ei","screen":true}\n');
|
|
assert.equal((await grant).screen, true);
|
|
const frame = input.captureFrame('/tmp/pw.png');
|
|
assert.match(written, /"type":"frame"/);
|
|
child.stdout.emit('data', '{"type":"frame","path":"/tmp/pw.png","source":"pipewire"}\n');
|
|
assert.equal(await frame, '/tmp/pw.png');
|
|
assert.equal(input.available, true);
|
|
});
|
|
|
|
test('a PipeWire frame error does not revoke desktop access', async () => {
|
|
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
|
|
const grant = input.grant();
|
|
child.stdout.emit('data', '{"type":"ready","backend":"portal-notify","screen":false}\n');
|
|
await grant;
|
|
const frame = input.captureFrame('/tmp/pw.png');
|
|
child.stdout.emit('data', '{"type":"error","reason":"no PipeWire frame yet"}\n');
|
|
await assert.rejects(frame, /no PipeWire frame/);
|
|
assert.equal(input.available, true);
|
|
});
|
|
|
|
test('portal grants wait for a complete readiness message and clear on exit', async () => {
|
|
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
|
|
const grant = input.grant();
|
|
child.stdout.emit('data', '{"type":"rea'); assert.equal(input.available, false);
|
|
child.stdout.emit('data', 'dy","restore_token_present":false}\n');
|
|
assert.equal((await grant).backend, 'portal-ei');
|
|
child.emit('close', 0); assert.equal(input.available, false);
|
|
assert.throws(() => input.send({}), /unavailable/);
|
|
});
|
|
|
|
test('revoked portal grants cannot become ready later', async () => {
|
|
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
|
|
const grant = input.grant(); input.revoke();
|
|
child.stdout.emit('data', '{"type":"ready"}\n');
|
|
await assert.rejects(grant, /revoked/); assert.equal(input.available, false); assert.equal(child.killed, true);
|
|
});
|
|
|
|
test('portal helper errors reject and allow a new grant', async () => {
|
|
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
|
|
const grant = input.grant(); child.stdout.emit('data', '{"type":"error","reason":"denied"}\n');
|
|
await assert.rejects(grant, /denied/); assert.equal(input.process, null);
|
|
});
|
|
|
|
test('idle timer never attempts to sleep a thinking or speaking voice', () => {
|
|
let now = 0; const voice = new VoiceStateMachine({ now: () => now, idleMs: 10 });
|
|
voice.typedUtterance(); now = 100; assert.equal(voice.expireIdle(), 'THINKING');
|
|
voice.speak(); now = 200; assert.equal(voice.expireIdle(), 'SPEAKING');
|
|
});
|
|
|
|
test('local endpoint policy accepts IPv6 loopback', () => {
|
|
assert.equal(assertLocalEndpoint('http://[::1]:8080').hostname, '[::1]');
|
|
});
|