Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1e6804e
🤖 feat(server): self-update a supervised mux server from the About di…
ibetitsmike Sep 4, 2026
232c6e4
🤖 refactor(server-update): point the launcher at the staged entry and…
ibetitsmike Sep 4, 2026
67efeac
🤖 fix(server-update): require a stable auth token and a symlink-start…
ibetitsmike Sep 4, 2026
c4d2e36
🤖 fix(server-update): close review gaps in the restart gate and regis…
ibetitsmike Sep 4, 2026
2e9ebe9
🤖 fix(server-update): harden the restart gate, registry trust, and sh…
ibetitsmike Sep 4, 2026
1d4d584
🤖 fix(server-update): follow the mux shim, widen the restart gate, la…
ibetitsmike Sep 4, 2026
479d7b8
🤖 fix(server-update): parse-only staging check, argv launcher, rename…
ibetitsmike Sep 4, 2026
f81412a
🤖 fix(server-update): gate restarts on projects, workflows, desktops,…
ibetitsmike Sep 6, 2026
417b64d
Merge origin/main into mike/server-self-update
ibetitsmike Sep 6, 2026
a3dc2bf
🤖 fix(server-update): refuse RPC calls during teardown, keep staged u…
ibetitsmike Sep 6, 2026
dc45dc1
🤖 fix(server-update): keep aborted config writes in flight, recheck s…
ibetitsmike Sep 6, 2026
000f71b
🤖 fix(server-update): force TLS validation for staged downloads, add …
ibetitsmike Sep 7, 2026
4fb921f
🤖 fix(server-update): verify registry TLS explicitly, require a launc…
ibetitsmike Sep 7, 2026
9df0fb8
🤖 fix(server-update): fetch and digest-check the release before the p…
ibetitsmike Sep 7, 2026
a71b8a5
🤖 fix(server-update): anchor staged dependencies to the registry, req…
ibetitsmike Sep 7, 2026
05d8969
🤖 fix(server-update): mark stages before naming them, abort manifest …
ibetitsmike Sep 7, 2026
02ca834
🤖 fix(server-update): finish short tarball writes, generate version.t…
ibetitsmike Sep 7, 2026
849e0d6
server-update: require every lockfile digest to be published, force l…
ibetitsmike Sep 7, 2026
3b01d8e
server-update: anchor dependency identity to the requested name
ibetitsmike Sep 7, 2026
6f8cd7e
server-update: keep the unique stage name and serialize channel changes
ibetitsmike Sep 7, 2026
9194e6d
server-update: report Windows as unsupported and force npm optional i…
ibetitsmike Sep 7, 2026
a25c2f4
server-update: force pnpm to install optional dependencies
ibetitsmike Sep 7, 2026
7afdf60
server-update: persist the channel choice on unsupported layouts
ibetitsmike Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/config/server-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ Equivalent CLI options:
- `--ssh-host <host>`
- `--add-project <path>`

## Updating the server

Open **About** (or use the **Check for Updates**, **Download Update**, **Install Update and Restart**, and **Update Channel** command palette actions) to check for updates, download, then choose **Install & restart**. The server uses the saved update channel, or infers Nightly from an installed `-next.` version when no channel is saved. Stable follows the npm `latest` tag; Nightly follows `next`. Switching channels can install an older version. Checks and restarts are manual. The registry must be reachable over HTTPS without credentials (`XUM_UPDATE_REGISTRY_URL` or `npm_config_registry` override the default) and must answer metadata and tarball requests itself: redirects are refused, and registries that require authentication for metadata report the registry error at check time. The server downloads the release tarball and verifies it against the registry's published sha512 digest before the package manager installs it and its dependencies. After the install, every dependency recorded in the staged lockfile is checked against the digest the registry publishes for that exact version, so a dependency the package manager fetched through a redirect or from a tampered mirror is refused. Every request validates the registry certificate, ignoring `strict-ssl=false` and `NODE_TLS_REJECT_UNAUTHORIZED`; trust a private CA by starting the server with `NODE_EXTRA_CA_CERTS`, which the server and the package manager both honor (`cafile` is not consulted).

Self-update requires a supervisor that restarts the server after it exits, an external launcher symlink pointing to an installed `@coder/xum` CLI with a bun, npm, or pnpm lockfile, and a stable auth token (`MUX_SERVER_AUTH_TOKEN`, `--auth-token`, or `--no-auth`). A generated token dies with the process, so the relaunched server would lock every browser session out. Set `XUM_BINARY` to that symlink and `XUM_SERVER_SUPERVISED=true` only when a supervisor is configured. The `coder/mux` registry module with `restart_on_kill=true` already declares these through its launcher environment. Unsupported installations show a reason instead of offering an update.

Downloads install an exact package version in a sibling staging directory without changing the running installation. Restart is blocked by active streams, pending turns, workspaces still initializing or being archived, removed, renamed, forked, or staged, workflow runs, project clones and creations, any other request still in flight, queued messages, pending auto-retries, open or starting terminals, live desktop sessions, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version.

After activation, the server exits gracefully and the supervisor relaunches it. Browser clients reconnect and reload when the server build changes. If reconnection takes longer than about 45 seconds, use **Retry**.

<Warning>
The registry module counts self-updates toward `max_restart_attempts`, just like other exits. The
server cannot read the remaining restart budget. Ensure the supervisor has restarts available, or
configure unlimited restarts (`max_restart_attempts=0`) before relying on self-update.
</Warning>

## Related

- [CLI reference](/reference/cli)
4 changes: 3 additions & 1 deletion src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch";
import { ProjectPage } from "@/browser/components/ProjectPage/ProjectPage";

import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
import { AboutDialogProvider } from "./contexts/AboutDialogContext";
import { AboutDialogProvider, useAboutDialog } from "./contexts/AboutDialogContext";
import { ConfirmDialogProvider, useConfirmDialog } from "./contexts/ConfirmDialogContext";
import { AboutDialog } from "./features/About/AboutDialog";
import { SettingsPage } from "@/browser/features/Settings/SettingsPage";
Expand Down Expand Up @@ -184,6 +184,7 @@ function AppInner() {
} = useRouter();
const { themePreference, setTheme, toggleTheme } = useTheme();
const { open: openSettings, isOpen: isSettingsOpen } = useSettings();
const { open: openAboutDialog } = useAboutDialog();
const { confirm: confirmDialog } = useConfirmDialog();
const setThemePreference = useCallback(
(nextTheme: ThemePreference) => {
Expand Down Expand Up @@ -1019,6 +1020,7 @@ function AppInner() {
onToggleTheme: toggleTheme,
onSetTheme: setThemePreference,
onOpenSettings: openSettings,
onOpenAbout: openAboutDialog,
layoutPresets,
onApplyLayoutSlot: (workspaceId, slot) => {
void applySlotToWorkspace(workspaceId, slot).catch(() => {
Expand Down
64 changes: 63 additions & 1 deletion src/browser/contexts/API.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { VERSION } from "@/version";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import type { RecursivePartial } from "@/browser/testUtils";

Expand Down Expand Up @@ -240,6 +241,67 @@ describe("API reconnection", () => {
expect(MockWebSocket.instances).toHaveLength(0);
});

test.each(["changed", "rebuilt", "same", "unreachable", "malformed", "cross-origin"])(
"checks the server version on reconnect: %s",
async (scenario) => {
if (scenario === "cross-origin") process.env.VITE_BACKEND_URL = "https://api.example.com";
const reload = spyOn(window.location, "reload").mockImplementation(() => undefined);
const requests: string[] = [];
fetchImpl = (input) => {
requests.push(
typeof input === "string" ? input : input instanceof URL ? input.href : input.url
);
if (scenario === "unreachable") return Promise.reject(new Error("offline"));
return Promise.resolve(
new Response(
JSON.stringify(
scenario === "malformed"
? {}
: {
git_commit:
scenario === "changed" ? "different-server-commit" : VERSION.git_commit,
git_describe: scenario === "rebuilt" ? "v9.9.9-rebuilt" : VERSION.git_describe,
}
),
{ status: 200 }
)
);
};
window.location.href = "https://coder.example.com/@u/ws/apps/mux/";
let latestState: UseAPIResult | null = null;
render(
<APIProvider createWebSocket={createMockWebSocket}>
<APIStateObserver
onState={(s) => {
latestState = s.apiState;
}}
/>
</APIProvider>
);
await act(async () => {
MockWebSocket.lastInstance()!.simulateOpen();
await Promise.resolve();
});
expect(latestState!.status).toBe("connected");
expect(requests).toEqual([]);
act(() => {
latestState!.retry();
});
await act(async () => {
MockWebSocket.lastInstance()!.simulateOpen();
await Promise.resolve();
});
expect(requests).toEqual(
scenario === "cross-origin" ? [] : ["https://coder.example.com/@u/ws/apps/mux/version"]
);
const reloads = scenario === "changed" || scenario === "rebuilt" ? 1 : 0;
expect(reload).toHaveBeenCalledTimes(reloads);
if (reloads === 0) expect(latestState!.status).toBe("connected");
reload.mockRestore();
delete process.env.VITE_BACKEND_URL;
}
);

test("reconnects on close without showing auth_required when previously connected", async () => {
const states: string[] = [];

Expand Down
41 changes: 41 additions & 0 deletions src/browser/contexts/API.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { SERVER_VERSION_CHECK_TIMEOUT_MS } from "@/constants/serverUpdate";
import { VERSION } from "@/version";
import {
createContext,
useContext,
Expand Down Expand Up @@ -148,6 +150,33 @@ function createBrowserClient(
};
}

async function reloadIfServerBuildChanged(
backendBaseUrl: string,
isCurrentConnection: () => boolean
): Promise<void> {
try {
const response = await fetch(`${backendBaseUrl}/version`, {
cache: "no-store",
signal: AbortSignal.timeout(SERVER_VERSION_CHECK_TIMEOUT_MS),
});
const version: unknown = response.ok ? await response.json() : null;
if (
isCurrentConnection() &&
version &&
typeof version === "object" &&
"git_commit" in version &&
typeof version.git_commit === "string" &&
version.git_commit.length > 0 &&
(version.git_commit !== VERSION.git_commit ||
("git_describe" in version && version.git_describe !== VERSION.git_describe))
) {
window.location.reload();
}
} catch {
// Version discovery must not disturb an already reconnected client.
}
}

function ManagedAPIProvider(props: Omit<APIProviderProps, "client">) {
const [state, setState] = useState<ConnectionState>({ status: "connecting" });
const [authToken, setAuthToken] = useState<string | null>(() => {
Expand Down Expand Up @@ -261,6 +290,7 @@ function ManagedAPIProvider(props: Omit<APIProviderProps, "client">) {
return;
}

const reconnected = hasConnectedRef.current;
authRequiredRef.current = false;
hasConnectedRef.current = true;
reconnectAttemptRef.current = 0;
Expand All @@ -269,6 +299,17 @@ function ManagedAPIProvider(props: Omit<APIProviderProps, "client">) {
window.__ORPC_CLIENT__ = client;
cleanupRef.current = cleanup;
setState({ status: "connected", client, cleanup });
// A reconnected socket may belong to a newer server than this loaded bundle. The probe
// runs after the client is published so a slow /version never delays reconnection, and
// only a bundle served by that server can be refreshed by reloading, so split-origin
// setups (VITE_BACKEND_URL, extension webviews) skip it.
const backendBaseUrl = getBrowserBackendBaseUrl();
if (reconnected && new URL(backendBaseUrl).origin === window.location.origin) {
void reloadIfServerBuildChanged(
backendBaseUrl,
() => connectionId === connectionIdRef.current
);
}
})
.catch((err: unknown) => {
if (connectionId !== connectionIdRef.current) {
Expand Down
100 changes: 100 additions & 0 deletions src/browser/features/About/AboutDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "@storybook/test";
import type { UpdateStatus } from "@/common/orpc/types";
import { APIProvider } from "@/browser/contexts/API";
import { AboutDialogProvider, useAboutDialog } from "@/browser/contexts/AboutDialogContext";
import { Button } from "@/browser/components/Button/Button";
import { lightweightMeta } from "@/browser/stories/meta";
import { createMockORPCClient } from "@/browser/stories/mocks/orpc";
import { AboutDialog } from "./AboutDialog";

function OpenAbout() {
const about = useAboutDialog();
return (
<>
<Button onClick={about.open}>Open About</Button>
<AboutDialog />
</>
);
}

function ServerUpdateStory(props: { status: UpdateStatus }) {
const [client] = useState(() =>
createMockORPCClient({ updateStatus: props.status, updateChannel: "nightly" })
);
return (
<APIProvider client={client}>
<AboutDialogProvider>
<OpenAbout />
</AboutDialogProvider>
</APIProvider>
);
}

const meta = {
...lightweightMeta,
title: "Features/About/Server updates",
component: ServerUpdateStory,
play: async ({ canvasElement }) => {
await userEvent.click(within(canvasElement).getByRole("button", { name: "Open About" }));
},
} satisfies Meta<typeof ServerUpdateStory>;
export default meta;
type Story = StoryObj<typeof meta>;

export const Unsupported: Story = {
args: {
status: {
type: "unsupported",
reason: "Server updates require a supervisor configured to restart after exit",
},
},
play: async (context) => {
await meta.play(context);
const dialog = await within(document.body).findByRole("dialog");
await expect(
within(dialog).queryByRole("button", { name: "Install & restart" })
).not.toBeInTheDocument();
await expect(
within(dialog).queryByRole("button", { name: "Check for Updates" })
).not.toBeInTheDocument();
},
};

export const Downloading: Story = {
args: { status: { type: "downloading", percent: null } },
play: async (context) => {
await meta.play(context);
const dialog = await within(document.body).findByRole("dialog");
await expect(within(dialog).getByRole("button", { name: "Check for Updates" })).toBeDisabled();
},
};

export const BlockedPhone: Story = {
args: {
status: {
type: "install-blocked",
info: { version: "0.28.4-next.123.g123456789" },
blockers: [
{ kind: "pending-turns", count: 2 },
{ kind: "terminals", count: 1 },
],
},
},
parameters: { pixel: { matrix: { viewports: ["phone"] } } },
globals: { viewport: { value: "mobile1", isRotated: false } },
play: async (context) => {
await meta.play(context);
const dialog = await within(document.body).findByRole("dialog");
const retry = within(dialog).getByRole("button", { name: "Install & restart" });
await expect(retry).toBeEnabled();
await expect(within(dialog).getByRole("status")).toBeVisible();
if (window.innerWidth <= 440) {
await expect(dialog.getBoundingClientRect().right).toBeLessThanOrEqual(window.innerWidth);
await expect(retry.getBoundingClientRect().right).toBeLessThanOrEqual(
dialog.getBoundingClientRect().right
);
}
},
};
Loading
Loading