# Files changed · PR \#23374 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23374/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): explain why older connections cannot update servers](https://git.cafe/pingdotgg/t3code/pull/23374.md)

Version: 13

Head commit: a1a73e8205729d745d78a6c318041c93e6531ecf

Base commit: 3b95e7831b11b17627dff16843bc087bfca397ec

Comparison base: e07595714a467e18c750107137d2c2e50f0c3530

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/scripts/issue-17034-fixture.mjs

Status: added; +234; −0

```diff
@@ -1,0 +1,234 @@
+/**
+ * Actual-app update-permission evidence fixture; never starts a server or browser.
+ * Run from any directory with Node 26:
+ *   node apps/server/scripts/issue-17034-fixture.mjs init /tmp/t3-issue-17034-proof
+ *   node apps/server/scripts/issue-17034-fixture.mjs sessions /tmp/t3-issue-17034-private HOME_A HOME_B HOME_C
+ *   node apps/server/scripts/issue-17034-fixture.mjs projects /tmp/t3-issue-17034-project HOME_A HOME_B HOME_C
+ *   node apps/server/scripts/issue-17034-fixture.mjs descriptor-apply /tmp/t3-issue-17034-descriptor.json 16471 16472 16473 16474
+ *   node apps/server/scripts/issue-17034-fixture.mjs descriptor-restore /tmp/t3-issue-17034-descriptor.json 16471 16472 16473 16474
+ *
+ * Coordinate ALL servers sharing this checkout with their owner before apply/restore.
+ * Ports must include every such server. Both commands refuse listening ports and
+ * live runtime PIDs in this worktree's .t3. Apply requires the committed source;
+ * restore requires the exact patched source. Never stage the descriptor or backup.
+ * Metadata advertises an update; it does not install a boot service. Do not infer
+ * successful updates from it. Pending/races/desktop branches still need a harness.
+ *
+ * init is for NEW homes only; existing lead-owned homes can go directly to sessions
+ * or projects. Sessions are minted privately by the supported auth CLI (not fixed
+ * tokens). Re-running sessions creates new credentials, so revoke old sessions via
+ * the auth CLI when finished. Output is private mode-0600 JSON; never publish it.
+ * projects uses the real project CLI and an empty Git repository shared by all homes.
+ * Select its new draft and Auto balance in the real client; no draft/catalog writes.
+ * Labels below are capture inputs, NOT a server-label override: the server descriptor
+ * derives its label from the OS. No supported server-label setting exists here.
+ */
+import * as NodeChildProcess from "node:child_process";
+import * as NodeCrypto from "node:crypto";
+import * as NodeFS from "node:fs";
+import * as NodeNet from "node:net";
+import * as NodePath from "node:path";
+import * as NodeURL from "node:url";
+
+const root = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "../../..");
+const source = NodePath.join(root, "apps/server/src/environment/ServerEnvironment.ts");
+const cli = NodePath.join(root, "apps/server/src/bin.ts");
+const oldVersion = "0.0.46-nightly.20261007.2761";
+const labels = [
+  "Office",
+  "Laptop",
+  `Studio ${"X".repeat(48)} ${"remote-room-".repeat(4)}`.slice(0, 96),
+];
+const scopes = ["orchestration:read", "orchestration:operate", "terminal:operate", "relay:read"];
+const digest = (value) => NodeCrypto.createHash("sha256").update(value).digest("hex");
+const run = (args) =>
+  NodeChildProcess.execFileSync(process.execPath, [cli, ...args], {
+    cwd: root,
+    encoding: "utf8",
+    stdio: ["ignore", "pipe", "pipe"],
+  });
+function safePath(value) {
+  if (!value || !NodePath.isAbsolute(value)) throw new Error("Use an absolute isolated path.");
+  const resolved = NodePath.resolve(value);
+  const allowed =
+    resolved.startsWith("/tmp/t3-issue-17034-") || resolved === NodePath.join(root, ".t3");
+  if (!allowed) throw new Error("Only /tmp/t3-issue-17034-* or this checkout's .t3 is permitted.");
+  let ancestor = resolved;
+  while (!NodeFS.existsSync(ancestor)) ancestor = NodePath.dirname(ancestor);
+  if (NodeFS.realpathSync(ancestor) !== ancestor)
+    throw new Error("Symlinked fixture paths are forbidden.");
+  return resolved;
+}
+function homesFrom(args) {
+  if (args.length !== 3) throw new Error("Supply exactly three distinct existing isolated homes.");
+  const homes = args.map(safePath);
+  const ids = homes.map((home) =>
+    NodeFS.readFileSync(NodePath.join(home, "userdata/environment-id"), "utf8").trim(),
+  );
+  if (ids.some((id) => !id) || new Set(ids).size !== 3)
+    throw new Error("Three distinct initialized environment IDs are required.");
+  return homes;
+}
+function patch(original) {
+  const version = "serverVersion: packageJson.version,";
+  const capability = "      ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),";
+  if (original.split(version).length !== 2 || original.split(capability).length !== 2) {
+    throw new Error("Descriptor source changed or is already patched; coordinate with its owner.");
+  }
+  return original
+    .replace(version, `serverVersion: "${oldVersion}",`)
+    .replace(capability, '      serverSelfUpdate: "boot-service",');
+}
+async function assertStopped(ports) {
+  if (
+    ports.length < 4 ||
+    ports.some((port) => !/^\d+$/.test(port) || +port < 1024 || +port > 65535)
+  ) {
+    throw new Error("List at least all four proof backend ports explicitly.");
+  }
+  for (const home of [
+    NodePath.join(root, ".t3"),
+    ...NodeFS.readdirSync("/tmp")
+      .filter((name) => name.startsWith("t3-issue-17034-"))
+      .map((name) => NodePath.join("/tmp", name)),
+  ]) {
+    const runtime = NodePath.join(home, "userdata/server-runtime.json");
+    if (!NodeFS.existsSync(runtime)) continue;
+    const { pid } = JSON.parse(NodeFS.readFileSync(runtime, "utf8"));
+    if (!Number.isInteger(pid) || pid <= 0) throw new Error("Invalid runtime PID.");
+    try {
+      process.kill(pid, 0);
+    } catch (error) {
+      if (error.code === "ESRCH") continue;
+      throw error;
+    }
+    throw new Error(`Stop the fixture server owned by ${home} before changing source.`);
+  }
+  for (const port of ports) {
+    await new Promise((resolve, reject) => {
+      const socket = NodeNet.connect({ host: "127.0.0.1", port: +port });
+      socket.setTimeout(1000);
+      socket.once("connect", () => {
+        socket.destroy();
+        reject(new Error(`Port ${port} is listening; owner must stop it.`));
+      });
+      socket.once("timeout", () => {
+        socket.destroy();
+        reject(new Error(`Port ${port} could not be checked.`));
+      });
+      socket.once("error", (error) => (error.code === "ECONNREFUSED" ? resolve() : reject(error)));
+    });
+  }
+}
+const [command, destination, ...args] = process.argv.slice(2);
+try {
+  if (command === "init") {
+    const base = safePath(destination);
+    NodeFS.mkdirSync(base, { mode: 0o700 });
+    const entries = labels.map((label, index) => {
+      const home = NodePath.join(base, `environment-${index + 1}`);
+      NodeFS.mkdirSync(NodePath.join(home, "userdata"), { recursive: true, mode: 0o700 });
+      const environmentId = `17034000-0000-4000-8000-00000000000${index + 1}`;
+      NodeFS.writeFileSync(NodePath.join(home, "userdata/environment-id"), `${environmentId}\n`, {
+        mode: 0o600,
+        flag: "wx",
+      });
+      return {
+        home,
+        environmentId,
+        requestedLabel: label,
+        defaultPermission: index === 1 ? "permitted" : "denied",
+      };
+    });
+    NodeFS.writeFileSync(
+      NodePath.join(base, "manifest.json"),
+      `${JSON.stringify(entries, null, 2)}\n`,
+      {
+        mode: 0o600,
+        flag: "wx",
+      },
+    );
+    console.log("Created three isolated homes and non-secret manifest. No server started.");
+  } else if (command === "sessions") {
+    const homes = homesFrom(args);
+    const output = safePath(destination);
+    NodeFS.mkdirSync(output, { mode: 0o700 });
+    for (const [index, home] of homes.entries()) {
+      for (const permission of ["denied", "permitted"]) {
+        const granted = permission === "permitted" ? [...scopes, "environment:maintain"] : scopes;
+        const result = run([
+          "auth",
+          "session",
+          "issue",
+          "--base-dir",
+          home,
+          ...granted.flatMap((scope) => ["--scope", scope]),
+          "--label",
+          `proof-${index + 1}-${permission}`,
+          "--json",
+        ]);
+        JSON.parse(result);
+        NodeFS.writeFileSync(NodePath.join(output, `${index + 1}-${permission}.json`), result, {
+          mode: 0o600,
+          flag: "wx",
+        });
+      }
+    }
+    console.log(
+      "Issued allowed/denied sessions for all three identities; credentials saved privately.",
+    );
+  } else if (command === "projects") {
+    const homes = homesFrom(args);
+    const workspace = safePath(destination);
+    NodeFS.mkdirSync(workspace, { mode: 0o700 });
+    NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main", workspace], {
+      stdio: "ignore",
+    });
+    for (const home of homes)
+      run(["project", "add", workspace, "--title", "Update permission proof", "--base-dir", home]);
+    console.log(
+      "Added the same empty Git workspace to three environments. Select its draft and Auto balance in the client.",
+    );
+  } else if (command === "descriptor-apply" || command === "descriptor-restore") {
+    const backup = safePath(destination);
+    await assertStopped(args);
+    if (command === "descriptor-apply") {
+      const original = NodeFS.readFileSync(source, "utf8");
+      const committed = NodeChildProcess.execFileSync(
+        "git",
+        ["show", "HEAD:apps/server/src/environment/ServerEnvironment.ts"],
+        { cwd: root, encoding: "utf8" },
+      );
+      if (original !== committed)
+        throw new Error("Descriptor has uncommitted changes; do not overwrite them.");
+      const patched = patch(original);
+      NodeFS.writeFileSync(backup, JSON.stringify({ original, patchedHash: digest(patched) }), {
+        mode: 0o600,
+        flag: "wx",
+      });
+      NodeFS.writeFileSync(source, patched);
+    } else {
+      const { original, patchedHash } = JSON.parse(NodeFS.readFileSync(backup, "utf8"));
+      if (
+        digest(NodeFS.readFileSync(source)) !== patchedHash ||
+        digest(patch(original)) !== patchedHash
+      )
+        throw new Error("Descriptor changed since apply; refusing to overwrite.");
+      NodeFS.writeFileSync(source, original);
+      NodeFS.unlinkSync(backup);
+    }
+    console.log("Descriptor fixture operation completed. No server started.");
+  } else {
+    throw new Error(
+      "Commands: init, sessions, projects, descriptor-apply, descriptor-restore. See file header for usage.",
+    );
+  }
+} catch (error) {
+  // CLI stderr/stdout can contain credentials; report only the operation failure.
+  console.error(
+    error?.status !== undefined
+      ? "Fixture CLI failed; inspect the isolated environment privately."
+      : error.message,
+  );
+  process.exitCode = 1;
+}
```

## apps/web/src/components/ServerUpdateAction.browser.test.tsx

Status: modified; +128; −4

```diff
@@ -15,6 +15,7 @@
   clipboard: vi.fn(),
   continueThreadsAfterServerUpdate: false,
   sessionAtom: null as unknown,
+  sessionAtoms: new Map<EnvironmentId, unknown>(),
 }));
 
 vi.mock(import("~/hooks/useCopyToClipboard"), async (original) => ({
@@ -39,12 +40,13 @@
 }));
 vi.mock(import("~/state/session"), async (original) => {
   const actual = await original();
+  const { environmentAtomList } = await import("~/state/environmentAtomList");
+  const sessionStateAtom = ((environmentId: EnvironmentId) =>
+    testState.sessionAtoms.get(environmentId) ?? testState.sessionAtom) as never;
   return {
     ...actual,
+    environmentSession: { ...actual.environmentSession, sessionStateAtom },
-    environmentSession: {
-      ...actual.environmentSession,
-      sessionStateAtom: (() => testState.sessionAtom) as never,
+    environmentSessionStatesAtom: environmentAtomList("test-session-states", sessionStateAtom),
-    },
   };
 });
 vi.mock(import("~/state/use-atom-command"), async (original) => ({
@@ -82,6 +84,19 @@
   },
 } as const satisfies AuthSessionState;
 
+// Paired before environment:maintain was split out; the server reports the stored grant.
+const legacyGrant = [
+  "orchestration:read",
+  "orchestration:operate",
+  "terminal:operate",
+  "relay:read",
+] as const;
+const legacyPairedSession = decodeSessionState({
+  ...currentSession,
+  scopes: legacyGrant,
+  permissions: legacyGrant,
+});
+
 const success = (method: "boot-service" | "desktop-app" = "boot-service", version = "0.0.31") =>
   AsyncResult.success({ targetVersion: version, method });
 
@@ -98,6 +113,7 @@
   testState.continueThreadsAfterServerUpdate = false;
   sessionAtom = Atom.make<SessionResult>(AsyncResult.success(currentSession));
   testState.sessionAtom = sessionAtom;
+  testState.sessionAtoms.clear();
   resetConfirmDialogForTests();
   return { [Symbol.dispose]: resetConfirmDialogForTests };
 }
@@ -115,6 +131,21 @@
     : buttons.find((element) => element.textContent?.trim() === name);
 }
 
+// Text a user can reach: rendered copy, a native title, or an accessible description.
+function explanation(screen: Screen) {
+  const attributes = [...screen.container.querySelectorAll("[title], [aria-description]")].map(
+    (element) =>
+      `${element.getAttribute("title") ?? ""} ${element.getAttribute("aria-description") ?? ""}`,
+  );
+  const described = [...screen.container.querySelectorAll("[aria-describedby]")].flatMap(
+    (element) =>
+      (element.getAttribute("aria-describedby") ?? "")
+        .split(/\s+/)
+        .map((id) => document.getElementById(id)?.textContent ?? ""),
+  );
+  return [screen.container.textContent ?? "", ...attributes, ...described].join(" ");
+}
+
 async function click(screen: Screen, name?: string) {
   await act(async () => button(screen, name)!.click());
 }
@@ -179,6 +210,15 @@
     await click(screen, "Update");
     expect(testState.updateServer).not.toHaveBeenCalled();
   });
+
+  it("explains that a connection paired before environment:maintain must re-pair", async () => {
+    using _state = setup();
+    setSession(AsyncResult.success(legacyPairedSession));
+    await using screen = await renderAction();
+
+    expect(button(screen, "Update")!.disabled).toBe(true);
+    expect(explanation(screen)).toMatch(/pair/i);
+  });
 });
 
 describe("ServerUpdatesAction", () => {
@@ -228,4 +268,88 @@
     );
     expect(button(screen, "Update all")!.disabled).toBe(false);
   });
+
+  it("does not offer Update all when no target grants environment:maintain", async () => {
+    using _state = setup();
+    setSession(AsyncResult.success(legacyPairedSession));
+    await using screen = await mount(targets.slice(0, 2));
+
+    expect(button(screen, "Update all")!.disabled).toBe(true);
+    expect(explanation(screen)).toMatch(/pair/i);
+  });
+
+  it.each([
+    { name: "loading", session: AsyncResult.initial() },
+    { name: "failed", session: AsyncResult.failure(Cause.fail(new Error("Offline"))) },
+  ])(
+    "disables Update all when a denied target is mixed with a $name session",
+    async ({ session }) => {
+      using _state = setup();
+      setSession(AsyncResult.success(legacyPairedSession));
+      testState.sessionAtoms.set("batch-a" as EnvironmentId, Atom.make(session));
+      await using screen = await mount(targets.slice(0, 2));
+
+      expect(button(screen, "Update all")!.disabled).toBe(true);
+      expect(explanation(screen)).toContain("Office can't be updated");
+      expect(explanation(screen)).not.toContain("Laptop can't be updated");
+      await click(screen, "Update all");
+      expect(testState.updateServer).not.toHaveBeenCalled();
+    },
+  );
+
+  it("does not ask to pair again when a mixed batch is pending", async () => {
+    using _state = setup();
+    const denied = Atom.make<SessionResult>(AsyncResult.success(legacyPairedSession));
+    testState.sessionAtoms.set("batch-b" as EnvironmentId, denied);
+    let resolveUpdate!: (result: ReturnType<typeof success>) => void;
+    const update = new Promise<ReturnType<typeof success>>((resolve) => {
+      resolveUpdate = resolve;
+    });
+    testState.updateServer.mockReturnValue(update);
+    await using screen = await mount(targets.slice(0, 2));
+
+    try {
+      await click(screen, "Update all");
+      expect(button(screen, "Update all")!.disabled).toBe(true);
+      expect(testState.toast).toHaveBeenCalledWith(
+        expect.objectContaining({ title: "Office skipped" }),
+      );
+
+      await act(() => button(screen, "Update all")!.parentElement!.click());
+
+      expect(testState.toast).not.toHaveBeenCalledWith(
+        expect.objectContaining({ title: "Pair again to update" }),
+      );
+    } finally {
+      await act(async () => {
+        resolveUpdate(success());
+        await update;
+      });
+    }
+    expect(button(screen, "Update all")!.disabled).toBe(false);
+  });
+
+  it("names a target it skips for missing environment:maintain", async () => {
+    using _state = setup();
+    const denied = Atom.make<SessionResult>(AsyncResult.success(legacyPairedSession));
+    testState.sessionAtoms.set("batch-b" as EnvironmentId, denied);
+    testState.updateServer.mockResolvedValue(success());
+    await using screen = await mount(targets.slice(0, 2));
+    await click(screen, "Update all");
+
+    expect(testState.updateServer).toHaveBeenCalledTimes(1);
+    expect(testState.updateServer).toHaveBeenCalledWith(
+      expect.objectContaining({ environmentId: "batch-a" }),
+    );
+    const notices = [
+      ...testState.toast.mock.calls.map((call) => JSON.stringify(call)),
+      explanation(screen),
+    ];
+    expect(
+      notices.some(
+        (notice) =>
+          notice.toLowerCase().includes("office") && notice.toLowerCase().includes("pair"),
+      ),
+    ).toBe(true);
+  });
 });
```

