'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { makeTmpDir, rmrf } = require('./helpers'); const { isLoopbackHost, validateOllamaHost } = require('../../core/text-intel'); const { TextIntelService } = require('../../app/text-intel'); const CaptureService = require('../../app/capture'); function makeSettings(ai = {}) { const data = { ai: { enabled: true, allowRemoteHost: false, attachScreenshots: true, timeoutMs: 60000, maxImageBytes: 12 * 1024 * 1024, ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b' }, ...ai, ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b', ...(ai.ollama || {}) }, }, }; return { get(key) { return key.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), data); }, }; } // ---- loopback policy (pure core) ------------------------------------------- test('isLoopbackHost recognizes local addresses and rejects remote ones', () => { for (const host of [ 'http://127.0.0.1:11434', '127.0.0.1', 'localhost:11434', 'http://[::1]:11434', 'http://127.5.6.7', '0.0.0.0', ]) { assert.equal(isLoopbackHost(host), true, `loopback: ${host}`); } for (const host of [ 'http://10.0.0.5:11434', 'http://192.168.1.20', 'https://ollama.example.com', 'http://8.8.8.8', 'http://ollama.internal', ]) { assert.equal(isLoopbackHost(host), false, `remote: ${host}`); } }); test('validateOllamaHost blocks remote hosts unless explicitly allowed', () => { assert.equal(validateOllamaHost('http://127.0.0.1:11434').ok, true); const blocked = validateOllamaHost('http://10.0.0.5:11434'); assert.equal(blocked.ok, false); assert.match(blocked.reason, /remote/i); assert.equal(validateOllamaHost('http://10.0.0.5:11434', { allowRemote: true }).ok, true); assert.equal(validateOllamaHost('').ok, false); assert.equal(validateOllamaHost('ftp://127.0.0.1').ok, false); }); // ---- host policy enforced by the service ----------------------------------- test('AI connection test refuses a remote host without the opt-in', async (t) => { const root = makeTmpDir('ai-remote-block'); t.after(() => rmrf(root)); let called = false; const service = new TextIntelService({ store: { settingsDir: root }, settings: makeSettings({ ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' } }), dataDir: root, fetchImpl: async () => { called = true; return { ok: true, json: async () => ({}) }; }, }); const result = await service.testAiConnection(); assert.equal(result.ok, false); assert.match(result.reason, /remote/i); assert.equal(called, false, 'must not contact a blocked host at all'); }); test('AI connection test allows a remote host with the opt-in', async (t) => { const root = makeTmpDir('ai-remote-allow'); t.after(() => rmrf(root)); const service = new TextIntelService({ store: { settingsDir: root }, settings: makeSettings({ allowRemoteHost: true, ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' }, }), dataDir: root, fetchImpl: async (url) => { const pathname = new URL(url).pathname; if (pathname === '/api/tags') return { ok: true, json: async () => ({ models: [{ name: 'llama3.2:1b' }] }) }; if (pathname === '/api/show') return { ok: true, json: async () => ({ capabilities: ['vision'] }) }; throw new Error(`unexpected ${pathname}`); }, }); const result = await service.testAiConnection(); assert.equal(result.ok, true); assert.equal(result.host, 'http://10.0.0.5:11434'); }); // ---- request timeout / cancellation ---------------------------------------- test('a hung endpoint times out instead of hanging forever', async (t) => { const root = makeTmpDir('ai-timeout'); t.after(() => rmrf(root)); const service = new TextIntelService({ store: { settingsDir: root }, settings: makeSettings({ timeoutMs: 40 }), dataDir: root, fetchImpl: (url, init) => new Promise((resolve, reject) => { // Never resolves on its own; only the abort signal ends it. init.signal.addEventListener('abort', () => { const err = new Error('aborted'); err.name = 'AbortError'; reject(err); }); }), }); const result = await service.testAiConnection(); assert.equal(result.ok, false); assert.match(result.reason, /timed out|timeout/i); }); test('cancelInflight aborts an outstanding request', async (t) => { const root = makeTmpDir('ai-cancel'); t.after(() => rmrf(root)); const service = new TextIntelService({ store: { settingsDir: root }, settings: makeSettings({ timeoutMs: 60000 }), dataDir: root, fetchImpl: (url, init) => new Promise((resolve, reject) => { init.signal.addEventListener('abort', () => { const err = new Error('aborted'); err.name = 'AbortError'; reject(err); }); }), }); const pending = service.testAiConnection(); // Give the request a tick to register in the inflight set. await new Promise((r) => setTimeout(r, 10)); assert.equal(service.inflight.size, 1); service.cancelInflight(); const result = await pending; assert.equal(result.ok, false); assert.match(result.reason, /cancel/i); }); // ---- typed-text capture is off by default ---------------------------------- function makeCaptureService(settingsOverrides = {}) { const settingsData = { 'capture.mode': 'fullscreen', ...settingsOverrides }; return new CaptureService({ store: {}, settings: { get: (k) => (k in settingsData ? settingsData[k] : null) }, getWindow: () => null, notify: () => {}, screenApi: { getCursorScreenPoint: () => ({ x: 0, y: 0 }), getAllDisplays: () => [] }, }); } test('raw typed text is ignored unless explicitly enabled', () => { const off = makeCaptureService(); off.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now()); off.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now()); assert.equal(off.snapshotKeyContext().recentTyped, '', 'typed text must not be buffered by default'); const on = makeCaptureService({ 'capture.captureTypedText': true }); on.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now()); on.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now()); assert.equal(on.snapshotKeyContext().recentTyped, 'hi'); }); test('shortcut detection still works with typed text disabled', () => { const off = makeCaptureService(); off.onKeyboardEvent('KEY', 'Ctrl+T', Date.now()); assert.equal(off.snapshotKeyContext().recentShortcut, 'Ctrl+T'); }); // ---- stale AI responses cannot overwrite user edits -------------------------- test('an AI patch built from stale data does not clobber a mid-generation user edit', async (t) => { const { GuideStore } = require('../../core/store'); const root = makeTmpDir('ai-stale-write'); t.after(() => rmrf(root)); const store = new GuideStore(root); const guide = store.createGuide({ title: 'G' }); const step = store.addStep(guide.guideId, { title: 'original title' }); const service = new TextIntelService({ store, settings: makeSettings(), dataDir: root, fetchImpl: async (url) => { const pathname = new URL(url).pathname; if (pathname === '/api/show') { return { ok: true, json: async () => ({ capabilities: ['completion'] }) }; } if (pathname === '/api/chat') { // While the model "thinks", the user edits and saves the step. const current = store.getStep(guide.guideId, step.stepId); store.saveStep(guide.guideId, { ...current, title: 'user edit during generation' }); return { ok: true, json: async () => ({ message: { content: JSON.stringify({ title: 'AI title from stale context' }) } }), }; } throw new Error(`unexpected fetch: ${pathname}`); }, }); const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' }); assert.equal(result.ok, false); assert.match(result.reason, /changed while AI was generating/i); assert.equal(store.getStep(guide.guideId, step.stepId).title, 'user edit during generation'); }); test('an AI patch applies cleanly when nothing changed during generation', async (t) => { const { GuideStore } = require('../../core/store'); const root = makeTmpDir('ai-clean-write'); t.after(() => rmrf(root)); const store = new GuideStore(root); const guide = store.createGuide({ title: 'G' }); const step = store.addStep(guide.guideId, { title: '' }); const service = new TextIntelService({ store, settings: makeSettings(), dataDir: root, fetchImpl: async (url) => { const pathname = new URL(url).pathname; if (pathname === '/api/show') { return { ok: true, json: async () => ({ capabilities: ['completion'] }) }; } if (pathname === '/api/chat') { return { ok: true, json: async () => ({ message: { content: JSON.stringify({ title: 'Generated title' }) } }), }; } throw new Error(`unexpected fetch: ${pathname}`); }, }); const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' }); assert.equal(result.ok, true); assert.equal(store.getStep(guide.guideId, step.stepId).title, 'Generated title'); }); // ---- source-level guards ---------------------------------------------------- const fs = require('node:fs'); const path = require('node:path'); const ROOT = path.resolve(__dirname, '..', '..'); test('the Windows keyboard hook only emits characters when opted in', () => { const src = fs.readFileSync(path.join(ROOT, 'app/capture.js'), 'utf8'); // The CHAR emission must be guarded by the opt-in flag threaded into C#. assert.match(src, /CaptureTypedText = \$\{captureTypedText\}/); assert.match(src, /else if \(CaptureTypedText\) \{/); });