Skip to content

frontend(desktop): hide the desktop entry from users the socket will refuse, with i18n in all 11 locales #17370

Description

@mrveiss

#17054's fourth criterion, split out because it is a different risk envelope from the backend
gate and more than one honest review pass combined with it. The backend half is PR #17369.

What is already true, so this is not re-derived

The mechanism exists. usePermissions().hasPermission('mcp.desktop.control') works today:
autobot-frontend/src/types/_generated/workflow.ts carries ROLE_PERMISSIONS auto-generated
from the backend table
(gen_frontend_types.py), and it already contains
'mcp.desktop.control'. So the frontend can ask the same question the socket now asks, from the
same source, with no new plumbing and no drift risk.

What the socket now does (PR #17369): a user credential must hold mcp.desktop.control —
admin and operator today. Everyone else is refused in the handshake with 1008. A paired
device is unaffected. Note superadmin is also refused, by ROLE_PERMISSIONS[Role.SUPERADMIN]
being empty (#13854); if the frontend gates on the same permission it will agree with the
socket automatically, including on that.

Entry points found

Where State
chat novnc tab — components/chat/ChatTabs.vue:42 { key: 'novnc', label: 'noVNC', icon: 'desktop' } in a withDefaults prop default. The label is a hardcoded English string, which is its own violation of the no-hardcoded-UI-strings rule, so this line needs i18n regardless.
/slm/tools/novnc — router/index.ts meta carries requiresAuth: true and nothing else.
views/AdvancedControlView.vue, views/DesktopView.vue Reference desktop/vnc; not assessed.
/desktop Already redirects to /chat.

The router has no role or permission mechanism today — grep 'requiresRole\|requiresPermission\|roles:' over router/index.ts returns nothing. So gating the route means introducing the first such meta and a guard that reads it, where gating the tab is a filter at the component. Those are different sizes; the choice belongs to whoever takes this.

Acceptance criteria

  • A user without mcp.desktop.control does not see, or cannot activate, the desktop entry at
    every point listed above — with the unassessed two checked rather than assumed absent.
  • The gate reads hasPermission('mcp.desktop.control'), the same permission the socket
    enforces, so the two cannot disagree. Not a role list, and not a second copy of the rule.
  • A refused user gets an explanation rather than a silent absence or a dead tab, with the
    string in all 11 locales (ar, de, en, es, fa, fr, he, lv, pl, pt, ur).
  • ChatTabs.vue:42's hardcoded 'noVNC' label moves to i18n as part of the same change.
  • A test proves a user lacking the permission does not get the entry, and one proves a user
    holding it does — the pair, since "hidden for everyone" would satisfy the first alone.

Explicitly not a security boundary

The socket is the boundary and it is closed by #17369. This issue is about not offering a
control that will be refused
— a UX and honesty fix, not a second gate. It should never be the
thing standing between a user and the desktop, and a reviewer should push back on any
implementation that reads as though it is.

Related

  • #17054 — parent; criteria 1-3 delivered in #17369
  • #15146 — the paired-device path, unaffected

Activity

  1. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Taking this. Two amendments first, one of which makes the second criterion produce the exact disagreement it forbids.

    AC2 cannot be met with hasPermission, because that function has the short-circuit #13854 removed

    This issue says:

    if the frontend gates on the same permission it will agree with the socket automatically, including on that [superadmin]

    It will not. autobot-frontend/src/composables/usePermissions.ts:88-92:

    const hasPermission = (permission: Permission | string): boolean => {
      // Admin always has all permissions
      if (userStore.isAdmin) return true
      return permissions.value.includes(permission as Permission)
    }

    And useUserStore.isAdmin is isAdminRole(currentUser.role), which admits superadmin — its own test says so in as many words (useUserStore.isAdmin.test.ts:46: "admits superadmin — a hand-rolled === 'admin' check previously rejected this").

    So, from the generated table:

    Role ROLE_PERMISSIONS holds mcp.desktop.control socket (asks the table) hasPermission(...)
    admin yes (63 perms) admits true
    operator yes (34 perms) admits true
    superadmin no — 0 perms refuses true

    A superadmin would be shown the desktop entry and then closed with 1008 on the handshake. That is the disagreement AC2 exists to prevent, and gating on hasPermission is what would create it.

    This is #13854's ruling reappearing one layer up. PR #17369 refused to write is_admin_role(role) or role_has_permission(...) in the backend gate precisely because #13854 removed the administrative short-circuit from the permission predicate. hasPermission is that short-circuit, alive in the frontend composable. Gating on it would reinstate in the UI the thing the backend was careful not to reinstate.

    The codebase already knows this hazard, three lines away from the same store — useUserStore.ts:107-109, for service.management:

    superadmin deliberately excluded: its ROLE_PERMISSIONS entry is empty by …

    AC2 is amended to: the gate reads the role permission table — the same source role_has_permission reads — with no administrative override, so the frontend and the socket agree on every role including superadmin. usePermissions already exposes permissions (table-derived, no short-circuit); I am adding a named, documented holdsPermission alongside hasPermission so the next call site that needs to match a backend gate has one to reach for instead of rediscovering this.

    hasPermission is not being changed. It has 60-odd call sites whose expectations I have not audited, and changing an administrative override's semantics across the app is not this issue's risk envelope. That is worth its own issue and I will file it.

    AC4 understates itself: all five tab labels are hardcoded, not one

    ChatTabs.vue:36-43 — the whole withDefaults default:

    { key: 'chat',     label: 'Chat',     icon: 'comments' },
    { key: 'files',    label: 'Files',    icon: 'folder' },
    { key: 'terminal', label: 'Terminal', icon: 'terminal' },
    { key: 'browser',  label: 'Browser',  icon: 'globe' },
    { key: 'novnc',    label: 'noVNC',    icon: 'desktop' }

    The issue names 'noVNC'. The other four are the same violation in the same expression, and fixing one of five while editing the line would be the "patched again in every PR that finds it" shape. All five go to i18n.

    The two unassessed entry points, checked rather than assumed

    Entry point Verdict
    views/AdvancedControlView.vue not an entry point. Its only noVNC references are capability readouts — novnc_available ? t('common.yes') : t('common.no') at :201-206 and :330-331. It reports whether the feature exists; it does not open it. No gate needed, and gating it would hide diagnostics from the people who need them
    views/DesktopView.vue is an entry point — 411 bytes, mounts DesktopInterface.vue. But /desktop redirects to /chat (router/index.ts:905), so the view is unreachable by URL. Unreachable-but-mounted is unfinished work rather than litter, so it is in scope for the gate rather than ignored

    Third entry point, reachable and needing the explanation rather than a hidden control: /slm/tools/novnc → SlmNoVncView.vue (router/index.ts:908-915, requiresAuth: true and nothing else). A bookmarked URL is activation, so hiding a tab does not cover it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions