diff --git a/apps/gnome-extension/jarvis@qvac.local/access-settings.js b/apps/gnome-extension/jarvis@qvac.local/access-settings.js new file mode 100644 index 0000000..0f740bc --- /dev/null +++ b/apps/gnome-extension/jarvis@qvac.local/access-settings.js @@ -0,0 +1,76 @@ +import Adw from 'gi://Adw'; +import Gtk from 'gi://Gtk'; +import GLib from 'gi://GLib'; +import { daemonCall } from './settings-editor.js'; + +function accessLabel(status, desktop = false) { + if (!status.active) return 'Off'; + if (desktop && status.steps_used >= status.steps_max) return 'Action limit reached · Save and Allow to renew'; + if (!status.backend || status.backend === 'none') return 'Waiting for permission or device…'; + const seconds = Math.max(0, Math.ceil((status.grant_expires_at - Date.now()) / 1000)); + return `${desktop ? (status.mode === 'observe' ? 'View only' : 'View and control') : 'On'} · ${Math.ceil(seconds / 60)} min left${desktop ? ` · ${Math.max(0, status.steps_max - status.steps_used)} actions left` : ''}`; +} + +export function accessSettingsPage(editor, window) { + const intro = new Adw.PreferencesGroup({ title: 'Give the agent access', description: 'Choose desktop and camera access below, then Save and Allow. This saves your settings and starts temporary access in one step. File and vault permissions stay saved until you change them.' }); + const row = new Adw.ActionRow({ title: 'Apply access choices', subtitle: 'Save only updates settings. Save and Allow also starts temporary access.', use_markup: false }); + editor.statusRows.push(row); + const allow = new Gtk.Button({ label: 'Save & Allow', valign: Gtk.Align.CENTER }); allow.add_css_class('suggested-action'); + const stop = new Gtk.Button({ label: 'Stop desktop & camera', valign: Gtk.Align.CENTER }); stop.add_css_class('destructive-action'); + const save = new Gtk.Button({ label: 'Save only', valign: Gtk.Align.CENTER }); + row.add_suffix(save); row.add_suffix(allow); intro.add(row); + const live = new Adw.ActionRow({ title: 'Current access', subtitle: 'Connecting…', use_markup: false }); live.add_suffix(stop); intro.add(live); + let refreshing = false; + let generation = 0; + const refresh = async () => { + if (refreshing) return; + refreshing = true; + try { + const results = await Promise.all([daemonCall('ComputerStatus'), daemonCall('WebcamStatus')]); + live.subtitle = `Desktop: ${accessLabel(JSON.parse(results[0][0]), true)}\nCamera: ${accessLabel(JSON.parse(results[1][0]))}`; + } catch (error) { live.subtitle = `Agent unavailable: ${error.message}`; } + finally { refreshing = false; } + }; + save.connect('clicked', async () => { + save.sensitive = false; allow.sensitive = false; + try { await editor.apply(); row.subtitle = editor.message; } + finally { save.sensitive = true; allow.sensitive = true; await refresh(); } + }); + allow.connect('clicked', async () => { + const current = ++generation; + const selected = { desktop: editor.values.computerMode !== 'off', camera: editor.values.webcamEnabled }; + allow.sensitive = false; save.sensitive = false; + try { + row.subtitle = 'Saving and applying access choices…'; + if (!await editor.apply()) throw new Error(editor.message || 'Settings could not be applied'); + if (current !== generation) return; + const calls = []; + if (selected.desktop) calls.push(['Desktop', daemonCall('ComputerGrant', '(b)', [false])]); + else calls.push(['Desktop', daemonCall('ComputerRevoke')]); + if (selected.camera) calls.push(['Camera', daemonCall('WebcamGrant')]); + else calls.push(['Camera', daemonCall('WebcamRevoke')]); + const results = await Promise.allSettled(calls.map(([, call]) => call)); + if (current !== generation) return; + const errors = results.flatMap((result, index) => result.status === 'rejected' ? [`${calls[index][0]}: ${result.reason.message}`] : []); + row.subtitle = errors.length ? errors.join(' · ') : !selected.desktop && !selected.camera ? 'Settings saved. Desktop and camera are off.' : 'Access requested. Complete any GNOME prompt; current access is shown below.'; + } catch (error) { if (current === generation) row.subtitle = error.message; } + finally { allow.sensitive = true; save.sensitive = true; await refresh(); } + }); + stop.connect('clicked', async () => { + generation++; + stop.sensitive = false; + try { + const results = await Promise.allSettled([daemonCall('ComputerRevoke'), daemonCall('WebcamRevoke')]); + const errors = results.filter(r => r.status === 'rejected').map(r => r.reason.message); + row.subtitle = errors.length ? `Could not stop all access: ${errors.join(' · ')}` : 'Desktop and camera stopped. Saved file and vault permissions are unchanged.'; + } finally { stop.sensitive = true; await refresh(); } + }); + const page = editor.page(window, 'Access', ['Temporary access', 'Files and memory', 'Access limits'], 'security-high-symbolic', false, intro, { controls: false }); + let timer = 0; + page.connect('map', () => { + refresh(); + if (!timer) timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 2000, () => { refresh(); return GLib.SOURCE_CONTINUE; }); + }); + page.connect('unmap', () => { if (timer) GLib.Source.remove(timer); timer = 0; }); + return page; +} diff --git a/apps/gnome-extension/jarvis@qvac.local/obsidian-settings.js b/apps/gnome-extension/jarvis@qvac.local/obsidian-settings.js index baab684..05e18d3 100644 --- a/apps/gnome-extension/jarvis@qvac.local/obsidian-settings.js +++ b/apps/gnome-extension/jarvis@qvac.local/obsidian-settings.js @@ -4,7 +4,7 @@ import Gtk from 'gi://Gtk'; import { daemonCall } from './settings-editor.js'; export function obsidianSettingsPage(editor, window) { - const page = editor.page(window, 'Obsidian', ['Obsidian vault', 'Obsidian memory'], 'folder-documents-symbolic'); + const page = editor.page(window, 'Obsidian', ['Obsidian vault'], 'folder-documents-symbolic'); const pathRow = editor.rows.get('obsidianVaultPath'); const choose = new Gtk.Button({ label: 'Choose folder…', valign: Gtk.Align.CENTER }); choose.connect('clicked', () => { @@ -19,7 +19,7 @@ export function obsidianSettingsPage(editor, window) { dialog.show(); }); pathRow.add_suffix(choose); - const group = new Adw.PreferencesGroup({ title: 'Configuration and access checks', description: 'Apply the settings above first. Initialize accepts only an empty folder or an existing Jarvis agent vault. Register the folder once using Open folder as vault in Obsidian.' }); + const group = new Adw.PreferencesGroup({ title: 'Configuration and access checks', description: 'Enable vault and memory access on the Access page and save first. Initialize accepts only an empty folder or an existing Jarvis agent vault. Register the folder once using Open folder as vault in Obsidian.' }); const state = new Adw.ActionRow({ title: 'Bridge status', subtitle: 'Refresh to inspect the applied configuration.' }); const buttons = []; const run = async args => { diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json b/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json index cbf113b..90eff0c 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json +++ b/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json @@ -627,10 +627,10 @@ { "key": "computerMode", "title": "Desktop access mode", - "group": "Desktop access", + "group": "Temporary access", "default": "act", "type": "choice", - "description": "A temporary Allow now grant is always required. Changing this revokes existing access.", + "description": "Choose what Save and Allow grants to the desktop. GNOME may ask you to choose a screen.", "options": [ { "value": "off", @@ -652,7 +652,7 @@ { "key": "computerSteps", "title": "Actions per grant", - "group": "Desktop access", + "group": "Access limits", "default": 20, "type": "number", "description": "Maximum input actions before another grant is needed.", @@ -666,7 +666,7 @@ { "key": "computerGrantMinutes", "title": "Grant duration (minutes)", - "group": "Desktop access", + "group": "Access limits", "default": 3, "type": "number", "description": "Desktop access expires automatically.", @@ -698,11 +698,11 @@ }, { "key": "webcamEnabled", - "title": "Camera access", - "group": "Camera", + "title": "Include camera", + "group": "Temporary access", "default": false, "type": "boolean", - "description": "Off by default. Press Allow now to start a temporary camera grant. Apply this switch if you want the feature remembered." + "description": "Also grant camera access when you press Save and Allow. Off by default." }, { "key": "webcamDevice", @@ -716,7 +716,7 @@ { "key": "webcamGrantMinutes", "title": "Camera grant duration (minutes)", - "group": "Camera", + "group": "Access limits", "default": 3, "type": "number", "description": "Camera access expires automatically. Changing this revokes an active grant.", @@ -899,10 +899,10 @@ { "key": "fsAccess", "title": "File access", - "group": "Agent limits", + "group": "Files and memory", "default": "workspace", "type": "choice", - "description": "Requires restart. The agent still runs as your user account and asks before writing. Shell commands can already reach other paths.", + "description": "Applies immediately and resets the agent conversation. The agent still asks before writing. Shell commands run as your user and can reach other paths.", "options": [ { "value": "workspace", @@ -920,15 +920,15 @@ "aliases": [ "fs_access" ], - "restart": true + "restart": false }, { "key": "obsidianEnabled", "title": "Enable Obsidian bridge", - "group": "Obsidian vault", + "group": "Files and memory", "type": "boolean", "default": false, - "description": "Opt in to native JavaScript access to a dedicated agent vault. Apply before initializing or verifying. No plugin, API key, or running Obsidian app is required." + "description": "Allow the dedicated agent vault. Configure its folder and initialize it on the Obsidian page." }, { "key": "obsidianVaultPath", @@ -941,7 +941,7 @@ { "key": "obsidianMemoryEnabled", "title": "Use vault for agent memory", - "group": "Obsidian memory", + "group": "Files and memory", "type": "boolean", "default": false, "description": "When the bridge is enabled, use memory/*.md in the agent vault for durable memories. Existing workspace memory is retained; it is not copied or deleted automatically." diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-editor.js b/apps/gnome-extension/jarvis@qvac.local/settings-editor.js index d08f37b..0176877 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-editor.js +++ b/apps/gnome-extension/jarvis@qvac.local/settings-editor.js @@ -185,11 +185,11 @@ export class SettingsEditor { button.connect('clicked', () => { for (const field of fields) { this.setValue(field, field.default); this.rows.get(field.key)?._setValue(field.default); } }); reset.add_suffix(button); group.add(reset); page.add(group); } - page(parent, title, groups, iconName, preview = false, intro = null) { + page(parent, title, groups, iconName, preview = false, intro = null, { controls = true } = {}) { const page = new Adw.PreferencesPage({ title, icon_name: iconName }); if (intro) page.add(intro); const fields = this.fields.filter(field => groups.includes(field.group)); - this.controls(page, fields, { preview }); + if (controls) this.controls(page, fields, { preview }); for (const name of groups) { const group = new Adw.PreferencesGroup({ title: name }); for (const field of fields.filter(item => item.group === name)) { @@ -236,7 +236,7 @@ export class SettingsEditor { finally { this.busy = false; this.refreshVisibility(); } } async apply(preview = false) { - if (this.busy) return; + if (this.busy) return false; this.busy = true; for (const button of this.applyButtons) button.sensitive = false; this.refreshVisibility(); try { if (this.loadError) throw new Error(`Fix config.json before saving: ${this.loadError}`); @@ -249,10 +249,11 @@ export class SettingsEditor { const errors = Object.entries(status.voice?.errors || {}).filter(([key]) => key !== 'tts' || validated.ttsEnabled).map(([key, message]) => `${key}: ${message}`); const restart = status.restartRequired?.length ? ' Restart Jarvis for chat model / agent changes.' : ''; const overrides = status.environmentOverrides?.length ? ` Environment overrides: ${status.environmentOverrides.join(', ')}.` : ''; - if (errors.length) { this.status(`Applied.${restart}${overrides} ${errors.join(' · ')}`); return; } + if (errors.length) { this.status(`Applied.${restart}${overrides} ${errors.join(' · ')}`); return true; } if (preview) { this.status('Playing your voice preview…'); await daemonCall('PreviewVoice', '(s)', [validated.previewText]); } this.status(`Applied.${restart}${overrides}${preview ? ' Preview finished.' : ''}`); - } catch (error) { this.status(error.message); } + return true; + } catch (error) { this.status(error.message); return false; } finally { this.busy = false; for (const button of this.applyButtons) button.sensitive = true; this.refreshVisibility(); } } } diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-window.js b/apps/gnome-extension/jarvis@qvac.local/settings-window.js index dd47b03..e01c802 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-window.js +++ b/apps/gnome-extension/jarvis@qvac.local/settings-window.js @@ -1,15 +1,11 @@ +import { accessSettingsPage } from './access-settings.js'; import { obsidianSettingsPage } from './obsidian-settings.js'; import Adw from 'gi://Adw'; -import Gio from 'gi://Gio'; -import GLib from 'gi://GLib'; import Gtk from 'gi://Gtk'; import Gdk from 'gi://Gdk'; import { SettingsEditor } from './settings-editor.js'; import { BRAND, normalizeAccent } from './brand.js'; -const BUS = 'io.qvac.Jarvis'; -const PATH = '/io/qvac/Jarvis'; -const IFACE = 'io.qvac.Jarvis.Session'; const OVERLAY_STYLES = ['tray', 'expanded']; function applyBrandCss(directory) { @@ -83,125 +79,6 @@ function comboRow(settings, title, subtitle, key, values) { return row; } -function callDaemon(name, signature, values, onDone) { - Gio.DBus.session.call( - BUS, - PATH, - IFACE, - name, - signature ? GLib.Variant.new(signature, values) : null, - null, - Gio.DBusCallFlags.NONE, - 4000, - null, - (_source, result) => { - try { - const reply = Gio.DBus.session.call_finish(result); - onDone?.(null, reply); - } catch (error) { - onDone?.(error); - } - }, - ); -} - -function grantRow() { - const row = new Adw.ActionRow({ - title: 'Desktop grant', - subtitle: 'Jarvis needs a temporary grant before observing or controlling the desktop.', - }); - const allow = new Gtk.Button({ label: 'Allow now', valign: Gtk.Align.CENTER }); - allow.add_css_class('suggested-action'); - const revoke = new Gtk.Button({ label: 'Revoke', valign: Gtk.Align.CENTER }); - revoke.add_css_class('destructive-action'); - const refresh = () => { - callDaemon('ComputerStatus', null, null, (error, reply) => { - if (error) { - row.subtitle = 'Jarvis daemon is unavailable. Start jarvisd, then try Allow now.'; - return; - } - let status = {}; - try { - const unpacked = reply.deep_unpack?.() ?? reply.unpack?.(); - const raw = Array.isArray(unpacked) ? unpacked[0] : unpacked; - status = JSON.parse(String(raw || '{}')); - } catch {} - if (status.active) { - row.subtitle = `Active. ${Number(status.steps_used) || 0} of ${Number(status.steps_max) || 20} steps used.`; - } else { - row.subtitle = 'Off. Press Allow now so Jarvis can observe or control the desktop for the configured grant duration.'; - } - }); - }; - allow.connect('clicked', () => { - callDaemon('ComputerGrant', '(b)', [false], (error) => { - row.subtitle = error ? `Could not grant: ${error.message}` : 'Grant requested. Choose a screen in the GNOME prompt.'; - refresh(); - }); - }); - revoke.connect('clicked', () => { - callDaemon('ComputerRevoke', null, null, (error) => { - row.subtitle = error ? `Could not revoke: ${error.message}` : 'Grant revoked.'; - refresh(); - }); - }); - row.add_suffix(allow); - row.add_suffix(revoke); - row.activatable_widget = allow; - refresh(); - return row; -} - -function cameraGrantRow() { - const row = new Adw.ActionRow({ - title: 'Camera grant', - subtitle: 'Press Allow now so Jarvis can capture a webcam still. This is not desktop ScreenCast.', - }); - const allow = new Gtk.Button({ label: 'Allow now', valign: Gtk.Align.CENTER }); - allow.add_css_class('suggested-action'); - const revoke = new Gtk.Button({ label: 'Revoke', valign: Gtk.Align.CENTER }); - revoke.add_css_class('destructive-action'); - const refresh = () => { - callDaemon('WebcamStatus', null, null, (error, reply) => { - if (error) { - row.subtitle = 'Jarvis daemon is unavailable. Start jarvisd, then try Allow now.'; - return; - } - let status = {}; - try { - const unpacked = reply.deep_unpack?.() ?? reply.unpack?.(); - const raw = Array.isArray(unpacked) ? unpacked[0] : unpacked; - status = JSON.parse(String(raw || '{}')); - } catch {} - if (status.active) { - row.subtitle = 'Active. Jarvis can capture one webcam still per webcam tool call until this grant expires.'; - } else if (!status.enabled) { - row.subtitle = 'Off. Press Allow now to start a camera grant, or enable Camera access and Apply to keep the feature on.'; - } else { - row.subtitle = 'Enabled. Press Allow now so Jarvis can use the webcam for the configured grant duration.'; - } - }); - }; - allow.connect('clicked', () => { - callDaemon('WebcamGrant', null, null, (error) => { - row.subtitle = error ? `Could not grant: ${error.message}` : 'Grant requested. Jarvis can use the camera until you revoke it or the timer ends.'; - refresh(); - GLib.timeout_add(GLib.PRIORITY_DEFAULT, 700, () => { refresh(); return GLib.SOURCE_REMOVE; }); - }); - }); - revoke.connect('clicked', () => { - callDaemon('WebcamRevoke', null, null, (error) => { - row.subtitle = error ? `Could not revoke: ${error.message}` : 'Camera grant revoked.'; - refresh(); - }); - }); - row.add_suffix(allow); - row.add_suffix(revoke); - row.activatable_widget = allow; - refresh(); - return row; -} - export function fillSettingsWindow(window, settings, directory) { window.set_title(BRAND.product); window.set_default_size(840, 780); @@ -215,16 +92,12 @@ export function fillSettingsWindow(window, settings, directory) { const listening = editor.page(window, 'Listening', ['Listening', 'Wake and privacy', 'Detection tuning', 'Audio routing'], 'audio-input-microphone-symbolic'); window.add(listening); - const desktop = editor.page(window, 'Desktop', ['Desktop access', 'Desktop images', 'Camera'], 'preferences-desktop-display-symbolic'); + const desktop = editor.page(window, 'Desktop', ['Desktop images', 'Camera'], 'preferences-desktop-display-symbolic'); const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts', description: 'These appearance settings take effect immediately.' }); desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey')); desktopGroup.add(accentRow(settings)); desktopGroup.add(comboRow(settings, 'Desktop layout', 'Tray keeps Jarvis in the top bar; expanded opens a conversation panel', 'overlay-style', OVERLAY_STYLES)); desktop.add(desktopGroup); - const grantGroup = new Adw.PreferencesGroup({ title: 'Temporary desktop access' }); - grantGroup.add(grantRow()); desktop.add(grantGroup); - const cameraGroup = new Adw.PreferencesGroup({ title: 'Temporary camera access' }); - cameraGroup.add(cameraGrantRow()); desktop.add(cameraGroup); window.add(desktop); const models = editor.page(window, 'Models', ['Chat model', 'Agent limits'], 'system-run-symbolic'); const service = new Adw.PreferencesGroup({ title: 'Apply chat model changes' }); @@ -233,7 +106,9 @@ export function fillSettingsWindow(window, settings, directory) { restartButton.connect('clicked', async () => { restartButton.sensitive = false; try { await editor.restart(); } finally { restartButton.sensitive = true; } }); restart.add_suffix(restartButton); service.add(restart); models.add(service); window.add(models); + const access = accessSettingsPage(editor, window); + window.add(access); const obsidian = obsidianSettingsPage(editor, window); window.add(obsidian); - return { editor, pages: [voice, listening, desktop, models, obsidian] }; + return { editor, pages: [voice, listening, desktop, models, access, obsidian] }; } diff --git a/daemon/harness-bridge.js b/daemon/harness-bridge.js index 0424c98..dace415 100644 --- a/daemon/harness-bridge.js +++ b/daemon/harness-bridge.js @@ -29,6 +29,7 @@ export class HarnessBridge extends EventEmitter { const assistantName = normalizeAssistantName(settings.assistantName); const workspace = cwd || ensureAgentWorkspace({ name: assistantName, prompt: settings.assistantPrompt }); const roots = harnessRoots(access); + this.filesystemComputer = computer; this.assistantName = assistantName; this.assistantPrompt = settings.assistantPrompt; this.options = { @@ -62,6 +63,13 @@ export class HarnessBridge extends EventEmitter { this.configureObsidian(settings); } + configureFilesystem(access) { + const tools = createPhase2Tools({ cwd: this.options.cwd, computer: this.filesystemComputer, roots: filesystemRoots(access, this.options.cwd) }); + const names = new Set(tools.map(tool => tool.name)); + this.options.tools = this.options.tools.filter(tool => !names.has(tool.name)).concat(tools); + this.options.roots = harnessRoots(access); + } + configureObsidian(settings) { this.obsidian.configure(settings); this.options.tools = this.options.tools.filter(tool => tool.name !== 'obsidian').concat(createObsidianTools(this.obsidian)); diff --git a/daemon/index.js b/daemon/index.js index a5e2e11..c9f938b 100644 --- a/daemon/index.js +++ b/daemon/index.js @@ -252,6 +252,7 @@ export class JarvisDaemon extends EventEmitter { this.camera.setBackend(payload.via || 'portal'); }).catch((error) => { if (generation !== this._webcamGeneration) return; + this.webcamRevoke(); this.emit('Error', 'WEBCAM_GRANT', error.message); }); return result; @@ -304,6 +305,10 @@ export class JarvisDaemon extends EventEmitter { await this.voiceLoop?.stop?.(); this.voiceLoop = null; this.settings = next; + if (next.fsAccess !== previous.fsAccess) { + await this.harness.resetContext(); + this.harness.configureFilesystem(next.fsAccess); + } if (['obsidianEnabled', 'obsidianVaultPath', 'obsidianMemoryEnabled'].some(key => next[key] !== previous[key])) { await this.harness.resetContext(); this.harness.configureObsidian(next); @@ -329,7 +334,7 @@ export class JarvisDaemon extends EventEmitter { Object.assign(this.webcamNormalizer, { maxLongEdge: next.webcamMaxEdge, quality: next.screenshotQuality }); await this.startVoice(); this.voice.cancel(); this.setState('ARMED'); - return JSON.stringify({ applied: true, voice: this.voiceLoop.status, environmentOverrides: ['JARVIS_TTS_MODEL', 'JARVIS_ASR_MODEL', 'JARVIS_WAKE_COMMAND', 'JARVIS_QVAC_MODEL', 'GROQ_API_KEY', 'JARVIS_GROQ_API_KEY'].filter(key => process.env[key]), restartRequired: ['chatModel', 'modelProfile', 'maxTurns', 'maxShellCalls', 'maxToolRounds', 'fsAccess'].filter(key => next[key] !== this.startupSettings[key]) }); + return JSON.stringify({ applied: true, voice: this.voiceLoop.status, environmentOverrides: ['JARVIS_TTS_MODEL', 'JARVIS_ASR_MODEL', 'JARVIS_WAKE_COMMAND', 'JARVIS_QVAC_MODEL', 'GROQ_API_KEY', 'JARVIS_GROQ_API_KEY'].filter(key => process.env[key]), restartRequired: ['chatModel', 'modelProfile', 'maxTurns', 'maxShellCalls', 'maxToolRounds'].filter(key => next[key] !== this.startupSettings[key]) }); } async previewVoice(text) { if (this.locked) throw new Error('Unlock the desktop to preview a voice'); diff --git a/docs/obsidian.md b/docs/obsidian.md index ba353ed..83a6158 100644 --- a/docs/obsidian.md +++ b/docs/obsidian.md @@ -7,11 +7,12 @@ Obsidian uses [local folders of Markdown files](https://obsidian.md/help/data-st ## Setup -1. Open Jarvis Settings → Obsidian. -2. Enable the bridge. Choose an empty folder or enter an absolute path to a new +1. Open Jarvis Settings → Access. Enable the Obsidian bridge and optionally + vault memory, then **Save only**. +2. On Obsidian, choose an empty folder or enter an absolute path to a new folder. A blank path uses `$XDG_DATA_HOME/jarvis/obsidian-agent`, normally `~/.local/share/jarvis/obsidian-agent`. -3. Optionally enable **Use vault for agent memory**. Press **Apply**. +3. Press **Apply** to save the vault folder. 4. Press **Initialize vault**, then **Verify access**. Verification performs an actual temporary write/read/delete in the vault and separately in `memory/` when memory access is enabled. Refresh shows applied configuration and access. diff --git a/docs/settings.md b/docs/settings.md index 8d711eb..4c9769a 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -7,6 +7,28 @@ HoneyPeer, LLC**. The standalone Control Center or the installed **Jarvis** launcher) uses the same pages. Search is available in the window header. +## Agent access + +Open **Access** to choose desktop mode, camera access, file scope, and Obsidian +vault/memory permissions in one place. + +- **Save & Allow** saves the form, applies it, and grants the selected desktop + and camera access. Complete the GNOME screen/permission prompt if shown. +- **Save only** applies settings without starting a new temporary grant. +- **Stop desktop & camera** revokes both temporary grants, including a pending + request. Saved file and vault permissions remain as selected. + +The live status shows whether each grant is off, waiting for permission, or on, +with time and desktop actions remaining. Renew with **Save & Allow**. Camera is +excluded by default. Duration and action budgets are under **Access limits**. +Device and image-quality choices remain on **Desktop**; vault initialization, +verification, and browsing remain on **Obsidian**. + +File access changes now apply without a service restart and reset the agent +conversation. File scope governs file tools; shell commands still run with the +user account's privileges. Disable the vault or its memory switch and save to +remove that access. Obsidian stays opt-in. + ## Choosing a voice 1. On **Voice**, enable **Spoken replies** and choose a speech model. @@ -211,7 +233,8 @@ selected model; the automated schema checks do not establish those results. ## Optional Obsidian vault -The **Obsidian** page configures an agent-only vault, disabled by default. It +The **Access** page enables the agent-only vault and its memory, both disabled +by default. The **Obsidian** page configures the vault folder. It includes folder selection, initialization, actual read/write and memory probes, note browsing/search, and opening the vault in Obsidian. See [Obsidian setup and bridge operations](obsidian.md). Applying these settings diff --git a/scripts/smoke-settings-ui.js b/scripts/smoke-settings-ui.js index c7c6590..9e66e50 100644 --- a/scripts/smoke-settings-ui.js +++ b/scripts/smoke-settings-ui.js @@ -11,7 +11,7 @@ app.connect('activate', () => { const settings = new Gio.Settings({ settings_schema: source.lookup('org.gnome.shell.extensions.jarvis', true) }); const window = new Adw.PreferencesWindow({ application: app }); const { editor, pages } = fillSettingsWindow(window, settings, directory); - const snapshots = ['voice', 'listening', 'desktop', 'models', 'obsidian']; + const snapshots = ['voice', 'listening', 'desktop', 'models', 'access', 'obsidian']; let index = 0; window.present(); GLib.timeout_add(GLib.PRIORITY_DEFAULT, 700, () => { diff --git a/test/daemon.test.js b/test/daemon.test.js index 5964a14..30778fb 100644 --- a/test/daemon.test.js +++ b/test/daemon.test.js @@ -287,14 +287,15 @@ test('camera Allow now starts a grant even when Camera access is off', async () } finally { await daemon.close(); } }); -test('a failed camera portal does not revoke an Allow now grant', async () => { +test('a failed camera permission request revokes the grant instead of showing active access', async () => { const daemon = new JarvisDaemon(); try { daemon.webcam = { access: async () => { throw new Error('Camera portal Access response 2'); } }; daemon.webcamGrant(); await Promise.resolve(); await Promise.resolve(); - assert.equal(daemon.camera.status().active, true); + assert.equal(daemon.camera.status().active, false); + assert.equal(daemon.camera.status().backend, 'none'); } finally { await daemon.close(); } }); diff --git a/test/settings.test.js b/test/settings.test.js index 482e110..402b0bf 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -237,6 +237,33 @@ test('Obsidian Apply replaces the tool and memory selection, resets context, and } }); +test('file access applies live without a service restart', async () => { + const previous = process.env.XDG_CONFIG_HOME; + const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-file-access-')); + process.env.XDG_CONFIG_HOME = dir; await mkdir(path.join(dir, 'jarvis')); + const daemon = new JarvisDaemon(); let resets = 0; + daemon.harness.resetContext = async () => { resets++; }; + daemon.setState = state => { daemon.state = state; }; + daemon.startVoice = async () => { daemon.voiceLoop = { stop: async () => {}, status: { errors: {} } }; }; + daemon.voiceLoop = { stop: async () => {} }; + try { + const config = path.join(dir, 'jarvis/config.json'); + await writeFile(config, JSON.stringify({ fsAccess: 'filesystem' })); + const result = JSON.parse(await daemon.reloadSettings()); + assert.equal(resets, 1); assert.deepEqual(daemon.harness.options.roots, ['/']); + assert.equal(result.restartRequired.includes('fsAccess'), false); + const names = daemon.harness.options.tools.map(t => t.name); + assert.equal(new Set(names).size, names.length); + await writeFile(config, JSON.stringify({ fsAccess: 'workspace' })); + await daemon.reloadSettings(); + assert.equal(resets, 2); assert.deepEqual(daemon.harness.options.roots, []); + } finally { + clearInterval(daemon._idleTimer); clearInterval(daemon._telemetryTimer); + if (previous === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous; + await rm(dir, { recursive: true, force: true }); + } +}); + test('voice preview uses selected text without changing chat history or the repeat reply', async () => { const daemon = new JarvisDaemon(); const speech = []; const replies = []; daemon.setState = state => { daemon.state = state; };