Skip to content

fix(auth): resolve agent runtime consoles through workspace membership - #2

Merged
MichaelAtram merged 1 commit into
michael-branchfrom
claude/hermes-ui-permissions-bug-5b13eb
Aug 17, 2026
Merged

fix(auth): resolve agent runtime consoles through workspace membership#2
MichaelAtram merged 1 commit into
michael-branchfrom
claude/hermes-ui-permissions-bug-5b13eb

Conversation

@mikelmao

Copy link
Copy Markdown
Collaborator

The bug

Give a user access to a workspace and they can see and manage its agents — but opening Hermes WebUI → Official Dashboard never loads. The agent's owner (or a super admin who happens to own the row) sees it fine.

The tab has two authorization models layered on one feature. GET /api/agents/:id/hermes-ui and the chat/cron/channel routes resolve access through workspace membership, so the metadata call returns dashboard.ready: true and the iframe mounts. The embed behind it looked the agent up with WHERE id = $1 AND user_id = $2 and answered 404 agent not found or not running — forever. There is no admin bypass on that path; a super admin only works there when they own the row.

Two more surfaces had the same owner-only lookup:

  • Hermes Skills (routes/hermesSkills.ts) — the sibling sub-tab in the same UI, broken the same way.
  • The OpenClaw gateway — control UI embed, bootstrap.js, the REST router, and the /ws/gateway/:agentId relay. A shared OpenClaw agent's console was unreachable for every workspace member.

The fix

buildAccessibleAgentQuery in middleware/ownership.ts resolves owner-or-sharing-member in one statement and exposes the caller's highest role as effective_role. The embed and gateway lookups need their own query rather than findAccessibleAgent because their SSRF allowlist authorizes against a pinned column projection, not SELECT *. user_id stays the owner in that projection — the remote-host grant check depends on it and is unchanged.

Thresholds follow the capability rather than the transport:

Surface Role
Hermes dashboard embed viewer to read, editor for DELETE/PATCH/POST/PUT
Hermes Skills viewer to list, editor to install/delete
OpenClaw gateway (embed, assets, bootstrap, REST, WS) editor throughout

The Hermes embed relays writes straight into the dashboard's own API, so a viewer reaching it for a write now gets 403 instead of silently succeeding. The OpenClaw surface gets no per-method split on purpose: bootstrap.js inlines the decrypted gateway password and rebinds the UI's WebSocket onto the relay, so even a GET grants live control. Viewers lose nothing there — the surface was owner-only before, so editor only widens access.

Roles are re-resolved on every request and every WS connection. The embed cookie carries no role, so a demotion takes effect on the next request rather than at token expiry.

Verification

  • 21 new tests across four suites, each confirmed to fail against the unfixed code and pass after. The Hermes embed set was checked explicitly by swapping the pre-fix server.ts back in: 6 fail → 6 pass.
  • Full backend suite serial: 2362 passed, 186 suites. prettier and eslint clean.
  • Five existing fixtures pinned the old owner-only SQL string or omitted user_id/effective_role from mock rows. Corrected as fixtures — the assertions they guard (remote-host grant revocation, gateway-credential non-exposure) are unchanged and still pass.
  • docs/concepts/workspaces.mdx documents both consoles under the workspace role table.

Note for review

The OpenClaw change means a workspace editor now receives that agent's gateway password via bootstrap.js, the same way its owner already does. That was a deliberate call — it keeps the credential inside the set of principals who can already operate the agent. Reworking the OpenClaw embed to log in server-side and hold an HttpOnly session, the way the Hermes proxy already does, would remove the exposure entirely and is the natural follow-up.

🤖 Generated with Claude Code

The Hermes dashboard embed, the Hermes Skills routes, and the whole
OpenClaw gateway surface authorized browser sessions with
`WHERE id = $1 AND user_id = $2` — direct ownership only. Their sibling
routes (/agents/:id/hermes-ui and its chat/cron/channel endpoints)
resolve access through workspace membership, so a member who could see
and manage a shared agent got a 404 from exactly these paths. The
"Official Dashboard" tab was the visible symptom: the metadata call
reported the dashboard ready, the iframe mounted, and the embed proxy
answered "agent not found or not running" forever.

Add buildAccessibleAgentQuery to middleware/ownership: one statement
resolving owner-or-sharing-member and exposing the caller's highest role
as effective_role. The embed and gateway lookups need it because their
SSRF allowlist authorizes against a pinned column projection and so
cannot route through findAccessibleAgent's SELECT *. user_id stays the
owner in that projection — the remote-host grant check depends on it.

Role thresholds follow the capability, not the transport:

- Hermes dashboard embed: reads take viewer, mutations take editor,
  matching the native panels. The proxy relays DELETE/PATCH/POST/PUT
  into the dashboard's own API, so a viewer reaching it for writes now
  gets 403 instead of silently succeeding.
- Hermes Skills: viewer to list, editor to install/delete.
- OpenClaw gateway (embed, assets, bootstrap.js, REST router, WS relay):
  editor throughout, with no per-method split. bootstrap.js inlines the
  decrypted gateway password and rebinds the UI socket onto the relay,
  so even a GET grants live control. Viewers lose nothing here; the
  surface was owner-only before, so editor only widens access.

Roles are re-resolved per request and per WS connection — the embed
cookie carries none — so a demotion applies on the next request rather
than at token expiry.

Five existing fixtures pinned the old owner-only SQL string or omitted
user_id/effective_role from mock rows; corrected without changing the
assertions they guard (remote-host grant revocation, credential
non-exposure).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MichaelAtram
MichaelAtram merged commit 737e945 into michael-branch Aug 17, 2026
62 of 64 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants