From b714ba15c24412b135b93c236c124100a911c4c7 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 31 May 2026 11:24:23 +0200 Subject: [PATCH 1/3] harness: drop trailing /bin from systemd path entries so wrapper sudo resolves (#672 fixup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit systemd.services..path appends /bin to each entry, so the literal '/run/wrappers/bin' here was being expanded to '/run/wrappers/bin/bin' inside the unit's PATH — a path that doesn't exist. 'which sudo' then fell back to '/run/current-system/sw/bin/sudo' (the non-setuid nix-store binary) and refused with 'must be owned by uid 0 and have the setuid bit set' on every agent, despite hyperhive.user.passwordlessSudo = true. Verified on this container post-rebuild: PATH includes /run/wrappers/bin/bin (non-existent) /run/wrappers/bin/sudo exists with mode r-s--x--x (real setuid) but `sudo` resolves to /run/current-system/sw/bin/sudo and fails. Fix: drop the trailing /bin from both entries. systemd appends it. The /run/current-system/sw entry was already correctly expanding to /run/current-system/sw/bin (because of the same auto-append), which is why everything else on PATH worked despite the broken wrappers entry — only sudo (the one binary that needs the wrapper dir) was affected. --- nix/templates/harness-base.nix | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index d99061ff..b8ac71bb 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -1269,8 +1269,17 @@ in # `hyperhive.user.passwordlessSudo = true` is configured # (#672 fixup pulled forward into this PR to avoid the # regression argus flagged on #676). + # + # `systemd.services..path` appends `/bin` to each entry, + # so the bare prefixes here resolve to `/run/wrappers/bin` + + # `/run/current-system/sw/bin` inside the unit's PATH. Passing + # the trailing `/bin` ourselves (the natural-looking spelling) + # would yield `/run/wrappers/bin/bin` + `/run/current-system/sw/bin/bin`, + # neither of which exists — that's how #672 originally landed + # broken: every agent had a PATH pointing at non-existent dirs + # and `which sudo` kept falling back to the un-setuid binary. path = [ - "/run/wrappers/bin" + "/run/wrappers" "/run/current-system/sw" ]; environment = { From 2950a7f9ee864e6baa7f2cdd1f5888c912d289e5 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 31 May 2026 09:35:52 +0200 Subject: [PATCH 2/3] dashboard: selection-bar M0V3 affordances for re-parenting agents (#486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend POST /api/topology/set-parent already shipped; the dashboard was missing the operator surface to drive it. Adds two affordances to the SW4RM tab's selection bar (alongside the existing R3ST4RT / ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3 actions): - '⇡ M0V3 → ROOT' (bulk): promote selected agents to top-level (parent=null). Disabled when all selected are already at root or the selection includes the manager (backend refuses anyway). - '⇢ M0V3 → [pick]' (single-agent only): inline ` + button pair + sitting alongside the bulk action buttons in the selection bar. + The select inherits the terminal-y monospace look so it doesn't + read as system-chrome popping out of the swarm aesthetic. Only + surfaces when exactly one agent is selected. */ +.move-picker { + display: inline-flex; + align-items: center; + gap: 0.3em; + margin-left: 0.6em; +} +.move-picker-select { + font-family: inherit; + font-size: 0.75em; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--mauve); + border-radius: 2px; + padding: 0.1em 0.3em; + max-width: 16em; +} +.move-picker-select:disabled { + opacity: 0.5; + cursor: default; +} +.move-picker .btn-move { + margin-left: 0; +} diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 3881123e..b06791c8 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -864,6 +864,134 @@ window.marked = marked; confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`, disabledTitle: why('PURG3', managerNames.map((n) => `\`${n}\` is the manager`)), }); + + // #486 — move agent(s) in the topology tree. Two affordances: + // + // ⇡ M0V3 → ROOT promote selected agent(s) to top-level (parent=null) + // ⇢ M0V3 → [sel] reparent the single selected agent under a picked + // parent (cycle-safe — the dropdown filters out self + // and own descendants on the client side; the + // backend rechecks via `topology::set_parent`). + // + // Backend lives at POST /api/topology/set-parent (dashboard.rs#2170), + // form-encoded `child=&new_parent=`. The + // backend re-emits container snapshots on success, so the tree + // repaints without a separate refresh. + addMoveActions(actions, selected, containers); + } + + // #486 — render the M0V3 affordances inside the selection bar. Split + // into its own helper because the picker variant needs a select + button + // pair, not the single-button shape addBulkButton ships. + function addMoveActions(parent, selected, containers) { + // Manager can never be a child of anything — backend refuses with + // "refusing to move the manager". Disable both affordances when the + // selection includes hm1nd (or any agent flagged is_manager). + const managerNames = selected.filter((c) => c.is_manager).map((c) => c.name); + const movable = !managerNames.length; + + // M0V3 → ROOT: parent=null for every selected agent. Only meaningful + // when at least one selected agent currently has a non-null parent; + // otherwise it's a no-op for everything. + const someNotAtRoot = selected.some((c) => c.parent); + addBulkButton(parent, 'btn-move', '⇡ M0V3 → ROOT', movable && someNotAtRoot, selected, { + action: '/api/topology/set-parent', + perAgentBodyFor: (name) => ({ child: name, new_parent: '' }), + confirm: (names) => `promote ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')}) to top-level (parent → root)?`, + disabledTitle: !movable + ? why('⇡ M0V3 → ROOT', managerNames.map((n) => `\`${n}\` is the manager`)) + : (!someNotAtRoot ? '⇡ M0V3 → ROOT not available — all selected agents are already at root' : null), + }); + + // M0V3 → : only when exactly one agent is selected. Picker + // omits self + own descendants (cycle-safe) + the manager (the + // backend allows manager-as-parent, that's fine — manager IS the + // default parent already, so we include it as an option). Empty + // candidate list ⇒ disable the picker. + if (selected.length !== 1 || !movable) { + // Multi-select or manager-included → skip the picker. The + // ROOT button above still applies; the picker is single-agent + // ergonomics only. + return; + } + const target = selected[0]; + const candidates = validReparentCandidates(target, containers); + + const wrap = el('span', { class: 'move-picker' }); + const select = el('select', { + class: 'move-picker-select', + title: `change ${target.name}'s parent`, + }); + select.append(el('option', { value: '' }, '— pick parent —')); + for (const name of candidates) { + select.append(el('option', { value: name }, name)); + } + if (!candidates.length) { + select.disabled = true; + select.title = `no valid parents for ${target.name} (all other agents are its descendants)`; + } + const btn = el('button', { type: 'button', class: 'btn btn-move' }, '⇢ M0V3'); + btn.disabled = true; + select.addEventListener('change', () => { btn.disabled = !select.value; }); + btn.addEventListener('click', async () => { + const newParent = select.value; + if (!newParent) return; + if (!confirm(`move ${target.name} under ${newParent}?`)) return; + btn.disabled = true; + const original = btn.innerHTML; + btn.innerHTML = ' ⇢ M0V3'; + try { + const body = new URLSearchParams({ child: target.name, new_parent: newParent }); + const resp = await fetch('/api/topology/set-parent', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok) { + const text = await resp.text().catch(() => ''); + alert(`move failed: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); + } + } catch (err) { + alert(`move failed: ${err}`); + } + btn.innerHTML = original; + btn.disabled = !select.value; + }); + wrap.append(select, btn); + parent.append(wrap); + } + + // Filter the dashboard's container list to those that are valid + // re-parent targets for `target`: anyone who isn't `target` itself, + // isn't a descendant of `target` (cycle prevention), and isn't a + // tombstoned/non-running entry that the topology layer would refuse. + // The backend re-checks the same constraints; this client-side filter + // is purely UX so the operator can't pick an obviously-invalid option. + function validReparentCandidates(target, containers) { + // Build descendant set for target via BFS through the parent map. + const childrenOf = new Map(); + for (const c of containers) { + const p = c.parent || null; + if (!childrenOf.has(p)) childrenOf.set(p, []); + childrenOf.get(p).push(c.name); + } + const descendants = new Set([target.name]); + const queue = [target.name]; + while (queue.length) { + const n = queue.shift(); + for (const child of (childrenOf.get(n) || [])) { + if (descendants.has(child)) continue; + descendants.add(child); + queue.push(child); + } + } + return containers + .filter((c) => !descendants.has(c.name)) + .map((c) => c.name) + .sort(); } function addBulkButton(parent, btnClass, label, enabled, selected, opts) { @@ -887,10 +1015,25 @@ window.marked = marked; // Sequential POSTs to keep server-side serialisation predictable // (rebuild_queue dedups but other endpoints don't); the loop is // short — bulk selections are typically a handful of agents. + // + // Two URL shapes: + // - `opts.action` is a path prefix and the agent name gets + // appended (lifecycle endpoints: /start/, /rebuild/). + // `opts.body` is a static object applied to every POST. + // - `opts.perAgentBodyFor(name)` is set: `opts.action` is the + // full URL (no name appended) and the per-agent body comes + // from the callback. Used by /api/topology/set-parent (#486), + // where the agent name is a body field rather than a URL + // component. for (const name of names) { - const body = new URLSearchParams(opts.body || {}); + const body = opts.perAgentBodyFor + ? new URLSearchParams(opts.perAgentBodyFor(name)) + : new URLSearchParams(opts.body || {}); + const url = opts.perAgentBodyFor + ? opts.action + : opts.action + encodeURIComponent(name); try { - const resp = await fetch(opts.action + encodeURIComponent(name), { + const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, From 1656b265ed2247710d17e9f58c31835b7f452940 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 31 May 2026 10:13:30 +0200 Subject: [PATCH 3/3] dashboard #486: drop manager special-case + enable M0V3 picker for multi-select (mara on #695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes from mara's review: 1. drop the manager special-case. Both M0V3 affordances now apply regardless of whether the manager is in the selection; backend topology::set_parent refuses the manager move and the failure surfaces in the bulk-action error roll-up. Matches the #443 ST0P policy of 'don't pre-gate manager actions, let the backend speak'. 2. enable the M0V3 → picker for multi-select. Was single-agent only in v1. Picker now omits every selected agent itself plus the union of every selected agent's descendants (cycle-safe across the whole batch); on submit POSTs once per selected agent sequentially, same shape as the existing bulk-button loop. Confirm message + error roll-up adapt to selection size. docs/web-ui.md updated to match. --- docs/web-ui.md | 21 ++-- frontend/packages/dashboard/src/tabs.js | 148 +++++++++++++----------- 2 files changed, 92 insertions(+), 77 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index a4a39df6..8248a034 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -441,15 +441,18 @@ frosted-mauve bar slides up from the bottom of the viewport - `↻ R3BU1LD` — always available - `DESTR0Y` / `PURG3` — sub-agents only (disabled if manager selected) - `⇡ M0V3 → ROOT` (#486) — promote selected agents to top-level - (parent = null); disabled when all selected are already at root - or when the selection includes the manager. - - `⇢ M0V3 → [select]` (#486) — single-agent-only inline picker; - the dropdown lists every container that isn't the target itself - nor a descendant of it (client-side cycle prevention; the - backend's `topology::set_parent` re-checks). On submit POSTs to - `/api/topology/set-parent` (form-encoded `child=&new_parent=`), - which writes `topology.json` and re-emits a container snapshot so - the tree repaints without a page reload. + (parent = null); disabled when all selected are already at root. + Manager included with no special-case (matches the `ST0P` policy); + the backend's `topology::set_parent` refuses to move the manager + and the refusal surfaces in the failure roll-up. + - `⇢ M0V3 → [select]` (#486) — inline picker available for any + selection size. The dropdown lists every container that isn't IN + the selection itself nor a descendant of any selected agent + (client-side BFS cycle prevention across the whole batch; the + backend re-checks per-agent). On submit POSTs to + `/api/topology/set-parent` (form-encoded `child=&new_parent=`) + once per selected agent, which writes `topology.json` and re-emits + a container snapshot so the tree repaints without a page reload. - **`✕ clear`** button + `Esc` key clear the entire selection. Stale selections (agents destroyed while selected) are pruned on diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index b06791c8..a8276669 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -883,113 +883,125 @@ window.marked = marked; // #486 — render the M0V3 affordances inside the selection bar. Split // into its own helper because the picker variant needs a select + button // pair, not the single-button shape addBulkButton ships. + // + // No client-side manager special-case (mara on #695): backend + // `topology::set_parent` refuses to move the manager and surfaces the + // refusal as a per-agent failure in the bulk-action error roll-up. Same + // pattern as #443 ST0P (which also doesn't special-case manager). function addMoveActions(parent, selected, containers) { - // Manager can never be a child of anything — backend refuses with - // "refusing to move the manager". Disable both affordances when the - // selection includes hm1nd (or any agent flagged is_manager). - const managerNames = selected.filter((c) => c.is_manager).map((c) => c.name); - const movable = !managerNames.length; - // M0V3 → ROOT: parent=null for every selected agent. Only meaningful // when at least one selected agent currently has a non-null parent; // otherwise it's a no-op for everything. const someNotAtRoot = selected.some((c) => c.parent); - addBulkButton(parent, 'btn-move', '⇡ M0V3 → ROOT', movable && someNotAtRoot, selected, { + addBulkButton(parent, 'btn-move', '⇡ M0V3 → ROOT', someNotAtRoot, selected, { action: '/api/topology/set-parent', perAgentBodyFor: (name) => ({ child: name, new_parent: '' }), confirm: (names) => `promote ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')}) to top-level (parent → root)?`, - disabledTitle: !movable - ? why('⇡ M0V3 → ROOT', managerNames.map((n) => `\`${n}\` is the manager`)) - : (!someNotAtRoot ? '⇡ M0V3 → ROOT not available — all selected agents are already at root' : null), + disabledTitle: !someNotAtRoot + ? '⇡ M0V3 → ROOT not available — all selected agents are already at root' + : null, }); - // M0V3 → : only when exactly one agent is selected. Picker - // omits self + own descendants (cycle-safe) + the manager (the - // backend allows manager-as-parent, that's fine — manager IS the - // default parent already, so we include it as an option). Empty - // candidate list ⇒ disable the picker. - if (selected.length !== 1 || !movable) { - // Multi-select or manager-included → skip the picker. The - // ROOT button above still applies; the picker is single-agent - // ergonomics only. - return; - } - const target = selected[0]; - const candidates = validReparentCandidates(target, containers); - + // M0V3 → : inline `