## apps/web/src/components/ServerUpdateAction.tsx

Status: modified; +133; −9

```diff
@@ -5,11 +5,12 @@
   sessionGrantsScope,
 } from "@t3tools/contracts";
 import type { AsyncResult } from "effect/reactivity";
+import { environmentIdsKey } from "~/state/environmentAtomList";
+import { environmentSession, environmentSessionStatesAtom } from "~/state/session";
+import {
-import { environmentSession } from "~/state/session";
-import type {
   EnvironmentId,
-  ServerInstallation,
-  ServerSelfUpdateCapability,
+  type ServerInstallation,
+  type ServerSelfUpdateCapability,
 } from "@t3tools/contracts";
 import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server";
 import {
@@ -18,7 +19,7 @@
   squashAtomCommandFailure,
 } from "@t3tools/client-runtime/state/runtime";
 import { CircleArrowUpIcon } from "lucide-react";
-import { type ComponentProps, useRef, useState } from "react";
+import { type ComponentProps, type ReactNode, useId, useRef, useState } from "react";
 
 import { requestConfirmDialog } from "~/confirmDialog";
 import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
@@ -42,6 +43,57 @@
   return UPDATE_STAGE_LABELS[stage];
 }
 
+const PAIR_AGAIN_FOR_UPDATES = "Pair again with a link that permits server updates.";
+
+function updatePermissionMessage(serverLabels: ReadonlyArray<string>): string {
+  return `${serverLabels.join(", ")} can't be updated from this connection. ${PAIR_AGAIN_FOR_UPDATES}`;
+}
+
+/**
+ * A disabled Button ignores pointer events, so the span carries the tooltip, keyboard focus,
+ * and a tap-to-toast fallback for touch screens where tooltips never open.
+ */
+function UpdatePermissionHint({
+  message,
+  descriptionId,
+  disabled,
+  children,
+}: {
+  readonly message: string;
+  readonly descriptionId: string;
+  readonly disabled: boolean;
+  readonly children: ReactNode;
+}) {
+  return (
+    <Tooltip>
+      <TooltipTrigger
+        render={
+          <span
+            className="inline-flex"
+            {...(disabled
+              ? {
+                  tabIndex: 0,
+                  onClick: () =>
+                    toastManager.add({
+                      type: "warning",
+                      title: "Pair again to update",
+                      description: message,
+                    }),
+                }
+              : {})}
+          />
+        }
+      >
+        {children}
+        <span id={descriptionId} className="sr-only">
+          {message}
+        </span>
+      </TooltipTrigger>
+      <TooltipPopup side="top">{message}</TooltipPopup>
+    </Tooltip>
+  );
+}
+
 function updateFailureMessage(error: unknown): string {
   return error instanceof Error ? error.message : "Server update failed.";
 }
