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 { createBrowserTools } from '../skills/browser-tools.js'; import { createWebcamTools } from '../skills/webcam-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('browser gateway is a public read and does not wait for confirmation', () => { const id = 'browser-permission'; try { custom.register(id, createBrowserTools({ browser: { call: async () => ({ ok: true }) } })); assert.equal(custom.needsPermission(id, 'browser', 'ask'), false); } finally { custom.clear(id); } }); test('webcam is a read tool gated by the camera grant', async () => { const id = 'webcam-permission'; const camera = { assertActive() { throw new Error('webcam grant is inactive'); }, device: '', setBackend() {}, backend: 'none' }; try { custom.register(id, createWebcamTools({ camera, capture: { capture: async () => ({ path: '/tmp/x.png', via: 'portal' }) } })); assert.equal(custom.needsPermission(id, 'webcam', 'ask'), false); const [tool] = createWebcamTools({ camera }); const blocked = await tool.execute(); assert.match(blocked.error, /inactive/); assert.match(blocked.next_action, /Allow now/); camera.assertActive = () => {}; camera.setBackend = (via) => { camera.backend = via; }; const [live] = createWebcamTools({ camera, capture: { capture: async () => ({ path: '/tmp/x.png', via: 'portal' }) }, }); const shot = await live.execute(); assert.equal(shot.ok, true); assert.equal(shot.via, 'portal'); assert.deepEqual(shot.images, [{ path: '/tmp/x.png', mime: 'image/png' }]); let dest = ''; const [encoded] = createWebcamTools({ camera, capture: { capture: async () => ({ path: '/tmp/x.png', via: 'v4l2' }) }, normalizer: { normalize: async (input, output) => { dest = output; return { path: output, mime: 'image/jpeg', width: 640, height: 480 }; }, }, }); const jpeg = await encoded.execute(); assert.match(dest, /\.jpg$/); assert.equal(jpeg.mime, 'image/jpeg'); assert.deepEqual(jpeg.images, [{ path: dest, mime: 'image/jpeg' }]); } finally { custom.clear(id); } }); test('webcam stills become a user vision follow-up so QVAC has a question after the image', async () => { const qvac = require('../vendor/agent-harness/lib/qvac.js'); const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-webcam-vision-')); const frame = path.join(dir, 'frame.webp'); await writeFile(frame, 'RIFF'); const history = qvac.prepareVisionHistory([ { role: 'user', content: 'Do you see anything?' }, { role: 'assistant', content: '', tool_calls: [{ name: 'webcam' }] }, { role: 'tool', name: 'webcam', content: '{"ok":true}', images: [{ path: frame }] }, ]); const last = history.at(-1); const tool = history.at(-2); assert.equal(tool.role, 'tool'); assert.equal(tool.attachments, undefined); assert.equal(last.role, 'user'); assert.equal(last.content, qvac.VISION_FOLLOWUP_QUESTION); assert.deepEqual(last.attachments, [{ path: frame }]); }); 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]'); });