diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 6b13add1..9609acd8 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -200,6 +200,37 @@ omitted — agents share the host netns, so there is no per-container net counter (per-agent network needs the netns-isolation roadmap in `docs/network.md`). +## M4TR1X ACC0UNTS page (`/matrix-accounts.html`) + +Operator surface to provision / log in a per-agent **external** matrix +account and store its access token, without editing the agent's config +repo. Standalone page reached from the **Matrix accounts** tile on the +H0M3 hub, same minimal chrome as `/core.html` (a `← home` back-link + +title). Its own esbuild bundle (`matrix-accounts.js`); no SSE — it reads +`/api/state` once for the agent picker and otherwise works off two +purpose-built endpoints. + +An agent picker (populated from `state.agents`) drives a list of that +agent's configured accounts — name, homeserver, and a token-status dot — +read from `GET /matrix-accounts?agent=` → +`{ accounts: [ { name, homeserver, token_present } ] }`. The status +reflects only whether a token is **stored** (labelled "token stored", +not "online"); a true live up/down indicator needs the matrix daemon's +account registry and is a follow-up. + +The provision form (account name, homeserver, login method) posts +`POST /matrix-account-login` (`x-www-form-urlencoded`, operator-auth): +fields `agent, account, homeserver, mode=password|token, user_id?, +password?, token?` → `2xx { ok, user_id }` on success or +`4xx { error }` on failure. The host coordinator performs the login +(password) or validates the token (`whoami`) and writes the bearer to +the agent's `matrixAccounts..tokenFile` via the same +privileged write path as the hive-internal `matrix-token`; the token is +**never** echoed back, and the page clears the secret inputs on submit +regardless of outcome. Because a bad credential can currently disturb +the agent's whole matrix session until per-account failure isolation +lands on the daemon, the form carries an explicit experimental notice. + ## P3RM1SS10NS tab Per-agent permission configuration. Two sections, each rendered as a diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 6814cd62..551360d6 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -55,7 +55,7 @@ mkdirSync(staticDir(''), { recursive: true }); // follow-up once asset sizes warrant it). esbuild writes each entry // to `static/.js` based on the entryPoint basename. await build({ - entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js')], + entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js'), src('core.js'), src('matrix-accounts.js')], outdir: staticDir(''), bundle: true, format: 'esm', @@ -94,7 +94,7 @@ await build({ // so a swap replaces only it) + theme.css (the semantic derivation // layer) + common.css (shared typography, badges, buttons, inbox, side // panel) plus its own page-specific bundle. -for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css']) { +for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css', 'core.css', 'matrix-accounts.css']) { await build({ entryPoints: [src(entry)], outfile: staticDir(entry), @@ -104,7 +104,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', ' }); } -for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html']) { +for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html', 'core.html', 'matrix-accounts.html']) { copyFileSync(src(html), dist(html)); } diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html index 5c3c5932..2c7ad188 100644 --- a/frontend/packages/dashboard/src/index.html +++ b/frontend/packages/dashboard/src/index.html @@ -76,6 +76,14 @@ rebuild queue · meta inputs · kept state · container load + + + + Matrix accounts + + provision · log in · store per-agent matrix tokens + + + + +
+

provision or log in an external matrix account for an agent and store its access token. the token is written to the agent's matrixAccounts.<account>.tokenFile by the host coordinator — it is never displayed back on this page.

+ +
+ ⚠ experimental. a wrong password or token entered here can currently disrupt the target agent's whole matrix session until per-account failure isolation lands on the daemon. use with care on a live agent. +
+ +

◇ agent

+ + +

◇ configured accounts

+

status reflects whether a token is stored, not a live session — a true online/offline indicator is a follow-up that needs the daemon's account registry.

+

select an agent to see its matrix accounts.

+ +

◇ provision / log in

+
+ + + +
+ login method + + +
+ +
+ + +
+ + + + +

+
+
+ + + + diff --git a/frontend/packages/dashboard/src/matrix-accounts.js b/frontend/packages/dashboard/src/matrix-accounts.js new file mode 100644 index 00000000..d9c1e053 --- /dev/null +++ b/frontend/packages/dashboard/src/matrix-accounts.js @@ -0,0 +1,180 @@ +// M4TR1X ACC0UNTS page entry (/matrix-accounts.html). +// +// Operator surface to provision / log in a per-agent EXTERNAL matrix +// account and store its access token, without editing the agent's config +// repo. Companion to the multi-account harness support. +// +// Backend contract (v1): +// GET /matrix-accounts?agent= +// -> { accounts: [ { name, homeserver, token_present: bool } ] } +// POST /matrix-account-login (x-www-form-urlencoded, operator-auth) +// fields: agent, account, homeserver, mode=password|token, +// user_id?, password?, token? +// -> 2xx { ok: true, user_id } on success +// -> 4xx { error: "" } on failure +// The token is NEVER echoed back in any response, and this page never +// re-renders a submitted secret. +// +// Live up/down (a true green/red dot) needs the daemon's account +// registry; until that follow-up lands the dot only reflects whether a +// token is STORED, labelled "token stored" rather than "online". +// +// Hard dependency: per-account failure isolation on the matrix daemon. +// Until that lands a bad credential entered here can crash the agent's +// whole matrix session, so the form carries an explicit experimental +// notice in the markup. + +import { $, el, esc, renderServerWarnings } from './common.js'; + +let agents = []; + +async function loadState() { + try { + const resp = await fetch('/api/state'); + if (!resp.ok) return; + const s = await resp.json(); + renderServerWarnings(s.server_warnings); + agents = (s.agents || []) + .map((a) => (typeof a === 'string' ? a : a && a.name)) + .filter(Boolean) + .sort(); + } catch { + // best-effort: on a failed state read the picker renders empty + // ("— no agents —") and the submit guard blocks until an agent is + // selected, rather than guessing a roster. + } +} + +function renderAgentPicker() { + const sel = $('ma-agent'); + sel.replaceChildren(); + if (!agents.length) { + sel.append(el('option', { value: '' }, '— no agents —')); + return; + } + sel.append(el('option', { value: '' }, '— select agent —')); + for (const a of agents) sel.append(el('option', { value: a }, a)); +} + +async function loadAccounts(agent) { + const list = $('ma-list'); + if (!agent) { + list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its matrix accounts.')); + return; + } + list.replaceChildren(el('p', { class: 'meta' }, 'loading…')); + let data; + try { + const resp = await fetch('/matrix-accounts?agent=' + encodeURIComponent(agent)); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + data = await resp.json(); + } catch (err) { + list.replaceChildren(el('p', { class: 'err' }, + 'could not load accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).')); + return; + } + const accounts = data.accounts || []; + list.replaceChildren(); + if (!accounts.length) { + list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.')); + return; + } + const ul = el('ul', { class: 'ma-accounts' }); + for (const acc of accounts) { + const present = !!acc.token_present; + ul.append(el('li', { class: 'ma-account' }, + el('span', { + class: 'ma-dot ' + (present ? 'ok' : 'absent'), + title: present ? 'token stored' : 'no token yet', + }), + el('span', { class: 'ma-name' }, acc.name || '(unnamed)'), + el('span', { class: 'ma-hs' }, acc.homeserver || '—'), + el('span', { class: 'ma-status ' + (present ? 'ok' : 'absent') }, + present ? 'token stored ✓' : 'no token'), + )); + } + list.append(ul); +} + +// Show only the fields for the selected login method, and DISABLE the +// hidden section's inputs so they don't ride along in the FormData (both +// sections carry a `user_id` field, so without this the wrong one — or +// both — would be submitted). +function toggleModeFields() { + const mode = document.querySelector('input[name="mode"]:checked'); + const value = mode ? mode.value : 'password'; + const pw = $('ma-pw-fields'); + const tok = $('ma-token-fields'); + pw.hidden = value !== 'password'; + tok.hidden = value !== 'token'; + pw.querySelectorAll('input').forEach((i) => { i.disabled = pw.hidden; }); + tok.querySelectorAll('input').forEach((i) => { i.disabled = tok.hidden; }); +} + +function clearSecrets(formEl) { + formEl.querySelectorAll('input[type="password"], input[name="token"]') + .forEach((i) => { i.value = ''; }); +} + +async function submitLogin(e) { + e.preventDefault(); + const formEl = e.target; + const out = $('ma-result'); + out.className = 'ma-result'; + out.textContent = ''; + + const agent = $('ma-agent').value; + if (!agent) { + out.className = 'ma-result err'; + out.textContent = 'select an agent first.'; + return; + } + + const fd = new FormData(formEl); + fd.set('agent', agent); + + const btn = formEl.querySelector('button[type="submit"]'); + const orig = btn.textContent; + btn.disabled = true; + btn.textContent = 'logging in…'; + + try { + const resp = await fetch('/matrix-account-login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(fd), + }); + let body = {}; + try { body = await resp.json(); } catch { /* tolerate non-JSON error pages */ } + + if (resp.ok && body.ok) { + out.className = 'ma-result ok'; + out.textContent = '✓ logged in as ' + (body.user_id || '(unknown)') + ' — token stored.'; + clearSecrets(formEl); + loadAccounts(agent); + } else { + out.className = 'ma-result err'; + out.textContent = '✗ ' + (body.error || ('login failed (HTTP ' + resp.status + ')')); + clearSecrets(formEl); + } + } catch (err) { + out.className = 'ma-result err'; + out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).'; + } finally { + btn.disabled = false; + btn.textContent = orig; + } +} + +async function init() { + await loadState(); + renderAgentPicker(); + $('ma-agent').addEventListener('change', (e) => loadAccounts(e.target.value)); + document.querySelectorAll('input[name="mode"]') + .forEach((r) => r.addEventListener('change', toggleModeFields)); + toggleModeFields(); + $('ma-form').addEventListener('submit', submitLogin); + loadAccounts(''); +} + +init();