Skip to content

Server update buttons silently do nothing for connections paired before the environment:maintain split #17034

Description

@kcazin

What happened

After updating the client on my macbook, I got the notification to update the server, but clicking it does nothing and the server seems to be out of date. Installing the latest nightly after a new update (2787) didn't fix it. My server is stuck at 2761.

The macOS desktop app (2787) connects to a remote Linux server (2761) that runs as the systemd background service. There are two update entry points, and neither works:

  • The composer banner "Server update available…" shows an Update button that is disabled and does not react to clicks.
  • Settings → Connections → "Update all" is enabled and reacts to the click, but does nothing. There is no toast, no error and no progress.

There is no indication of why. The server's 2752 → 2761 update, triggered from the same Mac the day before, worked.

Diagnosis

The client blocks the update because the connection's session lacks environment:maintain. That is intended. The bug is that the UI does not say so: one button is disabled with no explanation, and the other is enabled but does nothing.

  1. The Mac's saved connection uses a bearer session paired on 2026-09-28 by 0.0.43-nightly.20260927.2344, before feat(auth): separate environment administration permissions #9786 split out the granular permissions. The server's auth_sessions row for it has these scopes:
    ["orchestration:read","orchestration:operate","terminal:operate","review:write","relay:read"].
    The row is labelled "MacBook Air", client version 2787, and its last_connected_at matches the websocket that carried the clicks.
  2. 28ce9d1 (feat(auth): separate environment administration permissions #9786) is in v0.0.46-nightly.20261007.2761 and not in v0.0.46-nightly.20261006.2752. Since 2761, /api/auth/session returns permissions equal to the stored scopes, so sessionGrantsScope(session, "environment:maintain") (packages/contracts/src/auth.ts:181-192) takes the permissions branch at :186 and returns false. The legacy orchestration:operate → environment:maintain fallback only applies when permissions is absent. This is why the 2752 → 2761 update worked and every update after it does not.
  3. Composer banner / per-row button (apps/web/src/components/ServerUpdateAction.tsx): canUpdate = canUpdateServer(sessionState) at :226, then disabled={manualCommand === null && !canUpdate} at :333. manualCommand is null because selfUpdate === "boot-service". The button is greyed out with no reason given.
  4. "Update all": savedServerUpdateTargets (apps/web/src/components/settings/ConnectionsSettings.tsx:2148-2181) filters on version mismatch, enabled, connected, not desktop-local and a non-null selfUpdate, but not on the session's permissions. ServerUpdatesAction (ServerUpdateAction.tsx:125-159) therefore counts the server as eligible and keeps the button enabled. On click, useServerUpdate (:70-76) runs if (!canUpdateServer(...) || pending) return; before calling server.updateServer, so nothing is sent and nothing is shown.
  5. Server side: nothing reaches it. The server would also reject the call, since RpcAuthorization.ts:77-79 requires environment:maintain for server.updateServer and server.updateServerWithProgress. The background service, launcher and runtime download were not involved. Running t3 update by hand on the server succeeded right away.

PermissionUpdateNotice.tsx shows a one-time "Permissions have changed for …" toast for legacy-only sessions. Its dismissal is stored in localStorage, and after that the update UI gives no hint that re-pairing is needed.

The same code is unchanged on main as of 2026-10-08. ServerUpdateAction.tsx was last touched by 365aa87 (#10298).

Relevant files at the 2787 tag:

  • apps/web/src/components/ServerUpdateAction.tsx
  • apps/web/src/components/settings/ConnectionsSettings.tsx
  • packages/contracts/src/auth.ts

Steps to reproduce

  1. Pair a client with a remote server on a build from before feat(auth): separate environment administration permissions #9786 (for example 0.0.43 nightly), using a one-time pairing link (bearer session). The session gets the old five-scope set.
  2. Update the server to a build that includes feat(auth): separate environment administration permissions #9786 (2761 or later) and the client to a newer build, so the client shows "Server update available".
  3. Composer banner: the Update button is disabled and gives no reason.
  4. Settings → Connections: Update all is enabled. Click it. Nothing happens, no toast appears, and the server trace shows no ws.rpc.server.updateServer* span. The only traffic is server.reportClientActivity from the click.

Expected: either the session can still update its server, or both buttons explain that the connection needs re-pairing for environment:maintain. "Update all" should not count targets it will skip, and should not skip them silently.

Version

Client: 0.0.46-nightly.20261007.2787 (macOS desktop). Server: 0.0.46-nightly.20261007.2761, systemd user service with launcher 0.0.46-nightly.20261003.2610.

Environment

Server: CachyOS Linux x64 (kernel 7.2), Node v26.8.2, systemd user service. Client: macOS desktop app (Electron 44.4.2) over a direct Tailscale route.

Evidence

# Clicks at about 10:12:33-38 JST: server trace shows only activity pings on the open /ws, and no update RPC
10:12:33 ws.rpc.server.reportClientActivity {"rpc.transport":"websocket","rpc.method":"server.reportClientActivity"}
10:12:34 ws.rpc.server.reportClientActivity ...
10:12:38 ws.rpc.server.reportClientActivity ...
$ grep -c -E 'updateServer|self_update|SelfUpdate' server.trace.ndjson*   # all retained files
0

# Session the Mac is using (auth_sessions, read-only)
subject=one-time-token  method=bearer-access-token  label="MacBook Air"  surface=desktop
client_app_version=0.0.46-nightly.20261007.2787  issued_at=2026-09-28T10:46:07Z  last_connected_at=2026-10-08T01:05:03Z
scopes=["orchestration:read","orchestration:operate","terminal:operate","review:write","relay:read"]

# The /api/auth/session request from 2787 at 10:05:03 went over the direct Tailscale route with a bearer header (no DPoP)
http.server GET /api/auth/session host=<tailnet-host>.ts.net:3773 ua=T3Code(Nightly)/0.0.46-nightly.20261007.2787

# Last successful UI-driven update (server was 2752, before #9786)
[15:59:17.731] INFO: Server update prepared; handing off to the service launcher.
    targetVersion: '0.0.46-nightly.20261007.2761'

Related issues

Fix applied or workaround

Ran npx t3@nightly update --yes on the server, which moved it to 0.0.46-nightly.20261008.2801 and restarted the service cleanly. The Mac was deliberately left on its old pairing so the bug can still be reproduced on the next version mismatch. The permanent workaround is to re-pair the connection with a fresh link (t3 auth pairing create / t3 pair), which issues AuthStandardClientScopes including environment:maintain.

Filed by

Claude Code (claude-opus-5-5) via t3 triage

Activity

  1. juliusmarminge commented on Oct 8, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Thanks for the detailed report. I checked it against main at d720210, and the diagnosis holds.

    Confirmed on main

    • sessionGrantsScope returns session.permissions.includes(scope) whenever the server sends permissions (L186). The legacyParents fallback (environment:maintain → orchestration:operate, L154-L164) only runs against servers that send neither permissions nor auth.serverUpdateScope.
    • Upgraded servers send the stored grant as-is. getSessionState spreads authScopeResponse(session.scopes), which sets permissions to the stored scopes. fix(auth): keep old clients connected across scope changes #10298, which landed with feat(auth): separate environment administration permissions #9786, deliberately removed scope expansion ("Existing credentials keep their recorded grants"). A session paired with the old five scopes therefore has no environment:maintain.
    • The server would refuse the update anyway. RpcAuthorization.ts L77-L79 requires environment:maintain for server.updateServer / updateServerWithProgress, checked against the session's stored scopes (ws.ts L3159). So the client-side gate itself is consistent with the server.
    • Banner / row button: canUpdate feeds disabled={manualCommand === null && !canUpdate} (L333, and L316 for the icon variant). Nothing explains why it's disabled. The icon variant's tooltip only shows the action label, and the composer banner's description is the generic serverUpdateGuidance ("Update to stay in sync").
    • "Update all": savedServerUpdateTargets doesn't look at the session's permissions. ServerUpdatesAction only filters on selfUpdate and disables only when eligible.length === 0 (L159). On click, useServerUpdate returns early when canUpdateServer is false, before the RPC and before any toast. That matches the silent no-op you saw.
    • The only hint is PermissionUpdateNotice. It's a one-time toast whose dismissal is persisted in localStorage (t3code:permission-update:v1:<environmentId>), so after it's dismissed the update UI gives no clue.

    Root cause

    The permission split (#9786 / #10298) intentionally leaves pre-split sessions without environment:maintain, and the update UI gates on that permission without telling anyone. The single button just greys out, and the bulk button counts targets it will then skip without saying so. Your 2752 → 2761 update succeeding fits this: the client's legacy fallback applies to servers that don't advertise the new fields. I haven't verified exactly which release tags carry which commits.

    Possible fixes (untested)

    1. Surface the reason (safe part). When the button is disabled because environment:maintain is missing, show something like "This connection needs to be paired again to update the server", ideally with an "Open Connections" action. Do it in both ServerUpdateAction variants and in the banner description. sessionHasLegacyPermissions could word it specifically for pre-split pairings.
    2. Make "Update all" honest. Either leave targets without environment:maintain out of savedServerUpdateTargets (so the button hides or disables when none qualify), or have the click toast which servers were skipped and why, instead of returning silently.
    3. Letting old sessions update again would mean treating orchestration:operate as granting environment:maintain for legacy grants. The server enforces this too, so the change would have to be server-side as well. That widens what existing credentials can do and reverses an explicit decision in fix(auth): keep old clients connected across scope changes #10298, so it's a security/product call for the maintainers, not a straightforward bug fix.

    Workaround

    Pair the environment again with a fresh link (Settings → Connections → Add Environment with the new link or code; see remote-access.md). Pair through the same address you use now, because saved credentials are per route and other routes can keep the old grant (see #17014). Updating on the host directly, as you did with t3 update, also gets around it.

    Related

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 8, 2026
  3. Xanacas commented on Oct 8, 2026

    @Xanacas
    Contributor

    Additional data point from t3 triage: the browser side panel is also greyed out for the same reason, not only the update button.

    Environment

    • Client: T3 Code desktop, 0.0.46-nightly.20261008.2801 (d720210), Windows 11 x64
    • Remote: a saved remote environment ("agentic-gui"), paired before the permission split

    Symptoms

    • The server update nudge for the remote shows, but its Update button is disabled, with no explanation.
    • The browser/preview side panel for threads on that remote is disabled too, also with no explanation.

    Diagnosis (same mechanism)
    The preview UI is gated on preview:operate (AuthPreviewOperateScope), for example in PreviewPanel.tsx L37, LegacySidebar.tsx L409, ChatMarkdown.tsx L2581, ElectronBrowserHost.tsx L130 and _chat.tsx L40. Like environment:maintain, it is a child of orchestration:operate in legacyParents. sessionGrantsScope never falls back to that parent once the server returns permissions, so pre-split pairings lose the browser panel as well as server updates, and nothing in the UI says why.

    Suggestion
    Whatever fix lands for the update button's disabled reason (fix 1 above) should also cover the preview/browser panel entry points and any other control gated on a child scope. A shared "needs re-pairing for permission X" hint driven by sessionHasLegacyPermissions would cover all of them. The one-time PermissionUpdateNotice toast is easy to miss or dismiss.

    Workaround: run npx t3@nightly pair on the host (the default standard scopes include environment:maintain and preview:operate), then use Add Environment with the new link through the same route.

    Comment drafted by Claude Opus 5.5 (Claude Code) via t3 triage.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions