Arx 0.5.6: password/lockout fixes on Mac, Linux, Windows; Fix problems on Windows (fix.ps1); Linux pwquality read order
458b1d7 verified Download app/main.cjs from Devseis/Arx: direct link, hf CLI and curl.
- Browser
- Download file 21.2 kB
-
https://huggingface.co/spaces/Devseis/Arx/resolve/main/app/main.cjs
- Command line
-
hf download hf://spaces/Devseis/Arx/app/main.cjs
-
curl -L -o main.cjs https://huggingface.co/spaces/Devseis/Arx/resolve/main/app/main.cjs
21.2 kB
| // Arx (Devseis Endpoint Auditor) - Electron main process. | |
| // Runs the read-only collector for this OS (with the OS's own administrator prompt), streams progress | |
| // to the window, turns the report into a PDF and saves it where the user chooses or, after 60 seconds | |
| // without an answer, in Documents/Arx Reports/<computer>-<date>/. | |
| const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron'); | |
| const { spawn } = require('node:child_process'); | |
| const crypto = require('node:crypto'); | |
| const fs = require('node:fs'); | |
| const os = require('node:os'); | |
| const path = require('node:path'); | |
| const vulndb = require('./vulndb.cjs'); | |
| // Renamed to Arx in 0.4.3: keep the old settings folder, so baseline.conf, organisational.conf, the vulnerability data | |
| // and the downloaded AI model stay where earlier versions put them. | |
| app.setPath('userData', path.join(app.getPath('appData'), 'Devseis Endpoint Auditor')); | |
| // WebGPU (used by WebLLM) is still behind a flag on Linux. | |
| if (process.platform === 'linux') { | |
| app.commandLine.appendSwitch('enable-unsafe-webgpu'); | |
| app.commandLine.appendSwitch('enable-features', 'Vulkan'); | |
| } | |
| // Audit files: next to the app when packaged, the repository root during development. | |
| const AUDIT_ROOT = app.isPackaged ? path.join(process.resourcesPath, 'audit') : path.join(__dirname, '..'); | |
| const OS_FAMILY = { win32: 'windows', darwin: 'macos', linux: 'linux' }[process.platform]; | |
| const readJson = (...parts) => JSON.parse(fs.readFileSync(path.join(AUDIT_ROOT, ...parts), 'utf8')); | |
| // Settings the organisation may edit: copied once to the user's folder | |
| // (~/Library/Application Support/Devseis Endpoint Auditor on macOS) and read from there. | |
| function settingsFile(name) { | |
| const user = path.join(app.getPath('userData'), name); | |
| try { | |
| if (!fs.existsSync(user)) { fs.mkdirSync(path.dirname(user), { recursive: true }); fs.copyFileSync(path.join(AUDIT_ROOT, name), user); } | |
| return user; | |
| } catch { return path.join(AUDIT_ROOT, name); } | |
| } | |
| ipcMain.handle('settings:open', () => shell.openPath(path.dirname(settingsFile('baseline.conf')))); | |
| // Model version: published on the Space so installed apps follow updates; bundled copy as fallback. | |
| const MODEL_VERSION_URL = 'https://huggingface.co/spaces/Devseis/Arx/resolve/main/app/model-version.json'; | |
| // { version, version_4bit }: version_4bit is used by the 4-bit builds (graphics chips without 16-bit maths), because | |
| // v0.3 does not survive 4-bit rounding and v0.6 (trained 4-bit-aware) does. Apps before 0.5.0 read only "version". | |
| const pickVersions = j => { | |
| const ok = v => typeof v === 'string' && /^v\d+\.\d+$/.test(v); | |
| return ok(j.version) ? { version: j.version, version_4bit: ok(j.version_4bit) ? j.version_4bit : j.version } : undefined; | |
| }; | |
| ipcMain.handle('model:version', async () => { | |
| const local = () => { try { return pickVersions(JSON.parse(fs.readFileSync(path.join(__dirname, 'model-version.json'), 'utf8'))); } catch { return undefined; } }; | |
| try { | |
| const controller = new AbortController(); | |
| const timer = setTimeout(() => controller.abort(), 5000); | |
| const res = await fetch(MODEL_VERSION_URL, { signal: controller.signal, cache: 'no-store' }); | |
| clearTimeout(timer); | |
| if (res.ok) { const v = pickVersions(await res.json()); if (v) return v; } | |
| } catch { /* offline: use the bundled version */ } | |
| return local(); | |
| }); | |
| // Vulnerability data: the downloaded copy first, then the one shipped with the app (or built locally during development). | |
| const vulndbDirs = () => [path.join(app.getPath('userData'), 'vulndb'), path.join(AUDIT_ROOT, 'vulndb', 'out')]; | |
| let mainWindow = null; | |
| // Automated check of a build: DEVSEIS_SMOKE=<folder> runs a non-elevated audit without the model and saves the report there. | |
| const SMOKE = process.env.DEVSEIS_SMOKE || ''; | |
| function smokeTest() { | |
| const js = code => mainWindow.webContents.executeJavaScript(code); | |
| const until = async (code, ms) => { const t = Date.now(); while (!(await js(code).catch(() => false))) { if (Date.now() - t > ms) throw new Error(`smoke timeout: ${code}`); await new Promise(r => setTimeout(r, 500)); } }; | |
| mainWindow.webContents.once('did-finish-load', async () => { | |
| try { | |
| await until(`!document.getElementById('screen-approve').classList.contains('hidden')`, 3600000); | |
| console.log('SMOKE model:', await js(`document.getElementById('model-name').textContent`)); | |
| await js(`document.getElementById('elevated').checked = false; document.getElementById('run').click()`); | |
| await until(`!document.getElementById('save-modal').classList.contains('hidden')`, 3600000); | |
| console.log('SMOKE result:', (await js(`document.getElementById('result-summary').innerText`)).replace(/\s+/g, ' ').slice(0, 600)); | |
| await js(`document.getElementById('save-docs').click()`); | |
| await until(`/Saved|saved/.test(document.getElementById('saved-note').textContent)`, 120000); | |
| console.log('SMOKE fixes:', (await js(`document.getElementById('fixes').innerText`)).replace(/\s+/g, ' ').slice(0, 900)); | |
| fs.writeFileSync(path.join(SMOKE, 'smoke-results.png'), (await mainWindow.webContents.capturePage()).toPNG()); | |
| // DEVSEIS_FIX_DRY=1: click Fix selected and confirm; the fix script only prints what it would change (no root, no change). | |
| if (process.env.DEVSEIS_FIX_DRY && await js(`!!document.getElementById('fix-selected')`)) { | |
| await js(`document.getElementById('fix-selected').click()`); | |
| console.log('SMOKE confirm:', (await js(`document.getElementById('fix-confirm-list').innerText`)).replace(/\s+/g, ' ').slice(0, 600)); | |
| await js(`document.getElementById('fix-go').click()`); | |
| await until(`!document.getElementById('fix-banner').classList.contains('hidden')`, 3600000); | |
| console.log('SMOKE fix banner:', (await js(`document.getElementById('fix-banner').innerText`)).replace(/\s+/g, ' ').slice(0, 900)); | |
| await js(`document.getElementById('save-modal').classList.add('hidden'); 0`); | |
| fs.writeFileSync(path.join(SMOKE, 'smoke-fixed.png'), (await mainWindow.webContents.capturePage()).toPNG()); | |
| } | |
| console.log('SMOKE OK', await js(`document.getElementById('saved-note').textContent`)); | |
| app.exit(0); | |
| } catch (error) { console.log('SMOKE FAILED', error.message); app.exit(1); } | |
| }); | |
| } | |
| function createWindow() { | |
| mainWindow = new BrowserWindow({ | |
| width: 1100, height: 780, minWidth: 760, minHeight: 560, title: 'Arx', | |
| webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true }, | |
| }); | |
| // DEVSEIS_SMOKE_MODEL=1 also loads the AI model (downloads it on first run). | |
| mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'), SMOKE && !process.env.DEVSEIS_SMOKE_MODEL ? { search: 'smoke=1' } : undefined); | |
| if (SMOKE) smokeTest(); | |
| } | |
| // One Arx at a time: a second launch (a double-click while Arx is still starting or closing) hands over to the running | |
| // copy, which shows its window, and exits; two copies could leave one stuck in the background with no window. | |
| const firstInstance = !!SMOKE || app.requestSingleInstanceLock(); | |
| if (!firstInstance) app.quit(); | |
| app.on('second-instance', () => { | |
| if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); return; } | |
| if (mainWindow.isMinimized()) mainWindow.restore(); | |
| mainWindow.show(); | |
| mainWindow.focus(); | |
| }); | |
| app.whenReady().then(() => { | |
| if (!firstInstance) return; | |
| createWindow(); | |
| // Fetch newer vulnerability data in the background; the audit uses the last good copy if this fails. | |
| vulndb.refresh(vulndbDirs()[0]).catch(error => console.warn(`Vulnerability data not refreshed: ${error.message}`)); | |
| app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); | |
| }); | |
| // Closing the Arx window quits Arx, on macOS too: users expect a closed utility to be gone, not running unseen. | |
| app.on('window-all-closed', () => app.quit()); | |
| // --- What will be audited ------------------------------------------------------- | |
| ipcMain.handle('audit:plan', () => { | |
| const catalog = readJson('catalog', 'checks.json'); | |
| const sections = catalog.sections.map(section => ({ | |
| id: section.id, | |
| title: section.title, | |
| checks: catalog.checks.filter(c => c.section === section.id && c.os.includes(OS_FAMILY)) | |
| .map(c => ({ id: c.id, name: (c.names && c.names[OS_FAMILY]) || c.name })), | |
| })); | |
| return { os: OS_FAMILY, hostname: os.hostname(), platform: `${os.type()} ${os.release()}`, sections }; | |
| }); | |
| ipcMain.handle('audit:library', () => ({ | |
| catalog: readJson('catalog', 'checks.json'), | |
| controls: readJson('library', 'controls.json'), | |
| guidance: readJson('library', 'check_guidance.json'), | |
| aiTools: readJson('library', 'ai_tools.json'), | |
| fixes: readJson('library', 'fixes.json').fixes, | |
| })); | |
| // --- Fixes ------------------------------------------------------------------------------- | |
| // Only ids that library/fixes.json marks as 'auto' for this OS reach the fix script; nothing else is passed to a shell. | |
| function autoFixIds(ids) { | |
| const fixes = readJson('library', 'fixes.json').fixes; | |
| return [...new Set(ids)].filter(id => typeof id === 'string' && fixes[id] && fixes[id][OS_FAMILY] && fixes[id][OS_FAMILY].kind === 'auto'); | |
| } | |
| const FIX_SCRIPT = { macos: 'fix.sh', linux: 'fix.sh', windows: 'fix.ps1' }; | |
| // Test only (smoke test): the fix script prints what it would change and changes nothing; no administrator prompt. | |
| const FIX_DRY = !!(process.env.DEVSEIS_SMOKE && process.env.DEVSEIS_FIX_DRY); | |
| const fixHistoryFile = () => path.join(app.getPath('userData'), 'fix-history.json'); | |
| function fixHistory() { try { return JSON.parse(fs.readFileSync(fixHistoryFile(), 'utf8')); } catch { return []; } } | |
| ipcMain.handle('fix:history', () => fixHistory().slice(-20)); | |
| ipcMain.handle('fix:settings', (event, checkId) => { | |
| const fix = (readJson('library', 'fixes.json').fixes[checkId] || {})[OS_FAMILY]; | |
| if (fix && fix.kind === 'settings' && /^x-apple\.systempreferences:|^ms-settings:|^windowsdefender:/.test(fix.url)) return shell.openExternal(fix.url); | |
| return false; | |
| }); | |
| // --- Running the collector ---------------------------------------------------------- | |
| function collectorCommand(workDir, progressFile, elevated, fix = null) { | |
| const base = settingsFile('baseline.conf'); | |
| const org = settingsFile('organisational.conf'); | |
| if (OS_FAMILY === 'windows') { | |
| const q = s => `'${s.replace(/'/g, "''")}'`; | |
| const runner = path.join(workDir, 'run.ps1'); | |
| // A fix run: apply or undo first (old values saved in the fix state file), then audit again to show the result. | |
| const fixStep = fix ? `& ${q(path.join(AUDIT_ROOT, 'collectors', 'windows', FIX_SCRIPT.windows))} -Action ${fix.action} -State ${q(path.join(app.getPath('userData'), 'fix-state.tsv'))} -Results ${q(path.join(workDir, 'fix-results.tsv'))} -Baseline ${q(base)}${FIX_DRY ? ' -DryRun' : ''} -Ids @(${fix.ids.map(q).join(', ')}) *> ${q(path.join(workDir, 'fix.log'))}\n` : ''; | |
| fs.writeFileSync(runner, `${fixStep}& ${q(path.join(AUDIT_ROOT, 'collectors', 'windows', 'audit.ps1'))} -Baseline ${q(base)} -Organisational ${q(org)} -Output ${q(workDir)} -Progress ${q(progressFile)} *> ${q(path.join(workDir, 'collector.log'))}\n`); | |
| const inner = `-NoProfile -ExecutionPolicy Bypass -File "${runner}"`; | |
| if (!elevated) return ['powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', runner]]; | |
| // UAC prompt; -Wait keeps this process alive until the elevated audit finishes. | |
| return ['powershell.exe', ['-NoProfile', '-Command', `Start-Process powershell.exe -Verb RunAs -Wait -WindowStyle Hidden -ArgumentList '${inner.replace(/'/g, "''")}'`]]; | |
| } | |
| const q = s => `'${s.replace(/'/g, `'\\''`)}'`; | |
| const runner = path.join(workDir, 'run.sh'); | |
| // Development runs read the scripts from the repository, often under ~/Documents, which macOS privacy protection | |
| // (TCC) hides from the administrator process ("Operation not permitted"). Run copies from the temporary folder instead; | |
| // a packaged app keeps them inside the app bundle, which is not protected. | |
| let scripts = path.join(AUDIT_ROOT, 'collectors', OS_FAMILY); | |
| if (!app.isPackaged) { | |
| const copy = path.join(workDir, 'collectors'); | |
| fs.cpSync(scripts, copy, { recursive: true }); | |
| scripts = copy; | |
| } | |
| const collector = path.join(scripts, 'audit.sh'); | |
| // A fix run: apply or undo first (old values saved in the fix state file), then audit again to show the result. | |
| const fixStep = fix ? `/bin/bash ${q(path.join(scripts, FIX_SCRIPT[OS_FAMILY]))} ${fix.action}${FIX_DRY ? ' --dry-run' : ''} --state ${q(path.join(app.getPath('userData'), 'fix-state.tsv'))} --baseline ${q(base)} ${fix.ids.map(q).join(' ')} > ${q(path.join(workDir, 'fix-results.tsv'))} 2>&1\n` : ''; | |
| fs.writeFileSync(runner, `#!/bin/bash\n${fixStep}/bin/bash ${q(collector)} --baseline ${q(base)} --org ${q(org)} --output ${q(workDir)} --progress ${q(progressFile)} > ${q(path.join(workDir, 'collector.log'))} 2>&1\nchmod -R a+rX ${q(workDir)}\n`, { mode: 0o755 }); | |
| if (!elevated) return ['/bin/bash', [runner]]; | |
| if (OS_FAMILY === 'macos') { | |
| // macOS administrator prompt; the runner path has no quotes or backslashes. | |
| const prompt = fix ? `Arx needs administrator rights to ${fix.action === 'undo' ? 'undo' : 'apply'} ${fix.ids.length} fix${fix.ids.length > 1 ? 'es' : ''} you chose, then check this Mac again.` | |
| : 'Arx needs administrator rights to read security settings. It only reads settings and changes nothing.'; | |
| return ['/usr/bin/osascript', ['-e', `do shell script "/bin/bash '${runner}'" with prompt "${prompt}" with administrator privileges`]]; | |
| } | |
| return ['pkexec', ['/bin/bash', runner]]; | |
| } | |
| ipcMain.handle('audit:run', async (event, { elevated, fix: fixRequest }) => { | |
| let fix = null; | |
| if (fixRequest) { | |
| if (!FIX_SCRIPT[OS_FAMILY]) throw new Error('Automatic fixes are not available on this system yet.'); | |
| const ids = autoFixIds(fixRequest.ids || []); | |
| if (!ids.length || !['apply', 'undo'].includes(fixRequest.action)) throw new Error('No fix to run.'); | |
| fix = { action: fixRequest.action, ids }; | |
| elevated = !FIX_DRY; | |
| } | |
| const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devseis-audit-')); | |
| fs.chmodSync(workDir, 0o755); | |
| const progressFile = path.join(workDir, 'progress.tsv'); | |
| fs.writeFileSync(progressFile, ''); | |
| fs.chmodSync(progressFile, 0o666); | |
| const [command, args] = collectorCommand(workDir, progressFile, elevated, fix); | |
| return new Promise((resolve, reject) => { | |
| let offset = 0; | |
| let done = null; | |
| const poll = () => { | |
| const text = fs.readFileSync(progressFile, 'utf8'); | |
| const fresh = text.slice(offset); | |
| const complete = fresh.lastIndexOf('\n') + 1; | |
| offset += complete; | |
| for (const line of fresh.slice(0, complete).split('\n').filter(Boolean)) { | |
| const [first, second, third] = line.split('\t'); | |
| if (first === 'DONE') done = { evidencePath: second, reportPath: third }; | |
| else event.sender.send('audit:progress', { id: first, status: second, name: third }); | |
| } | |
| }; | |
| const timer = setInterval(poll, 250); | |
| const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); | |
| let stderr = ''; | |
| child.stderr.on('data', chunk => { stderr += chunk; }); | |
| child.on('error', error => { clearInterval(timer); reject(new Error(`Could not start the audit: ${error.message}`)); }); | |
| child.on('close', code => { | |
| clearInterval(timer); | |
| poll(); | |
| if (!done) { | |
| const log = fs.existsSync(path.join(workDir, 'collector.log')) ? fs.readFileSync(path.join(workDir, 'collector.log'), 'utf8').slice(-600) : ''; | |
| const cancelled = /User canceled|cancelled|-128|Request dismissed|126/.test(stderr) || code === 126; | |
| reject(new Error(cancelled ? 'The administrator prompt was cancelled.' : `The audit did not finish (exit ${code}). ${stderr.slice(-300)} ${log}`.trim())); | |
| return; | |
| } | |
| let evidenceText = fs.readFileSync(done.evidencePath, 'utf8'); | |
| let evidence = JSON.parse(evidenceText.replace(/^/, '')); | |
| // Match the software inventory against the offline vulnerability data (on this computer only). | |
| const inventoryPath = done.evidencePath.replace(/\.json$/, '-inventory.json'); | |
| if (fs.existsSync(inventoryPath)) { | |
| try { | |
| const inventory = JSON.parse(fs.readFileSync(inventoryPath, 'utf8').replace(/^/, '')); | |
| const maxSevere = parseInt((evidence.baseline || {}).MAX_CRITICAL_HIGH_VULNERABILITIES || '0', 10) || 0; | |
| evidence = vulndb.apply(evidence, inventory, vulndb.load(vulndbDirs()), maxSevere); | |
| evidenceText = JSON.stringify(evidence, null, 2); | |
| const r = evidence.results.find(x => x.id === 'vuln.known_vulnerabilities'); | |
| if (r) event.sender.send('audit:progress', { id: r.id, status: r.status, name: r.name }); | |
| } catch (error) { | |
| console.warn(`Vulnerability matching skipped: ${error.message}`); | |
| } | |
| } | |
| let fixResults = null; | |
| if (fix) { | |
| // (a dry run is a test: it is reported but never recorded as something to undo) | |
| const raw = fs.existsSync(path.join(workDir, 'fix-results.tsv')) ? fs.readFileSync(path.join(workDir, 'fix-results.tsv'), 'utf8') : ''; | |
| fixResults = raw.split('\n').filter(l => l.startsWith('RESULT\t')).map(l => { const [, id, outcome, message] = l.split('\t'); return { id, outcome, message }; }); | |
| const history = FIX_DRY ? null : fixHistory(); | |
| if (history) history.push({ at: new Date().toISOString(), action: fix.action, results: fixResults }); | |
| if (history) try { fs.writeFileSync(fixHistoryFile(), JSON.stringify(history.slice(-50), null, 1)); } catch { /* history is a convenience */ } | |
| } | |
| resolve({ evidence, evidenceText, sha256: crypto.createHash('sha256').update(evidenceText).digest('hex'), workDir, fixResults }); | |
| }); | |
| }); | |
| }); | |
| // --- Saving the report ------------------------------------------------------------------ | |
| function defaultFolder(hostname) { | |
| const d = new Date(); | |
| const stamp = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}-` + | |
| `${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`; | |
| return path.join(SMOKE || app.getPath('documents'), 'Arx Reports', `${hostname.replace(/[^\w.-]/g, '_')}-${stamp}`); | |
| } | |
| async function renderPdf(html) { | |
| const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, javascript: false } }); | |
| await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); | |
| const pdf = await win.webContents.printToPDF({ printBackground: true, pageSize: 'A4', | |
| margins: { top: 0.5, bottom: 0.5, left: 0.5, right: 0.5 } }); | |
| win.destroy(); | |
| return pdf; | |
| } | |
| // mode: 'choose' opens a Save dialog; 'auto' saves straight to the Documents folder. | |
| ipcMain.handle('report:save', async (event, { html, evidenceText, hostname, mode }) => { | |
| const pdf = await renderPdf(html); | |
| let pdfPath; | |
| if (mode === 'choose') { | |
| const folder = defaultFolder(hostname); | |
| const result = await dialog.showSaveDialog(mainWindow, { | |
| title: 'Save the audit report', defaultPath: path.join(folder, 'audit-report.pdf'), | |
| filters: [{ name: 'PDF', extensions: ['pdf'] }], | |
| }); | |
| if (result.canceled || !result.filePath) return { saved: false }; | |
| pdfPath = result.filePath; | |
| } else { | |
| pdfPath = path.join(defaultFolder(hostname), 'audit-report.pdf'); | |
| } | |
| fs.mkdirSync(path.dirname(pdfPath), { recursive: true }); | |
| fs.writeFileSync(pdfPath, pdf); | |
| // Keep the machine-readable evidence next to the PDF (it is personal data: it stays on this computer). | |
| fs.writeFileSync(pdfPath.replace(/\.pdf$/i, '') + '-evidence.json', evidenceText); | |
| return { saved: true, pdfPath }; | |
| }); | |
| ipcMain.handle('report:show', (event, filePath) => shell.showItemInFolder(filePath)); | |
| // The Devseis security assistant (the Space's chat page) in an app window, with this audit attached. | |
| // The report is handed over inside the app (the page's local storage); the page never uploads it. | |
| const CHAT_URL = 'https://devseis-arx.static.hf.space/index.html'; | |
| let chatWindow = null; | |
| ipcMain.handle('chat:open', async (event, evidenceText) => { | |
| if (chatWindow && !chatWindow.isDestroyed()) chatWindow.close(); | |
| chatWindow = new BrowserWindow({ width: 1000, height: 820, minWidth: 420, minHeight: 560, title: 'Arx · report', | |
| webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } }); | |
| chatWindow.webContents.setWindowOpenHandler(({ url }) => (url.startsWith('blob:') ? { action: 'allow' } : (shell.openExternal(url), { action: 'deny' }))); | |
| try { | |
| await chatWindow.loadURL(CHAT_URL); | |
| await chatWindow.webContents.executeJavaScript(`localStorage.setItem('devseis.lastAudit', ${JSON.stringify(evidenceText)}); true`); | |
| await chatWindow.loadURL(`${CHAT_URL}?from=app#audit`); | |
| return { opened: true }; | |
| } catch (error) { | |
| if (!chatWindow.isDestroyed()) chatWindow.close(); | |
| return { opened: false, error: `The assistant needs an internet connection the first time (${error.message}).` }; | |
| } | |
| }); | |