@@ -133,12 +185,30 @@
       target.selfUpdate !== null &&
       (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate),
   );
+  const sessionStates = useAtomValue(
+    environmentSessionStatesAtom(environmentIdsKey(eligible.map((target) => target.environmentId))),
+  );
+  const permitted = eligible.filter((_, index) => canUpdateServer(sessionStates[index]!));
+  const denied = eligible.filter((_, index) => isUpdatePermissionDenied(sessionStates[index]!));
+  const descriptionId = useId();
   const handleUpdate = async () => {
     if (pending.current) return;
     pending.current = true;
     setIsPending(true);
     await settlePromise(async () => {
-      const available = eligible.filter(
+      const sessionOf = (target: ServerUpdateTarget) =>
+        appAtomRegistry.get(environmentSession.sessionStateAtom(target.environmentId));
+      const allowed = eligible.filter((target) => canUpdateServer(sessionOf(target)));
+      const skipped = eligible.filter((target) => isUpdatePermissionDenied(sessionOf(target)));
+      if (skipped.length > 0) {
+        toastManager.add({
+          type: "warning",
+          title:
+            skipped.length === 1 ? `${skipped[0]!.serverLabel} skipped` : "Some servers skipped",
+          description: updatePermissionMessage(skipped.map((target) => target.serverLabel)),
+        });
+      }
+      const available = allowed.filter(
         (target) => !pendingUpdateEnvironmentIds.has(target.environmentId),
       );
       const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed");
@@ -154,17 +224,29 @@
     pending.current = false;
     setIsPending(false);
   };
+  const disabled = isPending || permitted.length === 0;
-  return (
+  const button = (
     <Button
       size={size}
       variant={variant}
       className={className}
-      disabled={isPending || eligible.length === 0}
+      disabled={disabled}
+      aria-describedby={denied.length > 0 ? descriptionId : undefined}
       onClick={() => void handleUpdate()}
     >
       {label}
     </Button>
   );
+  if (denied.length === 0) return button;
+  return (
+    <UpdatePermissionHint
+      message={updatePermissionMessage(denied.map((target) => target.serverLabel))}
+      descriptionId={descriptionId}
+      disabled={permitted.length === 0}
+    >
+      {button}
+    </UpdatePermissionHint>
+  );
 }
 
 export function canUpdateServer(
@@ -174,6 +256,13 @@
   return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope);
 }
 
+/** A settled session that lacks the grant, as opposed to one still loading or failing. */
+function isUpdatePermissionDenied(
+  result: AsyncResult.AsyncResult<AuthSessionState, unknown>,
+): boolean {
+  return result._tag === "Success" && !canUpdateServer(result);
+}
+
 export function ServerUpdateProgress({
   state,
 }: {
@@ -218,7 +307,9 @@
 }: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) {
   const isDesktopAppUpdate = selfUpdate === "desktop-managed";
   const sessionStateAtom = environmentSession.sessionStateAtom(environmentId);
+  const sessionState = useAtomValue(sessionStateAtom);
+  const canUpdate = canUpdateServer(sessionState);
-  const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom));
+  const descriptionId = useId();
   const continueThreadsAfterServerUpdate = useEnvironmentSettings(
     environmentId,
     (settings) => settings.continueThreadsAfterServerUpdate,
@@ -290,11 +381,44 @@
         ? "Copy update command"
         : "Copy relaunch command"
       : label;
+  const permissionMessage =
+    manualCommand === null && isUpdatePermissionDenied(sessionState)
+      ? updatePermissionMessage([serverLabel])
+      : undefined;
   const onClick =
     manualCommand !== null
       ? () => copyToClipboard(manualCommand, { command: manualCommand })
       : () => void handleUpdate();
 
+  if (permissionMessage !== undefined) {
+    return (
+      <UpdatePermissionHint message={permissionMessage} descriptionId={descriptionId} disabled>
+        {appearance === "icon" ? (
+          <Button
+            size="icon-xs"
+            variant="ghost-muted"
+            className={className}
+            aria-label={`${actionLabel} for ${serverLabel}`}
+            aria-describedby={descriptionId}
+            disabled
+          >
+            <CircleArrowUpIcon className="size-3.5" />
+          </Button>
+        ) : (
+          <Button
+            size={size}
+            variant={variant}
+            className={className}
+            aria-describedby={descriptionId}
+            disabled
+          >
+            {actionLabel}
+          </Button>
+        )}
+      </UpdatePermissionHint>
+    );
+  }
+
   if (appearance === "icon") {
     return (
       <Tooltip>
```

## knip.jsonc

Status: modified; +1; −0

```diff
@@ -40,6 +40,7 @@
         "scripts/cli.ts",
         "scripts/evaluate-thread-titles.ts",
         "scripts/fetch-expo-schema.ts",
+        "scripts/issue-17034-fixture.mjs",
         "scripts/measure-pr-preview.ts",
         "scripts/probe-claude-fork-local-rollback-replay.ts",
         "scripts/update-test-shard-weights.ts",
```
