From 1e6804e5986960183da5030fda8dadc94ad9bc75 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:23:50 +0000 Subject: [PATCH 01/22] =?UTF-8?q?=F0=9F=A4=96=20feat(server):=20self-updat?= =?UTF-8?q?e=20a=20supervised=20mux=20server=20from=20the=20About=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerUpdater fills the UpdateService impl slot in CLI mode. It recognizes the coder/mux launcher layout (external bin symlink into a bun/npm/pnpm install) and a declared restart supervisor, checks the npm dist-tag for the effective channel, stages the exact version into a sibling xum-staging- dir, and on install atomically re-points the launcher symlink and runs the SIGTERM cleanup path so the supervisor relaunches the new version. The restart is refused while volatile server-owned work exists (active streams, pending turn work, queued messages, pending auto-retry, open or starting terminals, running background processes or direct commands), and shutdown now latches new session, terminal, and command admission before the first await. Unrecognized layouts report unsupported and every action no-ops. The About dialog works in browser mode (unsupported reason, install-blocked blockers, indeterminate download), and browser clients reload when GET /version differs from the bundled build after reconnecting. --- _Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking: `xhigh` • Cost: `$60.84`_ --- docs/config/server-access.mdx | 16 + src/browser/contexts/API.test.tsx | 58 ++- src/browser/contexts/API.tsx | 28 +- .../features/About/AboutDialog.stories.tsx | 100 +++++ src/browser/features/About/AboutDialog.tsx | 66 +++- src/browser/stories/mocks/orpc.ts | 12 +- src/cli/server.ts | 6 + src/common/orpc/schemas/stream.ts | 20 +- src/common/orpc/types.ts | 1 + src/constants/serverUpdate.ts | 5 + src/desktop/main.ts | 3 +- .../builtInSkillContent.generated.ts | 16 + src/node/services/backgroundProcessManager.ts | 5 + src/node/services/serverUpdate/activation.ts | 21 ++ .../services/serverUpdate/installLayout.ts | 113 ++++++ src/node/services/serverUpdate/registry.ts | 23 ++ .../serverUpdate/serverUpdate.test.ts | 344 ++++++++++++++++++ .../services/serverUpdate/serverUpdater.ts | 149 ++++++++ src/node/services/serverUpdate/staging.ts | 83 +++++ src/node/services/serviceContainer.test.ts | 79 ++++ src/node/services/serviceContainer.ts | 21 +- src/node/services/terminalService.test.ts | 2 + src/node/services/terminalService.ts | 13 + src/node/services/updateService.ts | 43 ++- src/node/services/workspaceService.ts | 28 ++ src/node/utils/disposableExec.ts | 2 + tests/ipc/update.test.ts | 23 ++ 27 files changed, 1239 insertions(+), 41 deletions(-) create mode 100644 src/browser/features/About/AboutDialog.stories.tsx create mode 100644 src/constants/serverUpdate.ts create mode 100644 src/node/services/serverUpdate/activation.ts create mode 100644 src/node/services/serverUpdate/installLayout.ts create mode 100644 src/node/services/serverUpdate/registry.ts create mode 100644 src/node/services/serverUpdate/serverUpdate.test.ts create mode 100644 src/node/services/serverUpdate/serverUpdater.ts create mode 100644 src/node/services/serverUpdate/staging.ts create mode 100644 tests/ipc/update.test.ts diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx index 3e273d3aa51..48b1991609a 100644 --- a/docs/config/server-access.mdx +++ b/docs/config/server-access.mdx @@ -78,6 +78,22 @@ Equivalent CLI options: - `--ssh-host ` - `--add-project ` +## Updating the server + +Open **About** 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. + +Self-update requires a supervisor that restarts the server after it exits, and an external launcher symlink pointing to an installed `@coder/xum` CLI with a bun, npm, or pnpm lockfile. 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, queued messages, pending auto-retries, open or starting terminals, 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**. + + + 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. + + ## Related - [CLI reference](/reference/cli) diff --git a/src/browser/contexts/API.test.tsx b/src/browser/contexts/API.test.tsx index fc76c2d5e94..5f9ba5cdfb1 100644 --- a/src/browser/contexts/API.test.tsx +++ b/src/browser/contexts/API.test.tsx @@ -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"; @@ -240,6 +241,61 @@ describe("API reconnection", () => { expect(MockWebSocket.instances).toHaveLength(0); }); + test.each(["changed", "same", "unreachable", "malformed"])( + "checks the server version on reconnect: %s", + async (scenario) => { + 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, + } + ), + { status: 200 } + ) + ); + }; + window.location.href = "https://coder.example.com/@u/ws/apps/mux/"; + let latestState: UseAPIResult | null = null; + render( + + { + latestState = s.apiState; + }} + /> + + ); + 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(["https://coder.example.com/@u/ws/apps/mux/version"]); + expect(reload).toHaveBeenCalledTimes(scenario === "changed" ? 1 : 0); + if (scenario !== "changed") expect(latestState!.status).toBe("connected"); + reload.mockRestore(); + } + ); + test("reconnects on close without showing auth_required when previously connected", async () => { const states: string[] = []; diff --git a/src/browser/contexts/API.tsx b/src/browser/contexts/API.tsx index 0194b5ab042..6ace877cf6a 100644 --- a/src/browser/contexts/API.tsx +++ b/src/browser/contexts/API.tsx @@ -1,3 +1,5 @@ +import { SERVER_VERSION_CHECK_TIMEOUT_MS } from "@/constants/serverUpdate"; +import { VERSION } from "@/version"; import { createContext, useContext, @@ -254,7 +256,31 @@ function ManagedAPIProvider(props: Omit) { client.general .ping("auth-check") - .then(() => { + .then(async () => { + // A reconnected socket may belong to a newer server than this loaded bundle. + if (hasConnectedRef.current && connectionId === connectionIdRef.current) { + try { + const response = await fetch(new URL(getBrowserBackendBaseUrl() + "/version"), { + cache: "no-store", + signal: AbortSignal.timeout(SERVER_VERSION_CHECK_TIMEOUT_MS), + }); + const version: unknown = response.ok ? await response.json() : null; + if ( + connectionId === connectionIdRef.current && + version && + typeof version === "object" && + "git_commit" in version && + typeof version.git_commit === "string" && + version.git_commit.length > 0 && + version.git_commit !== VERSION.git_commit + ) { + window.location.reload(); + return; + } + } catch { + // Version discovery must not prevent reconnecting after a transient HTTP failure. + } + } // Ignore stale connections (e.g., auth-check returned after a new connect()). if (connectionId !== connectionIdRef.current) { cleanup(); diff --git a/src/browser/features/About/AboutDialog.stories.tsx b/src/browser/features/About/AboutDialog.stories.tsx new file mode 100644 index 00000000000..26464b39c71 --- /dev/null +++ b/src/browser/features/About/AboutDialog.stories.tsx @@ -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 ( + <> + + + + ); +} + +function ServerUpdateStory(props: { status: UpdateStatus }) { + const [client] = useState(() => + createMockORPCClient({ updateStatus: props.status, updateChannel: "nightly" }) + ); + return ( + + + + + + ); +} + +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; +export default meta; +type Story = StoryObj; + +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 + ); + } + }, +}; diff --git a/src/browser/features/About/AboutDialog.tsx b/src/browser/features/About/AboutDialog.tsx index fc682f054f7..3b315015795 100644 --- a/src/browser/features/About/AboutDialog.tsx +++ b/src/browser/features/About/AboutDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Download, Loader2, RefreshCw } from "lucide-react"; import { VERSION } from "@/version"; -import type { UpdateStatus } from "@/common/orpc/types"; +import type { RestartBlocker, UpdateStatus } from "@/common/orpc/types"; import type { UpdateChannel } from "@/common/types/project"; import XumLogoDark from "@/browser/assets/logos/xum-logo-dark.svg?react"; import XumLogoLight from "@/browser/assets/logos/xum-logo-light.svg?react"; @@ -15,6 +15,15 @@ import { ToggleGroupItem, } from "@/browser/components/ToggleGroupPrimitive/ToggleGroupPrimitive"; +const blockerLabels: Record = { + "active-streams": "Active streams", + "pending-turns": "Pending turns", + "queued-messages": "Sessions with queued messages", + "auto-retries": "Pending auto-retries", + terminals: "Open terminals", + "background-processes": "Running background processes", +}; + interface VersionRecord { buildTime?: unknown; git?: unknown; @@ -75,10 +84,8 @@ export function AboutDialog() { const [pendingAction, setPendingAction] = useState<"check" | "download" | "install" | null>(null); const channelRequestTokenRef = useRef(0); - const isDesktop = typeof window !== "undefined" && Boolean(window.api); - useEffect(() => { - if (!isOpen || !isDesktop || !api) { + if (!isOpen || !api) { return; } @@ -105,10 +112,10 @@ export function AboutDialog() { return () => { controller.abort(); }; - }, [api, isDesktop, isOpen]); + }, [api, isOpen]); useEffect(() => { - if (!isOpen || !isDesktop || !api) { + if (!isOpen || !api) { return; } @@ -128,9 +135,9 @@ export function AboutDialog() { return () => { active = false; }; - }, [api, isDesktop, isOpen]); + }, [api, isOpen]); - const canUseUpdateApi = isDesktop && Boolean(api); + const canUseUpdateApi = Boolean(api); const isChecking = canUseUpdateApi && (updateStatus.type === "checking" || @@ -216,12 +223,10 @@ export function AboutDialog() {
Updates
- {!isDesktop ? ( -
- Desktop updates are available in the Electron app only. -
+ {updateStatus.type === "unsupported" ? ( +
{updateStatus.reason}
) : !canUseUpdateApi ? ( -
Connecting to desktop update service…
+
Connecting to update service…
) : ( <> {channel !== null && ( @@ -236,7 +241,7 @@ export function AboutDialog() { handleChannelChange(next); } }} - disabled={channelLoading} + disabled={channelLoading || isChecking || pendingAction === "install"} aria-label="Update channel" size="sm" > @@ -271,8 +276,8 @@ export function AboutDialog() { )} {updateStatus.type === "available" && ( -
-
+
+
Update available: {updateStatus.info.version}
)} - {updateStatus.type === "downloaded" && ( -
-
+ {(updateStatus.type === "downloaded" || updateStatus.type === "install-blocked") && ( +
+
Ready to install: {updateStatus.info.version}
)} + {updateStatus.type === "install-blocked" && ( +
+
Finish or stop this work, then retry the restart:
+
    + {updateStatus.blockers.map((blocker) => ( +
  • + {blockerLabels[blocker.kind]}:{" "} + {blocker.count} +
  • + ))} +
+
+ )} + {updateStatus.type === "up-to-date" && (
Xum is up to date.
)} diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index bd940cd2d7a..b1cb7807125 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -32,7 +32,9 @@ import type { ProvidersConfigMap, WorkspaceStatsSnapshot, ServerAuthSession, + UpdateStatus, } from "@/common/orpc/types"; +import type { UpdateChannel } from "@/common/types/project"; import type { ProjectGitStatusResult as ApiProjectGitStatusResult } from "@/common/orpc/schemas/api"; import type { MuxMessage } from "@/common/types/message"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -189,6 +191,10 @@ export interface MockORPCClientOptions { memoryConsolidationStatus?: MemoryConsolidationStatusPayload; /** Optional file contents for memory.read keyed by virtual path. */ memoryFileContents?: Map; + /** Initial updater status for update.onStatus (About dialog stories). */ + updateStatus?: UpdateStatus; + /** Release channel for update.getChannel. */ + updateChannel?: UpdateChannel; /** Initial route priority for config.getConfig */ routePriority?: string[]; /** Initial per-model route overrides for config.getConfig */ @@ -416,6 +422,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl memoryFiles = [], memoryConsolidationStatus, memoryFileContents = new Map(), + updateStatus, + updateChannel = "stable", routePriority: initialRoutePriority = ["direct"], routeOverrides: initialRouteOverrides = {}, agentDefinitions: initialAgentDefinitions, @@ -1998,10 +2006,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl download: () => Promise.resolve(undefined), install: () => Promise.resolve(undefined), onStatus: async function* () { - yield* []; + if (updateStatus) yield updateStatus; await new Promise(() => undefined); }, - getChannel: () => Promise.resolve("stable" as const), + getChannel: () => Promise.resolve(updateChannel), setChannel: () => Promise.resolve(undefined), }, policy: { diff --git a/src/cli/server.ts b/src/cli/server.ts index 03301b259d3..f1cd05e1ee9 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -254,6 +254,7 @@ async function main(): Promise { }, 5000); try { + serviceContainer.terminalService.beginShutdown(); // Close all PTY sessions first shutdownStep("terminalService.closeAllSessions", () => serviceContainer.terminalService.closeAllSessions() @@ -286,6 +287,11 @@ async function main(): Promise { } }; + await serviceContainer.updateService.enableServerUpdater({ + collectBlockers: () => serviceContainer.collectRestartBlockers(), + restart: cleanup, + }); + process.on("SIGINT", () => void cleanup()); process.on("SIGTERM", () => void cleanup()); } diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 6108159fcbe..ab76e8eb575 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -721,12 +721,30 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [ ]); // Update Status +const RestartBlockerSchema = z.object({ + kind: z.enum([ + "active-streams", + "pending-turns", + "queued-messages", + "auto-retries", + "terminals", + "background-processes", + ]), + count: z.number().int().positive(), +}); + export const UpdateStatusSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("idle") }), z.object({ type: z.literal("checking") }), z.object({ type: z.literal("available"), info: z.object({ version: z.string() }) }), z.object({ type: z.literal("up-to-date") }), - z.object({ type: z.literal("downloading"), percent: z.number() }), + z.object({ type: z.literal("unsupported"), reason: z.string() }), + z.object({ + type: z.literal("install-blocked"), + info: z.object({ version: z.string() }), + blockers: z.array(RestartBlockerSchema), + }), + z.object({ type: z.literal("downloading"), percent: z.number().nullable() }), z.object({ type: z.literal("downloaded"), info: z.object({ version: z.string() }) }), z.object({ type: z.literal("error"), diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index 85c3e55bd82..e1b3bfc9d8d 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -50,6 +50,7 @@ export type DeleteMessage = z.infer; export type GoalBudgetLimitedEvent = z.infer; export type WorkspaceInitEvent = z.infer; export type UpdateStatus = z.infer; +export type RestartBlocker = Extract["blockers"][number]; export type DesktopPrereqStatus = z.infer; export type ChatMuxMessage = z.infer; export type WorkspaceStatsSnapshot = z.infer; diff --git a/src/constants/serverUpdate.ts b/src/constants/serverUpdate.ts new file mode 100644 index 00000000000..93397f87e7f --- /dev/null +++ b/src/constants/serverUpdate.ts @@ -0,0 +1,5 @@ +export const SERVER_UPDATE_CHECK_TIMEOUT_MS = 30_000; +export const SERVER_UPDATE_INSTALL_TIMEOUT_MS = 5 * 60_000; +export const SERVER_UPDATE_SMOKE_TIMEOUT_MS = 30_000; +export const SERVER_UPDATE_STAGING_PREFIX = "xum-staging-"; +export const SERVER_VERSION_CHECK_TIMEOUT_MS = 5_000; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 714c5b33fc2..caf499d1f22 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -997,8 +997,7 @@ function createWindow() { void prompt .then(({ response }) => { if (response === 0) { - services?.updateService.install(); - return; + return services?.updateService.install(); } if (response === 1) { diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index f6ca94d4100..82a8f4608c7 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4741,6 +4741,22 @@ export const BUILTIN_SKILL_FILES: Record> = { "- `--ssh-host `", "- `--add-project `", "", + "## Updating the server", + "", + "Open **About** 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.", + "", + "Self-update requires a supervisor that restarts the server after it exits, and an external launcher symlink pointing to an installed `@coder/xum` CLI with a bun, npm, or pnpm lockfile. 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, queued messages, pending auto-retries, open or starting terminals, 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**.", + "", + "", + " 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.", + "", + "", "## Related", "", "- [CLI reference](/reference/cli)", diff --git a/src/node/services/backgroundProcessManager.ts b/src/node/services/backgroundProcessManager.ts index ead41d842a8..a910afa927e 100644 --- a/src/node/services/backgroundProcessManager.ts +++ b/src/node/services/backgroundProcessManager.ts @@ -2381,6 +2381,11 @@ export class BackgroundProcessManager extends EventEmitter process.status === "running") + .length; + } + /** * Synchronous snapshot: whether any tracked background (non-foreground) process for the * workspace is still marked running. Statuses refresh lazily (see list()), so a just-exited diff --git a/src/node/services/serverUpdate/activation.ts b/src/node/services/serverUpdate/activation.ts new file mode 100644 index 00000000000..1232f2e522b --- /dev/null +++ b/src/node/services/serverUpdate/activation.ts @@ -0,0 +1,21 @@ +import { lstatSync, realpathSync, renameSync, symlinkSync, unlinkSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import type { InstallLayout } from "./installLayout"; + +export function activateUpdate(layout: InstallLayout, stagedBin: string): void { + if ( + !lstatSync(layout.launcher).isSymbolicLink() || + realpathSync(layout.launcher) !== layout.entry + ) { + throw new Error("Server launcher changed since startup"); + } + realpathSync(stagedBin); + const temporary = `${layout.launcher}.${randomUUID()}.tmp`; + symlinkSync(stagedBin, temporary); + try { + renameSync(temporary, layout.launcher); + } catch (error) { + unlinkSync(temporary); + throw error; + } +} diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts new file mode 100644 index 00000000000..37d373103b8 --- /dev/null +++ b/src/node/services/serverUpdate/installLayout.ts @@ -0,0 +1,113 @@ +import { getErrorMessage } from "@/common/utils/errors"; +import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; +import * as path from "node:path"; +import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; +import type { UpdateChannel } from "@/common/types/project"; + +export interface InstallLayout { + launcher: string; + entry: string; + workdir: string; + packageManager: "bun" | "npm" | "pnpm"; + version: string; + registry: string; +} + +export type LayoutResult = + | { supported: true; layout: InstallLayout } + | { supported: false; reason: string }; + +export function inferChannel(version: string): UpdateChannel { + return version.includes("-next.") ? "nightly" : "stable"; +} + +export function isExactVersion(value: unknown): value is string { + return ( + typeof value === "string" && + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value) + ); +} + +export function readPackageVersion(packageDir: string): string { + const pkg: unknown = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")); + if ( + !pkg || + typeof pkg !== "object" || + !("name" in pkg) || + pkg.name !== "@coder/xum" || + !("version" in pkg) || + !isExactVersion(pkg.version) + ) { + throw new Error("Expected an installed @coder/xum package with an exact version"); + } + return pkg.version; +} + +export function resolveInstallLayout( + env: NodeJS.ProcessEnv, + argv: readonly string[] +): LayoutResult { + try { + // coder/mux registry module v1.5 declares its restart loop through RESTART_ON_KILL_VALUE. + const supervised = resolveXumEnvironmentValue("SERVER_SUPERVISED", env); + if (!(/^(1|true|yes)$/i.test(supervised ?? "") || env.RESTART_ON_KILL_VALUE === "true")) { + throw new Error("Server updates require a supervisor configured to restart after exit"); + } + const running = argv[1]; + const binary = resolveXumEnvironmentValue("BINARY", env) ?? running; + if (!running || !binary) throw new Error("Cannot identify the server launcher"); + const launcher = path.resolve(binary); + if (!lstatSync(launcher).isSymbolicLink()) throw new Error("Server launcher must be a symlink"); + const entry = realpathSync(running); + if (realpathSync(launcher) !== entry) + throw new Error("Server launcher does not point to the running entry"); + const packageDir = path.dirname(path.dirname(path.dirname(entry))); + if (entry !== path.join(packageDir, "dist", "cli", "index.js")) + throw new Error("Unsupported server entry layout"); + const version = readPackageVersion(packageDir); + let workdir: string | undefined; + let packageManager: InstallLayout["packageManager"] | undefined; + for (let dir = packageDir; path.dirname(dir) !== dir; dir = path.dirname(dir)) { + if (path.basename(dir) !== "node_modules") continue; + const parent = path.dirname(dir); + const managers: Array = []; + if (["bun.lock", "bun.lockb"].some((lock) => existsSync(path.join(parent, lock)))) + managers.push("bun"); + if (existsSync(path.join(parent, "package-lock.json"))) managers.push("npm"); + if (existsSync(path.join(parent, "pnpm-lock.yaml"))) managers.push("pnpm"); + if (managers.length > 1) throw new Error("Ambiguous package manager lockfiles"); + if (managers.length === 1) { + workdir = parent; + packageManager = managers[0]; + break; + } + } + if (!workdir || !packageManager) throw new Error("No supported package manager lockfile found"); + if (launcher.startsWith(workdir + path.sep)) + throw new Error("Server launcher must be outside the package installation"); + const registry = + env.XUM_UPDATE_REGISTRY_URL ?? env.npm_config_registry ?? "https://registry.npmjs.org"; + const url = new URL(registry); + if ( + !["https:", "http:"].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) + throw new Error("Unsupported update registry URL"); + return { + supported: true, + layout: { + launcher, + entry, + workdir, + packageManager, + version, + registry: registry.replace(/\/+$/, ""), + }, + }; + } catch (error) { + return { supported: false, reason: getErrorMessage(error) }; + } +} diff --git a/src/node/services/serverUpdate/registry.ts b/src/node/services/serverUpdate/registry.ts new file mode 100644 index 00000000000..b8b76fbe0a3 --- /dev/null +++ b/src/node/services/serverUpdate/registry.ts @@ -0,0 +1,23 @@ +import { EnvHttpProxyAgent, type Dispatcher } from "undici"; +import { SERVER_UPDATE_CHECK_TIMEOUT_MS } from "@/constants/serverUpdate"; +import { isExactVersion } from "./installLayout"; + +const dispatcher = new EnvHttpProxyAgent(); + +export async function fetchDistTags( + registry: string, + request: (url: string, options: RequestInit) => Promise = fetch +): Promise<{ latest?: string; next?: string }> { + const options: RequestInit & { dispatcher: Dispatcher } = { + dispatcher, + signal: AbortSignal.timeout(SERVER_UPDATE_CHECK_TIMEOUT_MS), + }; + const response = await request(`${registry}/-/package/@coder%2Fxum/dist-tags`, options); + if (!response.ok) throw new Error(`Registry returned HTTP ${response.status}`); + const tags: unknown = await response.json(); + if (!tags || typeof tags !== "object") throw new Error("Invalid registry dist-tags response"); + return { + latest: "latest" in tags && isExactVersion(tags.latest) ? tags.latest : undefined, + next: "next" in tags && isExactVersion(tags.next) ? tags.next : undefined, + }; +} diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts new file mode 100644 index 00000000000..ce098501e17 --- /dev/null +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -0,0 +1,344 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import type { RestartBlocker, UpdateStatus } from "@/common/orpc/types"; +import { resolveInstallLayout, inferChannel, type InstallLayout } from "./installLayout"; +import { execFileAsync } from "@/node/utils/disposableExec"; +import { activateUpdate } from "./activation"; +import { installCommand, stageUpdate, verifyStagedPackage } from "./staging"; +import { fetchDistTags } from "./registry"; +import { ServerUpdater, type ServerUpdaterDeps } from "./serverUpdater"; + +const dirs: string[] = []; +afterEach(async () => { + for (const dir of dirs.splice(0)) await fs.rm(dir, { recursive: true, force: true }); +}); + +async function writePackage( + workdir: string, + version: string, + script = "console.log('test version')" +) { + const packageDir = path.join(workdir, "node_modules/@coder/xum"); + const entry = path.join(packageDir, "dist/cli/index.js"); + await fs.mkdir(path.dirname(entry), { recursive: true }); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "@coder/xum", version }) + ); + await fs.writeFile(entry, script); + const bin = path.join(workdir, "node_modules/.bin/mux"); + await fs.mkdir(path.dirname(bin), { recursive: true }); + await fs.symlink(entry, bin); + return { entry, bin }; +} + +async function fixture(manager: InstallLayout["packageManager"] = "bun", version = "1.0.0-next.1") { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "server-update-")); + dirs.push(root); + const workdir = path.join(root, "npm"); + const { entry, bin } = await writePackage(workdir, version); + if (manager === "pnpm") { + const packageDir = path.join(workdir, "node_modules/@coder/xum"); + const storeDir = path.join(workdir, "node_modules/.pnpm/xum/node_modules/@coder/xum"); + await fs.mkdir(path.dirname(storeDir), { recursive: true }); + await fs.rename(packageDir, storeDir); + await fs.symlink(storeDir, packageDir); + } + const lockfiles = { bun: "bun.lock", npm: "package-lock.json", pnpm: "pnpm-lock.yaml" }; + await fs.writeFile(path.join(workdir, lockfiles[manager]), ""); + const launcher = path.join(root, "mux"); + await fs.symlink(bin, launcher); + const env = { MUX_BINARY: launcher, RESTART_ON_KILL_VALUE: "true" }; + const argv = ["node", entry]; + const result = resolveInstallLayout(env, argv); + if (!result.supported) throw new Error(result.reason); + return { root, env, argv, layout: result.layout }; +} + +async function expectFailure(run: () => Promise) { + let failed = false; + try { + await run(); + } catch { + failed = true; + } + expect(failed).toBe(true); +} + +describe("server install layout", () => { + for (const manager of ["bun", "npm", "pnpm"] as const) { + test("recognizes " + manager + " and infers the release channel", async () => { + const { layout } = await fixture(manager); + expect(layout.packageManager).toBe(manager); + expect(layout.version).toBe("1.0.0-next.1"); + expect(inferChannel(layout.version)).toBe("nightly"); + expect(inferChannel("1.0.0")).toBe("stable"); + }); + } + test("requires supervisor, a symlink, and a matching running entry", async () => { + const { env, argv, layout, root } = await fixture(); + expect(resolveInstallLayout({ MUX_BINARY: env.MUX_BINARY }, argv).supported).toBe(false); + expect(resolveInstallLayout({ RESTART_ON_KILL_VALUE: "true" }, argv).supported).toBe(false); + expect( + resolveInstallLayout({ XUM_SERVER_SUPERVISED: "1" }, ["node", layout.launcher]).supported + ).toBe(true); + const other = await writePackage(path.join(root, "other"), "2.0.0"); + await fs.writeFile(path.join(root, "other/bun.lock"), ""); + expect(resolveInstallLayout(env, ["node", other.entry]).supported).toBe(false); + }); + test("honors canonical environment values and registry precedence", async () => { + const { env, argv, layout } = await fixture(); + const result = resolveInstallLayout( + { + ...env, + XUM_BINARY: layout.launcher, + MUX_BINARY: "/missing", + XUM_UPDATE_REGISTRY_URL: "https://registry.example.com/", + npm_config_registry: "https://ignored.invalid", + }, + argv + ); + expect(result.supported && result.layout.registry).toBe("https://registry.example.com"); + }); + test("fails closed for missing or conflicting lockfiles and malformed package metadata", async () => { + const { env, argv, layout } = await fixture(); + await fs.writeFile(path.join(layout.workdir, "package-lock.json"), "{}"); + expect(resolveInstallLayout(env, argv).supported).toBe(false); + await fs.unlink(path.join(layout.workdir, "package-lock.json")); + await fs.unlink(path.join(layout.workdir, "bun.lock")); + expect(resolveInstallLayout(env, argv).supported).toBe(false); + await fs.writeFile(path.join(layout.workdir, "bun.lockb"), ""); + expect(resolveInstallLayout(env, argv).supported).toBe(true); + await fs.writeFile(path.join(layout.workdir, "node_modules/@coder/xum/package.json"), "{}"); + expect(resolveInstallLayout(env, argv).supported).toBe(false); + }); +}); + +describe("staging and activation", () => { + test("package install commands execute in the isolated staging cwd", async () => { + const { layout } = await fixture(); + using command = execFileAsync("node", ["-p", "process.cwd()"], { cwd: layout.workdir }); + expect((await command.result).stdout.trim()).toBe(layout.workdir); + }); + test("installs an exact version with lifecycle scripts disabled for every manager", async () => { + const { layout } = await fixture(); + for (const packageManager of ["bun", "npm", "pnpm"] as const) { + const command = installCommand({ ...layout, packageManager }, "2.0.0"); + expect(command.file).toBe(packageManager); + expect(command.args).toContain("@coder/xum@2.0.0"); + expect(command.args).toContain("--ignore-scripts"); + expect(command.args.slice(-2)).toEqual(["--registry", layout.registry]); + } + expect(() => installCommand(layout, "../../escape")).toThrow(); + }); + test("prunes only old stages, preserves active and original installs, and swaps atomically", async () => { + const { layout, root } = await fixture(); + const oldEntry = await fs.readFile(layout.entry, "utf8"); + const stale = path.join(root, "xum-staging-0.9.0"); + await fs.mkdir(stale); + const bin = await stageUpdate(layout, "2.0.0", async (_file, _args, cwd) => { + await writePackage(cwd, "2.0.0"); + await fs.writeFile(path.join(cwd, "bun.lock"), ""); + }); + expect(await fs.readdir(root)).not.toContain("xum-staging-0.9.0"); + activateUpdate(layout, bin); + expect(await fs.readlink(layout.launcher)).toBe(bin); + expect(await fs.readFile(layout.entry, "utf8")).toBe(oldEntry); + const result = resolveInstallLayout( + { MUX_BINARY: layout.launcher, RESTART_ON_KILL_VALUE: "true" }, + ["node", layout.launcher] + ); + if (!result.supported) throw new Error(result.reason); + expect(result.layout.version).toBe("2.0.0"); + await stageUpdate(result.layout, "3.0.0", async (_file, _args, cwd) => { + await writePackage(cwd, "3.0.0"); + }); + expect((await fs.readdir(root)).filter((name) => name.startsWith("xum-staging-"))).toHaveLength( + 2 + ); + expect(await fs.realpath(layout.launcher)).toBe(await fs.realpath(bin)); + expect(await fs.readFile(layout.entry, "utf8")).toBe(oldEntry); + }); + test("verification rejects mismatched versions, missing entrypoints, and failing smoke runs", async () => { + const { layout } = await fixture(); + await expectFailure(() => verifyStagedPackage(layout.workdir, "9.0.0")); + await fs.writeFile(layout.entry, "process.exit(1)"); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + await fs.unlink(layout.entry); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + }); + test("normalizes pnpm shims so later launches remain identifiable", async () => { + const { layout } = await fixture("pnpm"); + const bin = path.join(layout.workdir, "node_modules/.bin/mux"); + await fs.unlink(bin); + await fs.writeFile(bin, "#!/bin/sh\nexit 0\n"); + await verifyStagedPackage(layout.workdir, layout.version); + expect(await fs.realpath(bin)).toBe(layout.entry); + }); + test("activation failure leaves the old link intact", async () => { + const { layout } = await fixture(); + const original = await fs.readlink(layout.launcher); + expect(() => activateUpdate(layout, "/missing-update-bin")).toThrow(); + expect(await fs.readlink(layout.launcher)).toBe(original); + await fs.unlink(layout.launcher); + await fs.writeFile(layout.launcher, "replaced externally"); + expect(() => activateUpdate(layout, layout.entry)).toThrow(); + expect(await fs.readFile(layout.launcher, "utf8")).toBe("replaced externally"); + }); +}); + +describe("server updater", () => { + test("unsupported actions have no effects", async () => { + const effect = () => { + throw new Error("must not run"); + }; + const updater = new ServerUpdater({ supported: false, reason: "test" }, undefined, { + collectBlockers: effect, + restart: effect, + fetchDistTags: effect, + runInstall: effect, + activate: effect, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + await updater.installUpdate(); + updater.setChannel("nightly"); + expect(updater.getStatus().type).toBe("unsupported"); + expect(updater.getChannel()).toBe("stable"); + }); + test("selects dist-tags by effective channel, including downgrades, and resets staged updates", async () => { + const { layout } = await fixture("bun", "2.0.0-next.1"); + const deps: ServerUpdaterDeps = { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => Promise.resolve({ latest: "1.0.0", next: layout.version }), + runInstall: () => Promise.resolve("/staged"), + }; + const updater = new ServerUpdater({ supported: true, layout }, undefined, deps); + await updater.checkForUpdates(); + expect(updater.getStatus().type).toBe("up-to-date"); + updater.setChannel("stable"); + await updater.checkForUpdates(); + expect(updater.getStatus()).toEqual({ type: "available", info: { version: "1.0.0" } }); + await updater.downloadUpdate(); + expect(updater.getStatus().type).toBe("downloaded"); + updater.setChannel("nightly"); + expect(updater.getStatus().type).toBe("idle"); + await updater.installUpdate(); + expect(updater.getStatus().type).toBe("idle"); + expect(new ServerUpdater({ supported: true, layout }, "stable", deps).getChannel()).toBe( + "stable" + ); + }); + test("reports check and download failures, suppresses automatic check errors, and retries", async () => { + const { layout } = await fixture(); + let checkFails = true; + let downloadFails = true; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => + checkFails ? Promise.reject(new Error("offline")) : Promise.resolve({ next: "2.0.0" }), + runInstall: () => + downloadFails ? Promise.reject(new Error("install failed")) : Promise.resolve("/staged"), + }); + const statuses: UpdateStatus[] = []; + updater.subscribe((s) => statuses.push(s)); + await updater.checkForUpdates({ source: "auto" }); + expect(updater.getStatus().type).toBe("idle"); + await updater.checkForUpdates(); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "check" }); + checkFails = false; + await updater.checkForUpdates(); + await updater.downloadUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "download" }); + downloadFails = false; + await updater.downloadUpdate(); + expect(updater.getStatus().type).toBe("downloaded"); + expect(statuses).toContainEqual({ type: "downloading", percent: null }); + }); + test("blocks volatile work and restarts exactly once only after successful activation", async () => { + const { layout } = await fixture(); + const events: string[] = []; + let blockers: RestartBlocker[] = [{ kind: "terminals", count: 1 }]; + let activationFails = true; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => blockers, + restart: () => { + events.push("restart"); + return Promise.resolve(); + }, + fetchDistTags: () => Promise.resolve({ next: "2.0.0" }), + runInstall: () => Promise.resolve("/staged"), + activate: () => { + if (activationFails) throw new Error("failed"); + events.push("activate"); + }, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + await updater.installUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "install-blocked", blockers }); + expect(events).toEqual([]); + blockers = []; + await updater.installUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "install" }); + expect(events).toEqual([]); + activationFails = false; + await Promise.all([updater.installUpdate(), updater.installUpdate()]); + expect(events).toEqual(["activate", "restart"]); + }); + test("serializes checks and downloads and refuses channel changes while busy", async () => { + const { layout } = await fixture(); + let resolveTags!: (tags: { next: string }) => void; + let checks = 0; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => { + checks++; + return new Promise((resolve) => { + resolveTags = resolve; + }); + }, + }); + const check = updater.checkForUpdates(); + await updater.checkForUpdates(); + expect(checks).toBe(1); + expect(() => updater.setChannel("stable")).toThrow(); + resolveTags({ next: layout.version }); + await check; + expect(updater.getStatus().type).toBe("up-to-date"); + }); +}); + +describe("registry discovery", () => { + test("requests scoped package dist-tags and accepts only exact versions", async () => { + let observedUrl = ""; + let hasSignal = false; + const tags = await fetchDistTags("https://registry.example.com/prefix", (url, options) => { + observedUrl = url; + hasSignal = options.signal instanceof AbortSignal; + return Promise.resolve( + new Response(JSON.stringify({ latest: "1.0.0", next: "../../invalid" })) + ); + }); + expect(observedUrl).toBe( + "https://registry.example.com/prefix/-/package/@coder%2Fxum/dist-tags" + ); + expect(hasSignal).toBe(true); + expect(tags).toEqual({ latest: "1.0.0", next: undefined }); + }); + test("rejects HTTP errors and malformed responses", async () => { + await expectFailure(() => + fetchDistTags("https://registry.example.com", () => + Promise.resolve(new Response("", { status: 503 })) + ) + ); + await expectFailure(() => + fetchDistTags("https://registry.example.com", () => Promise.resolve(new Response("not-json"))) + ); + }); +}); diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts new file mode 100644 index 00000000000..041198e32cd --- /dev/null +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -0,0 +1,149 @@ +import { getErrorMessage } from "@/common/utils/errors"; +import type { RestartBlocker, UpdateStatus } from "@/common/orpc/types"; +import type { UpdateChannel } from "@/common/types/project"; +import { log } from "@/node/services/log"; +import { activateUpdate } from "./activation"; +import { + inferChannel, + isExactVersion, + type InstallLayout, + type LayoutResult, +} from "./installLayout"; +import { fetchDistTags } from "./registry"; +import { stageUpdate } from "./staging"; + +export interface ServerUpdaterDeps { + collectBlockers: () => RestartBlocker[]; + restart: () => Promise; + fetchDistTags?: typeof fetchDistTags; + runInstall?: typeof stageUpdate; + activate?: typeof activateUpdate; +} + +export class ServerUpdater { + private status: UpdateStatus; + private channel: UpdateChannel; + private readonly layout: InstallLayout | null; + private readonly subscribers = new Set<(status: UpdateStatus) => void>(); + private availableVersion: string | null = null; + private stagedBin: string | null = null; + private installing = false; + + constructor( + result: LayoutResult, + channel: UpdateChannel | undefined, + private readonly deps: ServerUpdaterDeps + ) { + this.layout = result.supported ? result.layout : null; + this.channel = channel ?? inferChannel(this.layout?.version ?? ""); + this.status = result.supported + ? { type: "idle" } + : { type: "unsupported", reason: result.reason }; + } + + getStatus(): UpdateStatus { + return this.status; + } + getChannel(): UpdateChannel { + return this.channel; + } + + subscribe(callback: (status: UpdateStatus) => void): () => void { + this.subscribers.add(callback); + callback(this.status); + return () => this.subscribers.delete(callback); + } + + private setStatus(status: UpdateStatus): void { + this.status = status; + for (const callback of this.subscribers) { + try { + callback(status); + } catch (error) { + log.error("Server update subscriber failed", error); + } + } + } + + setChannel(channel: UpdateChannel): void { + if (!this.layout || channel === this.channel) return; + if (this.installing || this.status.type === "checking" || this.status.type === "downloading") + throw new Error("An update operation is in progress"); + this.channel = channel; + this.availableVersion = null; + this.stagedBin = null; + this.setStatus({ type: "idle" }); + } + + async checkForUpdates(options?: { source?: "auto" | "manual" }): Promise { + if ( + !this.layout || + this.installing || + this.status.type === "checking" || + this.status.type === "downloading" || + this.stagedBin + ) + return; + const previous = this.status; + this.setStatus({ type: "checking" }); + try { + const tags = await (this.deps.fetchDistTags ?? fetchDistTags)(this.layout.registry); + const version = tags[this.channel === "stable" ? "latest" : "next"]; + if (!isExactVersion(version)) + throw new Error("Registry has no valid version for the selected channel"); + this.availableVersion = version === this.layout.version ? null : version; + this.setStatus( + this.availableVersion ? { type: "available", info: { version } } : { type: "up-to-date" } + ); + } catch (error) { + this.setStatus( + options?.source === "auto" + ? previous + : { type: "error", phase: "check", message: getErrorMessage(error) } + ); + } + } + + async downloadUpdate(): Promise { + if ( + !this.layout || + !this.availableVersion || + this.stagedBin || + this.installing || + this.status.type === "checking" || + this.status.type === "downloading" + ) + return; + this.setStatus({ type: "downloading", percent: null }); + try { + this.stagedBin = await (this.deps.runInstall ?? stageUpdate)( + this.layout, + this.availableVersion + ); + this.setStatus({ type: "downloaded", info: { version: this.availableVersion } }); + } catch (error) { + this.setStatus({ type: "error", phase: "download", message: getErrorMessage(error) }); + } + } + + async installUpdate(): Promise { + if (!this.layout || !this.stagedBin || !this.availableVersion || this.installing) return; + try { + const blockers = this.deps.collectBlockers(); + if (blockers.length) { + this.setStatus({ + type: "install-blocked", + info: { version: this.availableVersion }, + blockers, + }); + return; + } + // No await between the idle snapshot, atomic swap, and the CLI's shutdown latch. + (this.deps.activate ?? activateUpdate)(this.layout, this.stagedBin); + this.installing = true; + await this.deps.restart(); + } catch (error) { + this.setStatus({ type: "error", phase: "install", message: getErrorMessage(error) }); + } + } +} diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts new file mode 100644 index 00000000000..4c81f2aafb9 --- /dev/null +++ b/src/node/services/serverUpdate/staging.ts @@ -0,0 +1,83 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { execFileAsync } from "@/node/utils/disposableExec"; +import { + SERVER_UPDATE_INSTALL_TIMEOUT_MS, + SERVER_UPDATE_SMOKE_TIMEOUT_MS, + SERVER_UPDATE_STAGING_PREFIX, +} from "@/constants/serverUpdate"; +import { isExactVersion, readPackageVersion, type InstallLayout } from "./installLayout"; + +export function installCommand( + layout: InstallLayout, + version: string +): { file: string; args: string[] } { + if (!isExactVersion(version)) throw new Error("Invalid update version"); + const spec = `@coder/xum@${version}`; + const flags = { + bun: ["add", "--ignore-scripts", "--exact"], + npm: ["install", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"], + pnpm: ["add", "--ignore-scripts"], + } satisfies Record; + return { + file: layout.packageManager, + args: [...flags[layout.packageManager], spec, "--registry", layout.registry], + }; +} + +export async function verifyStagedPackage(dir: string, version: string): Promise { + const packageDir = path.join(dir, "node_modules/@coder/xum"); + if (readPackageVersion(packageDir) !== version) + throw new Error("Staged package version does not match the requested update"); + const entry = path.join(packageDir, "dist/cli/index.js"); + if (!(await fs.stat(entry)).isFile()) throw new Error("Staged CLI entry is not a file"); + const bin = path.join(dir, "node_modules/.bin/mux"); + await fs.access(bin); + // pnpm emits shell shims; a direct link keeps the next launch identifiable by realpath. + if (!(await fs.lstat(bin)).isSymbolicLink()) { + await fs.unlink(bin); + await fs.symlink(entry, bin); + } + if ((await fs.realpath(bin)) !== (await fs.realpath(entry))) + throw new Error("Staged launcher does not resolve to the CLI entry"); + using smoke = execFileAsync("node", [entry, "--version"], { + timeoutMs: SERVER_UPDATE_SMOKE_TIMEOUT_MS, + }); + await smoke.result; + return bin; +} + +async function runInstall(file: string, args: string[], cwd: string): Promise { + using install = execFileAsync(file, args, { + cwd, + timeoutMs: SERVER_UPDATE_INSTALL_TIMEOUT_MS, + killTreeOnTermination: true, + }); + await install.result; +} + +export async function stageUpdate( + layout: InstallLayout, + version: string, + install = runInstall +): Promise { + const command = installCommand(layout, version); + const parent = path.dirname(layout.workdir); + const active = await fs.realpath(layout.workdir); + const dir = path.join(parent, `${SERVER_UPDATE_STAGING_PREFIX}${version}`); + for (const entry of await fs.readdir(parent, { withFileTypes: true })) { + if ( + !entry.isDirectory() || + !entry.name.startsWith(SERVER_UPDATE_STAGING_PREFIX) || + !isExactVersion(entry.name.slice(SERVER_UPDATE_STAGING_PREFIX.length)) + ) + continue; + const candidate = path.join(parent, entry.name); + if ((await fs.realpath(candidate)) !== active) await fs.rm(candidate, { recursive: true }); + } + // Exclusive creation refuses pre-existing links, and never mutates the running installation. + await fs.mkdir(dir); + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ private: true })); + await install(command.file, command.args, dir); + return verifyStagedPackage(dir, version); +} diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 19d480f1f9d..a2323becba4 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -170,6 +170,85 @@ describe("ServiceContainer", () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + it("collects restart blockers from live sessions, including pre-stream work", () => { + services = new ServiceContainer(stores); + expect(services.collectRestartBlockers()).toEqual([]); + const session = services.workspaceService.getOrCreateSession("restart-test"); + const internal = session as unknown as { + setTurnPhase(phase: "idle" | "preparing"): void; + autoRetryStarting: boolean; + }; + using _admission = session.holdTurnAdmission(); + internal.setTurnPhase("preparing"); + expect(services.collectRestartBlockers()).toContainEqual({ kind: "pending-turns", count: 1 }); + internal.setTurnPhase("idle"); + session.queueMessage("queued for later"); + expect(services.collectRestartBlockers()).toEqual([{ kind: "queued-messages", count: 1 }]); + session.clearQueue(); + internal.autoRetryStarting = true; + expect(services.collectRestartBlockers()).toEqual([{ kind: "auto-retries", count: 1 }]); + internal.autoRetryStarting = false; + expect(services.collectRestartBlockers()).toEqual([]); + }); + + it("counts server-wide streams, terminal starts, and foreground or background processes", () => { + services = new ServiceContainer(stores); + const streams = services.streamManager as unknown as { workspaceStreams: Map }; + const terminals = services.terminalService as unknown as { + pendingSessionCreations: Map; + }; + const processes = services.backgroundProcessManager as unknown as { + processes: Map; + }; + const workspace = services.workspaceService as unknown as { + preflightSendCounts: Map; + preflightExecCounts: Map; + }; + try { + streams.workspaceStreams.set("streaming", {}); + terminals.pendingSessionCreations.set("terminal-starting", 2); + processes.processes.set("running", { status: "running", isForeground: false }); + processes.processes.set("foreground", { status: "running", isForeground: true }); + processes.processes.set("finished", { status: "exited", isForeground: false }); + workspace.preflightSendCounts.set("preflight", 1); + workspace.preflightExecCounts.set("executing", 1); + expect(services.collectRestartBlockers()).toEqual([ + { kind: "pending-turns", count: 1 }, + { kind: "background-processes", count: 3 }, + { kind: "active-streams", count: 1 }, + { kind: "terminals", count: 2 }, + ]); + } finally { + streams.workspaceStreams.clear(); + terminals.pendingSessionCreations.clear(); + processes.processes.clear(); + workspace.preflightSendCounts.clear(); + workspace.preflightExecCounts.clear(); + } + expect(services.collectRestartBlockers()).toEqual([]); + }); + + it("refuses new sessions, commands, and terminals synchronously during disposal", async () => { + services = new ServiceContainer(stores); + const disposal = services.dispose(); + expect(() => services!.workspaceService.getOrCreateSession("cold-workspace")).toThrow( + "shutting down" + ); + expect(await services.workspaceService.executeBash("cold-workspace", "echo not-run")).toEqual({ + success: false, + error: "Server is shutting down", + }); + let terminalError: unknown; + try { + await services.terminalService.create({ workspaceId: "cold-workspace", cols: 80, rows: 24 }); + } catch (error) { + terminalError = error; + } + expect(terminalError).toBeInstanceOf(Error); + expect(String(terminalError)).toContain("shutting down"); + await disposal; + }); + it("attributes multi-project stream-end analytics to the primary project path", async () => { const primaryProjectPath = "/fake/project-a"; const secondaryProjectPath = "/fake/project-b"; diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 1234247a584..4a5efd88775 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -1,3 +1,4 @@ +import type { RestartBlocker } from "@/common/orpc/types"; import { log } from "@/node/services/log"; import type { Config, ConfigStores, WorkspaceSessionLocator } from "@/node/config"; import type { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; @@ -171,7 +172,7 @@ export class ServiceContainer { public readonly memoryConsolidationService: CoreServices["memoryConsolidationService"]; public readonly refineService: RefineService; private readonly extensionMetadata: CoreServices["extensionMetadata"]; - private readonly backgroundProcessManager: CoreServices["backgroundProcessManager"]; + public readonly backgroundProcessManager: CoreServices["backgroundProcessManager"]; // Desktop-only services (`di/layers/desktop.ts`) public readonly projectService: ProjectService; public readonly muxGatewayOauthService: MuxGatewayOauthService; @@ -542,6 +543,23 @@ export class ServiceContainer { this.terminalService.setTerminalWindowManager(manager); } + collectRestartBlockers(): RestartBlocker[] { + const blockers = this.workspaceService.collectRestartBlockers(); + const counts: Array<[RestartBlocker["kind"], number]> = [ + ["active-streams", this.streamManager.getActiveStreams().length], + ["terminals", this.terminalService.getOpenSessionCount()], + ["background-processes", this.backgroundProcessManager.getRunningProcessCount()], + ]; + for (const [kind, count] of counts) { + if (count > 0) { + const existing = blockers.find((blocker) => blocker.kind === kind); + if (existing) existing.count += count; + else blockers.push({ kind, count }); + } + } + return blockers; + } + /** * Dispose all services. Called on app quit to clean up resources. * Terminates all background processes to prevent orphans. Idempotent: @@ -576,6 +594,7 @@ export class ServiceContainer { // abort nor the join, so latch every session before the wait: nothing may start a stream inside // it, and nothing may dispatch through the provider/runtime services torn down below. shutdownStep("workspaceService.beginShutdown", () => this.workspaceService.beginShutdown()); + shutdownStep("terminalService.beginShutdown", () => this.terminalService.beginShutdown()); const housekeepingSettled = this.startupHousekeepingSettled; if (housekeepingSettled != null) { await shutdownStep("startupHousekeeping.join", async () => { diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index 83f9a5c8c61..d33d846e68a 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -493,7 +493,9 @@ describe("TerminalService", () => { await service.create({ workspaceId: "ws-1", cols: 80, rows: 24 }); closeSessionMock.mockClear(); + expect(service.getOpenSessionCount()).toBe(1); service.closeAllSessions(); + expect(service.getOpenSessionCount()).toBe(0); expect(closeSessionMock).toHaveBeenCalled(); // PTY bulk close should NOT be used diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 618bfee8763..3c4de192544 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -69,6 +69,7 @@ export class TerminalService { private readonly headlessOnDataDisposables = new Map void }>(); private readonly titleChangeDisposables = new Map void }>(); + private shuttingDown = false; // Per-session activity tracking for sidebar indicator. // Maps sessionId -> { workspaceId, isRunning (derived from terminal title) }. private readonly sessionActivity = new Map(); @@ -233,7 +234,12 @@ export class TerminalService { return proxyUriEnv; } + beginShutdown(): void { + this.shuttingDown = true; + } + async create(params: TerminalCreateParams): Promise { + if (this.shuttingDown) throw new Error("Server is shutting down"); // Reserve the startup synchronously: a creation that has passed its archived check but is // still awaiting metadata/secrets/PTY spawn is not yet in sessionActivity, so without this // reservation an archive's live-activity gate could pass and the pending creation would @@ -1281,6 +1287,13 @@ export class TerminalService { } } + getOpenSessionCount(): number { + return ( + this.sessionActivity.size + + Array.from(this.pendingSessionCreations.values()).reduce((sum, count) => sum + count, 0) + ); + } + /** * Whether any live terminal PTY sessions are tracked for a workspace. Model-facing * lifecycle paths consult this to refuse archiving instead of silently killing PTYs. diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts index f29bbacd883..dc272199226 100644 --- a/src/node/services/updateService.ts +++ b/src/node/services/updateService.ts @@ -3,13 +3,14 @@ import type { UpdateStatus } from "@/common/orpc/types"; import type { UpdateChannel } from "@/common/types/project"; import { parseDebugUpdater } from "@/common/utils/env"; import type { Config } from "@/node/config"; +import { ServerUpdater, type ServerUpdaterDeps } from "./serverUpdate/serverUpdater"; +import { resolveInstallLayout } from "./serverUpdate/installLayout"; -// Interface matching the implementation class in desktop/updater.ts -// We redefine it here to avoid importing the class directly which brings in electron-updater -interface DesktopUpdaterService { - checkForUpdates(options?: { source?: "auto" | "manual" }): void; +// Keep the Electron implementation out of CLI value imports. +interface UpdaterImpl { + checkForUpdates(options?: { source?: "auto" | "manual" }): void | Promise; downloadUpdate(): Promise; - installUpdate(): void; + installUpdate(): void | Promise; subscribe(callback: (status: UpdateStatus) => void): () => void; getStatus(): UpdateStatus; getChannel(): UpdateChannel; @@ -17,8 +18,11 @@ interface DesktopUpdaterService { } export class UpdateService { - private impl: DesktopUpdaterService | null = null; - private currentStatus: UpdateStatus = { type: "idle" }; + private impl: UpdaterImpl | null = null; + private currentStatus: UpdateStatus = { + type: "unsupported", + reason: "Server updater is not enabled for this process", + }; // Keep the user's stable/nightly preference loaded from config at startup so // the About dialog and updater initialization share the same persisted value. private currentChannel: UpdateChannel; @@ -60,6 +64,20 @@ export class UpdateService { } } + async enableServerUpdater(deps: ServerUpdaterDeps): Promise { + await this.ready; + if (process.versions.electron) return; + this.impl = new ServerUpdater( + resolveInstallLayout(process.env, process.argv), + this.config.loadConfigOrDefault().updateChannel, + deps + ); + this.impl.subscribe((status) => { + this.currentStatus = status; + this.notifySubscribers(); + }); + } + async check(options?: { source?: "auto" | "manual" }): Promise { await this.ready; if (this.impl) { @@ -82,7 +100,7 @@ export class UpdateService { log.debug("UpdateService: Error checking env:", err); } } - this.impl.checkForUpdates(options); + await this.impl.checkForUpdates(options); } else { log.debug("UpdateService: check() called but no implementation (CLI mode)"); } @@ -95,9 +113,9 @@ export class UpdateService { } } - install(): void { + async install(): Promise { if (this.impl) { - this.impl.installUpdate(); + await this.impl.installUpdate(); } } @@ -111,9 +129,8 @@ export class UpdateService { async setChannel(channel: UpdateChannel): Promise { await this.ready; - // Apply runtime switch first — it throws if the updater is in a blocked - // state (checking/downloading/downloaded). Only persist after success so - // config and runtime stay in sync. + if (this.impl && this.currentStatus.type === "unsupported") return; + // Let the implementation reject busy-state changes before persisting the preference. if (this.impl) { this.impl.setChannel(channel); } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 3e24599c358..5e2cc6192e0 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1,3 +1,4 @@ +import type { RestartBlocker } from "@/common/orpc/types"; import * as path from "path"; import { TASK_TERMINATION_STOP_STREAM_TIMEOUT_MS } from "@/constants/terminationTimeouts"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; @@ -1803,6 +1804,7 @@ const DELEGATED_TURN_CONTINUATION_OPTIONS_SCHEMA = SendMessageOptionsSchema.pick // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly sessions = new Map(); + private shuttingDown = false; private readonly providerConfigChangedListener = (): void => { const liveSessions = new Map([ ...this.sessions.entries(), @@ -3950,6 +3952,28 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { ); } + collectRestartBlockers(): RestartBlocker[] { + const sessions = new Map([...this.sessions, ...this.transientStartupRecoverySessions]); + const pendingTurns = new Set(this.preflightSendCounts.keys()); + let queuedMessages = 0; + let autoRetries = 0; + for (const [workspaceId, session] of sessions) { + if (session.hasActiveOrPendingTurnWork()) pendingTurns.add(workspaceId); + if (session.hasQueuedMessages()) queuedMessages++; + if (session.hasPendingAutoRetry()) autoRetries++; + } + const blockers: RestartBlocker[] = [ + { kind: "pending-turns", count: pendingTurns.size }, + { kind: "queued-messages", count: queuedMessages }, + { kind: "auto-retries", count: autoRetries }, + { + kind: "background-processes", + count: Array.from(this.preflightExecCounts.values()).reduce((sum, count) => sum + count, 0), + }, + ]; + return blockers.filter((blocker) => blocker.count > 0); + } + /** * Shutdown: stop startup chat recovery before the services it dispatches through go away. * Transient recovery sessions are disposed outright. Sessions that outlived that sweep (promoted @@ -3957,6 +3981,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * on) may own a live stream whose partial the next startup needs, so they only stop dispatching. */ beginShutdown(): void { + this.shuttingDown = true; for (const [workspaceId, session] of this.transientStartupRecoverySessions) { this.transientStartupRecoverySessions.delete(workspaceId); session.dispose(); @@ -4015,6 +4040,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } private createSession(workspaceId: string): AgentSession { + if (this.shuttingDown) throw new Error("Server is shutting down"); return new AgentSession({ workspaceId, config: this.config, @@ -4086,6 +4112,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } public getOrCreateSession(workspaceId: string): AgentSession { + if (this.shuttingDown) throw new Error("Server is shutting down"); assert(typeof workspaceId === "string", "workspaceId must be a string"); const trimmed = workspaceId.trim(); assert(trimmed.length > 0, "workspaceId must not be empty"); @@ -14171,6 +14198,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { command?: string, args?: string[] ): Promise> { + if (this.shuttingDown) return Err("Server is shutting down"); // Block bash execution while workspace is being removed to prevent races with directory deletion. // A common case: subagent calls agent_report → frontend's GitStatusStore triggers a git status // refresh → executeBash arrives while remove() is deleting the directory → spawn fails with ENOENT. diff --git a/src/node/utils/disposableExec.ts b/src/node/utils/disposableExec.ts index d396527d4eb..f25de8da25e 100644 --- a/src/node/utils/disposableExec.ts +++ b/src/node/utils/disposableExec.ts @@ -269,6 +269,7 @@ export function execAsync(command: string, options?: ExecAsyncOptions): Disposab * Options for execFileAsync. */ export interface ExecFileAsyncOptions { + cwd?: string; /** Extra environment variables for the child process. */ env?: Record; /** Optional callback for each stderr data chunk from the process. */ @@ -322,6 +323,7 @@ export function execFileAsync( const killsProcessTree = options?.maxOutputBytes !== undefined || options?.killTreeOnTermination === true; const child = spawn(file, args, { + cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"], env: options?.env ? { ...process.env, ...options.env } : undefined, // Unix tree termination needs a separate process group, but detaching also hides terminal diff --git a/tests/ipc/update.test.ts b/tests/ipc/update.test.ts new file mode 100644 index 00000000000..ab4abb11e17 --- /dev/null +++ b/tests/ipc/update.test.ts @@ -0,0 +1,23 @@ +import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; +import { resolveOrpcClient } from "./helpers"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Server update IPC", () => { + test("reports unsupported instead of offering an unsafe restart in the harness", async () => { + const env = await createTestEnvironment(); + const controller = new AbortController(); + try { + const client = resolveOrpcClient(env); + const statuses = await client.update.onStatus(undefined, { signal: controller.signal }); + const first = await statuses.next(); + expect(first.value).toMatchObject({ type: "unsupported", reason: expect.any(String) }); + await client.update.check({ source: "manual" }); + await client.update.download(); + await client.update.install(); + } finally { + controller.abort(); + await cleanupTestEnvironment(env); + } + }, 30000); +}); From 232c6e473e63770a8ca1e2d87709440915a62722 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:49:20 +0000 Subject: [PATCH 02/22] =?UTF-8?q?=F0=9F=A4=96=20refactor(server-update):?= =?UTF-8?q?=20point=20the=20launcher=20at=20the=20staged=20entry=20and=20c?= =?UTF-8?q?lose=20the=20install=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage verification no longer depends on a .bin shim (pnpm shims and the hardcoded mux name go away); the smoke run uses process.execPath. The installing latch is taken before the blocker snapshot so two concurrent install calls cannot both activate. Existing sessions stay reachable during shutdown; only new session creation is refused. Registry override resolves through the XUM/MUX compatibility resolver. --- src/browser/contexts/API.tsx | 2 +- src/browser/features/About/AboutDialog.tsx | 2 +- src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/stream.ts | 2 +- src/common/orpc/types.ts | 2 +- src/node/services/backgroundProcessManager.ts | 7 +++++-- src/node/services/serverUpdate/activation.ts | 13 +++++++++---- .../services/serverUpdate/installLayout.ts | 9 +++++---- .../services/serverUpdate/serverUpdate.test.ts | 14 -------------- .../services/serverUpdate/serverUpdater.ts | 18 ++++++++++-------- src/node/services/serverUpdate/staging.ts | 13 ++----------- src/node/services/workspaceService.ts | 1 - src/node/utils/disposableExec.test.ts | 10 ++++++++++ tests/ipc/update.test.ts | 2 ++ 14 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/browser/contexts/API.tsx b/src/browser/contexts/API.tsx index 6ace877cf6a..cd21172ec8d 100644 --- a/src/browser/contexts/API.tsx +++ b/src/browser/contexts/API.tsx @@ -260,7 +260,7 @@ function ManagedAPIProvider(props: Omit) { // A reconnected socket may belong to a newer server than this loaded bundle. if (hasConnectedRef.current && connectionId === connectionIdRef.current) { try { - const response = await fetch(new URL(getBrowserBackendBaseUrl() + "/version"), { + const response = await fetch(`${getBrowserBackendBaseUrl()}/version`, { cache: "no-store", signal: AbortSignal.timeout(SERVER_VERSION_CHECK_TIMEOUT_MS), }); diff --git a/src/browser/features/About/AboutDialog.tsx b/src/browser/features/About/AboutDialog.tsx index 3b315015795..da5dbaa504f 100644 --- a/src/browser/features/About/AboutDialog.tsx +++ b/src/browser/features/About/AboutDialog.tsx @@ -241,7 +241,7 @@ export function AboutDialog() { handleChannelChange(next); } }} - disabled={channelLoading || isChecking || pendingAction === "install"} + disabled={channelLoading || isChecking || pendingAction !== null} aria-label="Update channel" size="sm" > diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 17e054d6d68..8253362ecf1 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -279,6 +279,7 @@ export { AdvisorOutputEventSchema, AdvisorReasoningOutputEventSchema, AdvisorPhaseEventSchema, + RestartBlockerSchema, UpdateStatusSchema, UsageDeltaEventSchema, WorkspaceChatMessageSchema, diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index ab76e8eb575..e01b4333636 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -721,7 +721,7 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [ ]); // Update Status -const RestartBlockerSchema = z.object({ +export const RestartBlockerSchema = z.object({ kind: z.enum([ "active-streams", "pending-turns", diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index e1b3bfc9d8d..b86bf7aa469 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -50,7 +50,7 @@ export type DeleteMessage = z.infer; export type GoalBudgetLimitedEvent = z.infer; export type WorkspaceInitEvent = z.infer; export type UpdateStatus = z.infer; -export type RestartBlocker = Extract["blockers"][number]; +export type RestartBlocker = z.infer; export type DesktopPrereqStatus = z.infer; export type ChatMuxMessage = z.infer; export type WorkspaceStatsSnapshot = z.infer; diff --git a/src/node/services/backgroundProcessManager.ts b/src/node/services/backgroundProcessManager.ts index a910afa927e..3e6d5834da5 100644 --- a/src/node/services/backgroundProcessManager.ts +++ b/src/node/services/backgroundProcessManager.ts @@ -2382,8 +2382,11 @@ export class BackgroundProcessManager extends EventEmitter process.status === "running") - .length; + let count = 0; + for (const process of this.processes.values()) { + if (process.status === "running") count++; + } + return count; } /** diff --git a/src/node/services/serverUpdate/activation.ts b/src/node/services/serverUpdate/activation.ts index 1232f2e522b..22d8fda9a0c 100644 --- a/src/node/services/serverUpdate/activation.ts +++ b/src/node/services/serverUpdate/activation.ts @@ -2,20 +2,25 @@ import { lstatSync, realpathSync, renameSync, symlinkSync, unlinkSync } from "no import { randomUUID } from "node:crypto"; import type { InstallLayout } from "./installLayout"; -export function activateUpdate(layout: InstallLayout, stagedBin: string): void { +export function activateUpdate(layout: InstallLayout, stagedEntry: string): void { if ( !lstatSync(layout.launcher).isSymbolicLink() || realpathSync(layout.launcher) !== layout.entry ) { throw new Error("Server launcher changed since startup"); } - realpathSync(stagedBin); + // A dangling launcher would brick the next start, so refuse a missing target before the swap. + realpathSync(stagedEntry); const temporary = `${layout.launcher}.${randomUUID()}.tmp`; - symlinkSync(stagedBin, temporary); + symlinkSync(stagedEntry, temporary); try { renameSync(temporary, layout.launcher); } catch (error) { - unlinkSync(temporary); + try { + unlinkSync(temporary); + } catch { + // Report the failed swap, not the cleanup of its temporary link. + } throw error; } } diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index 37d373103b8..84c9152af88 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -54,9 +54,8 @@ export function resolveInstallLayout( throw new Error("Server updates require a supervisor configured to restart after exit"); } const running = argv[1]; - const binary = resolveXumEnvironmentValue("BINARY", env) ?? running; - if (!running || !binary) throw new Error("Cannot identify the server launcher"); - const launcher = path.resolve(binary); + if (!running) throw new Error("Cannot identify the server launcher"); + const launcher = path.resolve(resolveXumEnvironmentValue("BINARY", env) ?? running); if (!lstatSync(launcher).isSymbolicLink()) throw new Error("Server launcher must be a symlink"); const entry = realpathSync(running); if (realpathSync(launcher) !== entry) @@ -86,7 +85,9 @@ export function resolveInstallLayout( if (launcher.startsWith(workdir + path.sep)) throw new Error("Server launcher must be outside the package installation"); const registry = - env.XUM_UPDATE_REGISTRY_URL ?? env.npm_config_registry ?? "https://registry.npmjs.org"; + resolveXumEnvironmentValue("UPDATE_REGISTRY_URL", env) ?? + env.npm_config_registry ?? + "https://registry.npmjs.org"; const url = new URL(registry); if ( !["https:", "http:"].includes(url.protocol) || diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index ce098501e17..c666b537fef 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -4,7 +4,6 @@ import * as path from "node:path"; import * as os from "node:os"; import type { RestartBlocker, UpdateStatus } from "@/common/orpc/types"; import { resolveInstallLayout, inferChannel, type InstallLayout } from "./installLayout"; -import { execFileAsync } from "@/node/utils/disposableExec"; import { activateUpdate } from "./activation"; import { installCommand, stageUpdate, verifyStagedPackage } from "./staging"; import { fetchDistTags } from "./registry"; @@ -117,11 +116,6 @@ describe("server install layout", () => { }); describe("staging and activation", () => { - test("package install commands execute in the isolated staging cwd", async () => { - const { layout } = await fixture(); - using command = execFileAsync("node", ["-p", "process.cwd()"], { cwd: layout.workdir }); - expect((await command.result).stdout.trim()).toBe(layout.workdir); - }); test("installs an exact version with lifecycle scripts disabled for every manager", async () => { const { layout } = await fixture(); for (const packageManager of ["bun", "npm", "pnpm"] as const) { @@ -169,14 +163,6 @@ describe("staging and activation", () => { await fs.unlink(layout.entry); await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); }); - test("normalizes pnpm shims so later launches remain identifiable", async () => { - const { layout } = await fixture("pnpm"); - const bin = path.join(layout.workdir, "node_modules/.bin/mux"); - await fs.unlink(bin); - await fs.writeFile(bin, "#!/bin/sh\nexit 0\n"); - await verifyStagedPackage(layout.workdir, layout.version); - expect(await fs.realpath(bin)).toBe(layout.entry); - }); test("activation failure leaves the old link intact", async () => { const { layout } = await fixture(); const original = await fs.readlink(layout.launcher); diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts index 041198e32cd..aa1d24a60eb 100644 --- a/src/node/services/serverUpdate/serverUpdater.ts +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -26,7 +26,7 @@ export class ServerUpdater { private readonly layout: InstallLayout | null; private readonly subscribers = new Set<(status: UpdateStatus) => void>(); private availableVersion: string | null = null; - private stagedBin: string | null = null; + private stagedEntry: string | null = null; private installing = false; constructor( @@ -71,7 +71,7 @@ export class ServerUpdater { throw new Error("An update operation is in progress"); this.channel = channel; this.availableVersion = null; - this.stagedBin = null; + this.stagedEntry = null; this.setStatus({ type: "idle" }); } @@ -81,7 +81,7 @@ export class ServerUpdater { this.installing || this.status.type === "checking" || this.status.type === "downloading" || - this.stagedBin + this.stagedEntry ) return; const previous = this.status; @@ -108,7 +108,7 @@ export class ServerUpdater { if ( !this.layout || !this.availableVersion || - this.stagedBin || + this.stagedEntry || this.installing || this.status.type === "checking" || this.status.type === "downloading" @@ -116,7 +116,7 @@ export class ServerUpdater { return; this.setStatus({ type: "downloading", percent: null }); try { - this.stagedBin = await (this.deps.runInstall ?? stageUpdate)( + this.stagedEntry = await (this.deps.runInstall ?? stageUpdate)( this.layout, this.availableVersion ); @@ -127,10 +127,12 @@ export class ServerUpdater { } async installUpdate(): Promise { - if (!this.layout || !this.stagedBin || !this.availableVersion || this.installing) return; + if (!this.layout || !this.stagedEntry || !this.availableVersion || this.installing) return; + this.installing = true; try { const blockers = this.deps.collectBlockers(); if (blockers.length) { + this.installing = false; this.setStatus({ type: "install-blocked", info: { version: this.availableVersion }, @@ -139,10 +141,10 @@ export class ServerUpdater { return; } // No await between the idle snapshot, atomic swap, and the CLI's shutdown latch. - (this.deps.activate ?? activateUpdate)(this.layout, this.stagedBin); - this.installing = true; + (this.deps.activate ?? activateUpdate)(this.layout, this.stagedEntry); await this.deps.restart(); } catch (error) { + this.installing = false; this.setStatus({ type: "error", phase: "install", message: getErrorMessage(error) }); } } diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index 4c81f2aafb9..2cfd088ca80 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -31,20 +31,11 @@ export async function verifyStagedPackage(dir: string, version: string): Promise throw new Error("Staged package version does not match the requested update"); const entry = path.join(packageDir, "dist/cli/index.js"); if (!(await fs.stat(entry)).isFile()) throw new Error("Staged CLI entry is not a file"); - const bin = path.join(dir, "node_modules/.bin/mux"); - await fs.access(bin); - // pnpm emits shell shims; a direct link keeps the next launch identifiable by realpath. - if (!(await fs.lstat(bin)).isSymbolicLink()) { - await fs.unlink(bin); - await fs.symlink(entry, bin); - } - if ((await fs.realpath(bin)) !== (await fs.realpath(entry))) - throw new Error("Staged launcher does not resolve to the CLI entry"); - using smoke = execFileAsync("node", [entry, "--version"], { + using smoke = execFileAsync(process.execPath, [entry, "--version"], { timeoutMs: SERVER_UPDATE_SMOKE_TIMEOUT_MS, }); await smoke.result; - return bin; + return entry; } async function runInstall(file: string, args: string[], cwd: string): Promise { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5e2cc6192e0..3d7aaf5f88b 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -4112,7 +4112,6 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } public getOrCreateSession(workspaceId: string): AgentSession { - if (this.shuttingDown) throw new Error("Server is shutting down"); assert(typeof workspaceId === "string", "workspaceId must be a string"); const trimmed = workspaceId.trim(); assert(trimmed.length > 0, "workspaceId must not be empty"); diff --git a/src/node/utils/disposableExec.test.ts b/src/node/utils/disposableExec.test.ts index a0a1d5a5ce8..c9650d64b7c 100644 --- a/src/node/utils/disposableExec.test.ts +++ b/src/node/utils/disposableExec.test.ts @@ -166,6 +166,16 @@ describe("disposableExec", () => { expect(childProc.killed).toBe(false); }); + test("cwd option runs the command in the requested directory", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "disposable-exec-cwd-")); + try { + using command = execFileAsync("node", ["-p", "process.cwd()"], { cwd }); + expect((await command.result).stdout.trim()).toBe(await fs.realpath(cwd)); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + test("stdout and stderr are captured correctly", async () => { using proc = execAsync("echo 'stdout message' && echo 'stderr message' >&2"); const childProc = (proc as any).child; diff --git a/tests/ipc/update.test.ts b/tests/ipc/update.test.ts index ab4abb11e17..f61f532d7af 100644 --- a/tests/ipc/update.test.ts +++ b/tests/ipc/update.test.ts @@ -15,6 +15,8 @@ describeIntegration("Server update IPC", () => { await client.update.check({ source: "manual" }); await client.update.download(); await client.update.install(); + // An unsupported install must not have started the restart path: the server still answers. + expect(await client.update.getChannel()).toBe("stable"); } finally { controller.abort(); await cleanupTestEnvironment(env); From 67efeacddc83b3d2c6028da444e388c7bcbc80bc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:05:22 +0000 Subject: [PATCH 03/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20requ?= =?UTF-8?q?ire=20a=20stable=20auth=20token=20and=20a=20symlink-started=20p?= =?UTF-8?q?rocess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UAT found that a server running with a generated auth token relaunches with a new token, so every browser session lands on the auth page after an update. Self-update now reports unsupported unless the token is stable (MUX_SERVER_AUTH_TOKEN, --auth-token, or --no-auth). It also refuses when the process was started from the entry file directly even if MUX_BINARY names a launcher, because the supervisor would relaunch the old path. --- docs/config/server-access.mdx | 2 +- src/cli/server.ts | 13 ++++++++++++- .../agentSkills/builtInSkillContent.generated.ts | 2 +- src/node/services/serverUpdate/installLayout.ts | 4 ++++ src/node/services/serverUpdate/serverUpdate.test.ts | 12 ++++++++---- src/node/services/updateService.ts | 10 +++------- 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx index 48b1991609a..6a81af08ca2 100644 --- a/docs/config/server-access.mdx +++ b/docs/config/server-access.mdx @@ -82,7 +82,7 @@ Equivalent CLI options: Open **About** 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. -Self-update requires a supervisor that restarts the server after it exits, and an external launcher symlink pointing to an installed `@coder/xum` CLI with a bun, npm, or pnpm lockfile. 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. +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, queued messages, pending auto-retries, open or starting terminals, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version. diff --git a/src/cli/server.ts b/src/cli/server.ts index f1cd05e1ee9..d4042e47520 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -18,6 +18,7 @@ import { validateProjectPath } from "@/node/utils/pathUtils"; import { VERSION } from "@/version"; import { getParseOptions } from "./argv"; import { resolveServerAuthToken } from "./serverAuthToken"; +import { resolveInstallLayout } from "@/node/services/serverUpdate/installLayout"; import { appendServerCrashLogSync } from "./serverCrashLogging"; import { shouldExposeLaunchProject } from "./launchProject"; @@ -287,7 +288,17 @@ async function main(): Promise { } }; - await serviceContainer.updateService.enableServerUpdater({ + // A generated token dies with this process, so the relaunched server would lock every browser + // session out; self-update is offered only when clients can re-authenticate on their own. + const updateLayout = + resolved.mode === "enabled" && resolved.source === "generated" + ? { + supported: false as const, + reason: + "Server updates require a stable auth token: set MUX_SERVER_AUTH_TOKEN or pass --auth-token", + } + : resolveInstallLayout(process.env, process.argv); + await serviceContainer.updateService.enableServerUpdater(updateLayout, { collectBlockers: () => serviceContainer.collectRestartBlockers(), restart: cleanup, }); diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 82a8f4608c7..61eb1694f98 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4745,7 +4745,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Open **About** 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.", "", - "Self-update requires a supervisor that restarts the server after it exits, and an external launcher symlink pointing to an installed `@coder/xum` CLI with a bun, npm, or pnpm lockfile. 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.", + "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, queued messages, pending auto-retries, open or starting terminals, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version.", "", diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index 84c9152af88..18e2a97f5ef 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -55,6 +55,10 @@ export function resolveInstallLayout( } const running = argv[1]; if (!running) throw new Error("Cannot identify the server launcher"); + // The supervisor relaunches whatever path it started; a direct entry path would keep running + // the old version after the launcher symlink is re-pointed. + if (!lstatSync(path.resolve(running)).isSymbolicLink()) + throw new Error("Server must be started through its launcher symlink"); const launcher = path.resolve(resolveXumEnvironmentValue("BINARY", env) ?? running); if (!lstatSync(launcher).isSymbolicLink()) throw new Error("Server launcher must be a symlink"); const entry = realpathSync(running); diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index c666b537fef..9d3d4841c85 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -37,7 +37,7 @@ async function fixture(manager: InstallLayout["packageManager"] = "bun", version const root = await fs.mkdtemp(path.join(os.tmpdir(), "server-update-")); dirs.push(root); const workdir = path.join(root, "npm"); - const { entry, bin } = await writePackage(workdir, version); + const { bin } = await writePackage(workdir, version); if (manager === "pnpm") { const packageDir = path.join(workdir, "node_modules/@coder/xum"); const storeDir = path.join(workdir, "node_modules/.pnpm/xum/node_modules/@coder/xum"); @@ -50,7 +50,7 @@ async function fixture(manager: InstallLayout["packageManager"] = "bun", version const launcher = path.join(root, "mux"); await fs.symlink(bin, launcher); const env = { MUX_BINARY: launcher, RESTART_ON_KILL_VALUE: "true" }; - const argv = ["node", entry]; + const argv = ["node", launcher]; const result = resolveInstallLayout(env, argv); if (!result.supported) throw new Error(result.reason); return { root, env, argv, layout: result.layout }; @@ -79,13 +79,17 @@ describe("server install layout", () => { test("requires supervisor, a symlink, and a matching running entry", async () => { const { env, argv, layout, root } = await fixture(); expect(resolveInstallLayout({ MUX_BINARY: env.MUX_BINARY }, argv).supported).toBe(false); - expect(resolveInstallLayout({ RESTART_ON_KILL_VALUE: "true" }, argv).supported).toBe(false); + expect( + resolveInstallLayout({ RESTART_ON_KILL_VALUE: "true" }, ["node", layout.entry]).supported + ).toBe(false); expect( resolveInstallLayout({ XUM_SERVER_SUPERVISED: "1" }, ["node", layout.launcher]).supported ).toBe(true); const other = await writePackage(path.join(root, "other"), "2.0.0"); await fs.writeFile(path.join(root, "other/bun.lock"), ""); - expect(resolveInstallLayout(env, ["node", other.entry]).supported).toBe(false); + expect(resolveInstallLayout(env, ["node", other.bin]).supported).toBe(false); + // A declared launcher does not excuse starting the entry file directly. + expect(resolveInstallLayout(env, ["node", layout.entry]).supported).toBe(false); }); test("honors canonical environment values and registry precedence", async () => { const { env, argv, layout } = await fixture(); diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts index dc272199226..2d2391c7710 100644 --- a/src/node/services/updateService.ts +++ b/src/node/services/updateService.ts @@ -4,7 +4,7 @@ import type { UpdateChannel } from "@/common/types/project"; import { parseDebugUpdater } from "@/common/utils/env"; import type { Config } from "@/node/config"; import { ServerUpdater, type ServerUpdaterDeps } from "./serverUpdate/serverUpdater"; -import { resolveInstallLayout } from "./serverUpdate/installLayout"; +import type { LayoutResult } from "./serverUpdate/installLayout"; // Keep the Electron implementation out of CLI value imports. interface UpdaterImpl { @@ -64,14 +64,10 @@ export class UpdateService { } } - async enableServerUpdater(deps: ServerUpdaterDeps): Promise { + async enableServerUpdater(layout: LayoutResult, deps: ServerUpdaterDeps): Promise { await this.ready; if (process.versions.electron) return; - this.impl = new ServerUpdater( - resolveInstallLayout(process.env, process.argv), - this.config.loadConfigOrDefault().updateChannel, - deps - ); + this.impl = new ServerUpdater(layout, this.config.loadConfigOrDefault().updateChannel, deps); this.impl.subscribe((status) => { this.currentStatus = status; this.notifySubscribers(); From c4d2e364bdde4a8e3087cac0e546e7fd4f012a19 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:30:07 +0000 Subject: [PATCH 04/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20clos?= =?UTF-8?q?e=20review=20gaps=20in=20the=20restart=20gate=20and=20registry?= =?UTF-8?q?=20trust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refuse plaintext registries except on loopback: the staged package is executed by the smoke run, so a network registry must be TLS-protected. - Count workspaces whose background init is still running as a blocker. - Refresh lazily tracked background process statuses before the synchronous blocker snapshot so a naturally exited command cannot block restarts indefinitely. - Give /version the API CORS treatment so the reconnect version probe works when the frontend is served from another origin. --- docs/config/server-access.mdx | 2 +- src/browser/features/About/AboutDialog.tsx | 1 + src/cli/server.ts | 1 + src/common/orpc/schemas/stream.ts | 1 + src/node/orpc/server.ts | 7 ++++- .../builtInSkillContent.generated.ts | 2 +- .../services/serverUpdate/installLayout.ts | 5 +++- .../serverUpdate/serverUpdate.test.ts | 26 ++++++++++++++++--- .../services/serverUpdate/serverUpdater.ts | 3 +++ src/node/services/serviceContainer.test.ts | 4 +++ src/node/services/serviceContainer.ts | 5 ++++ src/node/services/workspaceService.ts | 1 + 12 files changed, 50 insertions(+), 8 deletions(-) diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx index 6a81af08ca2..05abf418696 100644 --- a/docs/config/server-access.mdx +++ b/docs/config/server-access.mdx @@ -84,7 +84,7 @@ Open **About** to check for updates, download, then choose **Install & restart** 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, queued messages, pending auto-retries, open or starting terminals, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version. +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, queued messages, pending auto-retries, open or starting terminals, 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**. diff --git a/src/browser/features/About/AboutDialog.tsx b/src/browser/features/About/AboutDialog.tsx index da5dbaa504f..488192ed2bb 100644 --- a/src/browser/features/About/AboutDialog.tsx +++ b/src/browser/features/About/AboutDialog.tsx @@ -18,6 +18,7 @@ import { const blockerLabels: Record = { "active-streams": "Active streams", "pending-turns": "Pending turns", + "workspace-inits": "Workspaces still initializing", "queued-messages": "Sessions with queued messages", "auto-retries": "Pending auto-retries", terminals: "Open terminals", diff --git a/src/cli/server.ts b/src/cli/server.ts index d4042e47520..43b78973ff1 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -299,6 +299,7 @@ async function main(): Promise { } : resolveInstallLayout(process.env, process.argv); await serviceContainer.updateService.enableServerUpdater(updateLayout, { + refreshBlockers: () => serviceContainer.refreshRestartBlockers(), collectBlockers: () => serviceContainer.collectRestartBlockers(), restart: cleanup, }); diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index e01b4333636..86ad6e91fbc 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -725,6 +725,7 @@ export const RestartBlockerSchema = z.object({ kind: z.enum([ "active-streams", "pending-turns", + "workspace-inits", "queued-messages", "auto-retries", "terminals", diff --git a/src/node/orpc/server.ts b/src/node/orpc/server.ts index 04abfe4eb91..cea9818cb86 100644 --- a/src/node/orpc/server.ts +++ b/src/node/orpc/server.ts @@ -742,8 +742,13 @@ function isOAuthCallbackNavigationRequest(req: Pick): boolean { // User rationale: static HTML/CSS/JS must keep loading even when intermediaries rewrite // Origin/forwarded headers, while API and auth endpoints retain strict same-origin checks. + // /version joins the API set because the browser's reconnect probe fetches it cross-origin when + // the frontend is served from a different origin than the backend. return ( - req.path.startsWith("/orpc") || req.path.startsWith("/api") || req.path.startsWith("/auth/") + req.path.startsWith("/orpc") || + req.path.startsWith("/api") || + req.path.startsWith("/auth/") || + req.path === "/version" ); } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 61eb1694f98..20bbd012fae 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4747,7 +4747,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "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, queued messages, pending auto-retries, open or starting terminals, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version.", + "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, queued messages, pending auto-retries, open or starting terminals, 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**.", "", diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index 18e2a97f5ef..2dbd0ceb373 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -93,8 +93,11 @@ export function resolveInstallLayout( env.npm_config_registry ?? "https://registry.npmjs.org"; const url = new URL(registry); + // The staged package is executed by the smoke run, so a registry reachable over the network + // must be TLS-protected; plaintext is tolerated only on loopback, where no path exists to hijack. + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); if ( - !["https:", "http:"].includes(url.protocol) || + !(url.protocol === "https:" || (url.protocol === "http:" && loopback)) || url.username || url.password || url.search || diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 9d3d4841c85..f9f9325e518 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -104,6 +104,15 @@ describe("server install layout", () => { argv ); expect(result.supported && result.layout.registry).toBe("https://registry.example.com"); + for (const [registry, supported] of [ + ["http://registry.example.com", false], + ["http://127.0.0.1:4873", true], + ["https://user:secret@registry.example.com", false], + ] as const) { + expect( + resolveInstallLayout({ ...env, XUM_UPDATE_REGISTRY_URL: registry }, argv).supported + ).toBe(supported); + } }); test("fails closed for missing or conflicting lockfiles and malformed package metadata", async () => { const { env, argv, layout } = await fixture(); @@ -255,7 +264,14 @@ describe("server updater", () => { let blockers: RestartBlocker[] = [{ kind: "terminals", count: 1 }]; let activationFails = true; const updater = new ServerUpdater({ supported: true, layout }, undefined, { - collectBlockers: () => blockers, + refreshBlockers: () => { + events.push("refresh"); + return Promise.resolve(); + }, + collectBlockers: () => { + events.push("snapshot"); + return blockers; + }, restart: () => { events.push("restart"); return Promise.resolve(); @@ -271,14 +287,16 @@ describe("server updater", () => { await updater.downloadUpdate(); await updater.installUpdate(); expect(updater.getStatus()).toMatchObject({ type: "install-blocked", blockers }); - expect(events).toEqual([]); + expect(events).toEqual(["refresh", "snapshot"]); blockers = []; + events.length = 0; await updater.installUpdate(); expect(updater.getStatus()).toMatchObject({ type: "error", phase: "install" }); - expect(events).toEqual([]); + expect(events).toEqual(["refresh", "snapshot"]); activationFails = false; + events.length = 0; await Promise.all([updater.installUpdate(), updater.installUpdate()]); - expect(events).toEqual(["activate", "restart"]); + expect(events).toEqual(["refresh", "snapshot", "activate", "restart"]); }); test("serializes checks and downloads and refuses channel changes while busy", async () => { const { layout } = await fixture(); diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts index aa1d24a60eb..fa96ffa6c08 100644 --- a/src/node/services/serverUpdate/serverUpdater.ts +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -13,6 +13,8 @@ import { fetchDistTags } from "./registry"; import { stageUpdate } from "./staging"; export interface ServerUpdaterDeps { + /** Refreshes lazily updated blocker sources; the snapshot that follows stays synchronous. */ + refreshBlockers?: () => Promise; collectBlockers: () => RestartBlocker[]; restart: () => Promise; fetchDistTags?: typeof fetchDistTags; @@ -130,6 +132,7 @@ export class ServerUpdater { if (!this.layout || !this.stagedEntry || !this.availableVersion || this.installing) return; this.installing = true; try { + await this.deps.refreshBlockers?.(); const blockers = this.deps.collectBlockers(); if (blockers.length) { this.installing = false; diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index a2323becba4..4a5f98226c4 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -203,9 +203,11 @@ describe("ServiceContainer", () => { const workspace = services.workspaceService as unknown as { preflightSendCounts: Map; preflightExecCounts: Map; + initSettlementPromises: Map>; }; try { streams.workspaceStreams.set("streaming", {}); + workspace.initSettlementPromises.set("initializing", new Promise(() => undefined)); terminals.pendingSessionCreations.set("terminal-starting", 2); processes.processes.set("running", { status: "running", isForeground: false }); processes.processes.set("foreground", { status: "running", isForeground: true }); @@ -214,6 +216,7 @@ describe("ServiceContainer", () => { workspace.preflightExecCounts.set("executing", 1); expect(services.collectRestartBlockers()).toEqual([ { kind: "pending-turns", count: 1 }, + { kind: "workspace-inits", count: 1 }, { kind: "background-processes", count: 3 }, { kind: "active-streams", count: 1 }, { kind: "terminals", count: 2 }, @@ -224,6 +227,7 @@ describe("ServiceContainer", () => { processes.processes.clear(); workspace.preflightSendCounts.clear(); workspace.preflightExecCounts.clear(); + workspace.initSettlementPromises.clear(); } expect(services.collectRestartBlockers()).toEqual([]); }); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 4a5efd88775..600d4dcb74a 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -543,6 +543,11 @@ export class ServiceContainer { this.terminalService.setTerminalWindowManager(manager); } + /** Background process statuses refresh lazily, so refresh them before a blocker snapshot. */ + async refreshRestartBlockers(): Promise { + await this.backgroundProcessManager.list(); + } + collectRestartBlockers(): RestartBlocker[] { const blockers = this.workspaceService.collectRestartBlockers(); const counts: Array<[RestartBlocker["kind"], number]> = [ diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 3d7aaf5f88b..8f04a6f7245 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3964,6 +3964,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } const blockers: RestartBlocker[] = [ { kind: "pending-turns", count: pendingTurns.size }, + { kind: "workspace-inits", count: this.initSettlementPromises.size }, { kind: "queued-messages", count: queuedMessages }, { kind: "auto-retries", count: autoRetries }, { From 2e9ebe94dfedbee7da6ab890d2ce6f757839482b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:50:32 +0000 Subject: [PATCH 05/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20hard?= =?UTF-8?q?en=20the=20restart=20gate,=20registry=20trust,=20and=20shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Count workspaces from the start of provisioning (init abort controllers), not only from init settlement registration. - Require HTTPS registries outright; loopback plaintext can still traverse an inherited HTTP proxy. - Build the proxy dispatcher on first use so a malformed proxy variable is a check error instead of a startup crash. - Refuse to prune stages when the launcher no longer points at the running entry. - Abort a pending staged install during shutdown and wait for it to settle so a detached package manager cannot outlive the server. --- .../services/serverUpdate/installLayout.ts | 13 +++------- src/node/services/serverUpdate/registry.ts | 4 ++- .../serverUpdate/serverUpdate.test.ts | 24 ++++++++++++++++- .../services/serverUpdate/serverUpdater.ts | 26 ++++++++++++++++--- src/node/services/serverUpdate/staging.ts | 25 ++++++++++++++---- src/node/services/serviceContainer.test.ts | 6 ++++- src/node/services/serviceContainer.ts | 1 + src/node/services/updateService.ts | 5 ++++ src/node/services/workspaceService.ts | 7 ++++- 9 files changed, 89 insertions(+), 22 deletions(-) diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index 2dbd0ceb373..6b15303d5ee 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -93,16 +93,9 @@ export function resolveInstallLayout( env.npm_config_registry ?? "https://registry.npmjs.org"; const url = new URL(registry); - // The staged package is executed by the smoke run, so a registry reachable over the network - // must be TLS-protected; plaintext is tolerated only on loopback, where no path exists to hijack. - const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); - if ( - !(url.protocol === "https:" || (url.protocol === "http:" && loopback)) || - url.username || - url.password || - url.search || - url.hash - ) + // The staged package is executed by the smoke run, so the registry must be TLS-protected; even + // loopback plaintext can be routed through an inherited HTTP proxy. + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Unsupported update registry URL"); return { supported: true, diff --git a/src/node/services/serverUpdate/registry.ts b/src/node/services/serverUpdate/registry.ts index b8b76fbe0a3..8a93301acd8 100644 --- a/src/node/services/serverUpdate/registry.ts +++ b/src/node/services/serverUpdate/registry.ts @@ -2,12 +2,14 @@ import { EnvHttpProxyAgent, type Dispatcher } from "undici"; import { SERVER_UPDATE_CHECK_TIMEOUT_MS } from "@/constants/serverUpdate"; import { isExactVersion } from "./installLayout"; -const dispatcher = new EnvHttpProxyAgent(); +// Built on first use: a malformed proxy variable must surface as a check error, not crash startup. +let dispatcher: Dispatcher | undefined; export async function fetchDistTags( registry: string, request: (url: string, options: RequestInit) => Promise = fetch ): Promise<{ latest?: string; next?: string }> { + dispatcher ??= new EnvHttpProxyAgent(); const options: RequestInit & { dispatcher: Dispatcher } = { dispatcher, signal: AbortSignal.timeout(SERVER_UPDATE_CHECK_TIMEOUT_MS), diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index f9f9325e518..4522578f38d 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -106,7 +106,8 @@ describe("server install layout", () => { expect(result.supported && result.layout.registry).toBe("https://registry.example.com"); for (const [registry, supported] of [ ["http://registry.example.com", false], - ["http://127.0.0.1:4873", true], + ["http://127.0.0.1:4873", false], + ["https://registry.example.com:8443/npm", true], ["https://user:secret@registry.example.com", false], ] as const) { expect( @@ -298,6 +299,27 @@ describe("server updater", () => { await Promise.all([updater.installUpdate(), updater.installUpdate()]); expect(events).toEqual(["refresh", "snapshot", "activate", "restart"]); }); + test("shutdown aborts a pending stage and waits for it to settle", async () => { + const { layout } = await fixture(); + let observed: AbortSignal | undefined; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => Promise.resolve({ next: "2.0.0" }), + runInstall: (_layout, _version, _install, signal) => + new Promise((_resolve, reject) => { + observed = signal; + signal?.addEventListener("abort", () => reject(new Error("aborted"))); + }), + }); + await updater.checkForUpdates(); + const download = updater.downloadUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "downloading" }); + await updater.beginShutdown(); + await download; + expect(observed?.aborted).toBe(true); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "download" }); + }); test("serializes checks and downloads and refuses channel changes while busy", async () => { const { layout } = await fixture(); let resolveTags!: (tags: { next: string }) => void; diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts index fa96ffa6c08..20bd5ab7d04 100644 --- a/src/node/services/serverUpdate/serverUpdater.ts +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -30,6 +30,7 @@ export class ServerUpdater { private availableVersion: string | null = null; private stagedEntry: string | null = null; private installing = false; + private download: { abort: AbortController; settled: Promise } | null = null; constructor( result: LayoutResult, @@ -117,17 +118,36 @@ export class ServerUpdater { ) return; this.setStatus({ type: "downloading", percent: null }); + const abort = new AbortController(); + const download = { + abort, + settled: this.stage(this.layout, this.availableVersion, abort.signal), + }; + this.download = download; + await download.settled; + if (this.download === download) this.download = null; + } + + private async stage(layout: InstallLayout, version: string, signal: AbortSignal): Promise { try { this.stagedEntry = await (this.deps.runInstall ?? stageUpdate)( - this.layout, - this.availableVersion + layout, + version, + undefined, + signal ); - this.setStatus({ type: "downloaded", info: { version: this.availableVersion } }); + this.setStatus({ type: "downloaded", info: { version } }); } catch (error) { this.setStatus({ type: "error", phase: "download", message: getErrorMessage(error) }); } } + /** A detached package manager must not outlive the server and keep writing into the stage. */ + async beginShutdown(): Promise { + this.download?.abort.abort(); + await this.download?.settled; + } + async installUpdate(): Promise { if (!this.layout || !this.stagedEntry || !this.availableVersion || this.installing) return; this.installing = true; diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index 2cfd088ca80..b9baff0f5a4 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -25,7 +25,11 @@ export function installCommand( }; } -export async function verifyStagedPackage(dir: string, version: string): Promise { +export async function verifyStagedPackage( + dir: string, + version: string, + signal?: AbortSignal +): Promise { const packageDir = path.join(dir, "node_modules/@coder/xum"); if (readPackageVersion(packageDir) !== version) throw new Error("Staged package version does not match the requested update"); @@ -33,16 +37,23 @@ export async function verifyStagedPackage(dir: string, version: string): Promise if (!(await fs.stat(entry)).isFile()) throw new Error("Staged CLI entry is not a file"); using smoke = execFileAsync(process.execPath, [entry, "--version"], { timeoutMs: SERVER_UPDATE_SMOKE_TIMEOUT_MS, + signal, }); await smoke.result; return entry; } -async function runInstall(file: string, args: string[], cwd: string): Promise { +async function runInstall( + file: string, + args: string[], + cwd: string, + signal?: AbortSignal +): Promise { using install = execFileAsync(file, args, { cwd, timeoutMs: SERVER_UPDATE_INSTALL_TIMEOUT_MS, killTreeOnTermination: true, + signal, }); await install.result; } @@ -50,9 +61,13 @@ async function runInstall(file: string, args: string[], cwd: string): Promise { const command = installCommand(layout, version); + // Pruning must never remove the target of a launcher that was re-pointed behind this process. + if ((await fs.realpath(layout.launcher)) !== layout.entry) + throw new Error("Server launcher changed since startup"); const parent = path.dirname(layout.workdir); const active = await fs.realpath(layout.workdir); const dir = path.join(parent, `${SERVER_UPDATE_STAGING_PREFIX}${version}`); @@ -69,6 +84,6 @@ export async function stageUpdate( // Exclusive creation refuses pre-existing links, and never mutates the running installation. await fs.mkdir(dir); await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ private: true })); - await install(command.file, command.args, dir); - return verifyStagedPackage(dir, version); + await install(command.file, command.args, dir, signal); + return verifyStagedPackage(dir, version, signal); } diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 4a5f98226c4..9a3497fb3b3 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -204,10 +204,13 @@ describe("ServiceContainer", () => { preflightSendCounts: Map; preflightExecCounts: Map; initSettlementPromises: Map>; + initAbortControllers: Map; }; try { streams.workspaceStreams.set("streaming", {}); workspace.initSettlementPromises.set("initializing", new Promise(() => undefined)); + workspace.initAbortControllers.set("initializing", new AbortController()); + workspace.initAbortControllers.set("provisioning", new AbortController()); terminals.pendingSessionCreations.set("terminal-starting", 2); processes.processes.set("running", { status: "running", isForeground: false }); processes.processes.set("foreground", { status: "running", isForeground: true }); @@ -216,7 +219,7 @@ describe("ServiceContainer", () => { workspace.preflightExecCounts.set("executing", 1); expect(services.collectRestartBlockers()).toEqual([ { kind: "pending-turns", count: 1 }, - { kind: "workspace-inits", count: 1 }, + { kind: "workspace-inits", count: 2 }, { kind: "background-processes", count: 3 }, { kind: "active-streams", count: 1 }, { kind: "terminals", count: 2 }, @@ -228,6 +231,7 @@ describe("ServiceContainer", () => { workspace.preflightSendCounts.clear(); workspace.preflightExecCounts.clear(); workspace.initSettlementPromises.clear(); + workspace.initAbortControllers.clear(); } expect(services.collectRestartBlockers()).toEqual([]); }); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 600d4dcb74a..77910cd5cb7 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -600,6 +600,7 @@ export class ServiceContainer { // it, and nothing may dispatch through the provider/runtime services torn down below. shutdownStep("workspaceService.beginShutdown", () => this.workspaceService.beginShutdown()); shutdownStep("terminalService.beginShutdown", () => this.terminalService.beginShutdown()); + await shutdownStep("updateService.beginShutdown", () => this.updateService.beginShutdown()); const housekeepingSettled = this.startupHousekeepingSettled; if (housekeepingSettled != null) { await shutdownStep("startupHousekeeping.join", async () => { diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts index 2d2391c7710..d67ea257913 100644 --- a/src/node/services/updateService.ts +++ b/src/node/services/updateService.ts @@ -11,6 +11,7 @@ interface UpdaterImpl { checkForUpdates(options?: { source?: "auto" | "manual" }): void | Promise; downloadUpdate(): Promise; installUpdate(): void | Promise; + beginShutdown?(): Promise; subscribe(callback: (status: UpdateStatus) => void): () => void; getStatus(): UpdateStatus; getChannel(): UpdateChannel; @@ -115,6 +116,10 @@ export class UpdateService { } } + async beginShutdown(): Promise { + await this.impl?.beginShutdown?.(); + } + getChannel(): UpdateChannel { if (this.impl) { return this.impl.getChannel(); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 8f04a6f7245..bb87509c05a 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3964,7 +3964,12 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } const blockers: RestartBlocker[] = [ { kind: "pending-turns", count: pendingTurns.size }, - { kind: "workspace-inits", count: this.initSettlementPromises.size }, + { + kind: "workspace-inits", + // Controllers exist from the start of provisioning; settlements from init start onward. + count: new Set([...this.initAbortControllers.keys(), ...this.initSettlementPromises.keys()]) + .size, + }, { kind: "queued-messages", count: queuedMessages }, { kind: "auto-retries", count: autoRetries }, { From 1d4d584a1f12dbc45bf37e1e43a2ad80542ac25d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:15:49 +0000 Subject: [PATCH 06/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20foll?= =?UTF-8?q?ow=20the=20mux=20shim,=20widen=20the=20restart=20gate,=20latch?= =?UTF-8?q?=20shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve the published mux forwarding shim (bin/mux.js) to the @coder/xum entry so the registry module layout is recognized under every package manager; activation and pruning use the same resolution. - Block restarts while workspaces are being removed, archived, forked, or staged, and refuse new removals and archives once shutdown has begun. - Latch the updater on shutdown so no check, download, or install can start a detached installer after cleanup began. - Reload the browser when git_describe differs too, so a stable build on the same commit as a nightly is picked up. - Probe /version only when the bundle is served from the backend origin; split-origin dev bundles and extension webviews cannot be refreshed by a reload. Generate src/version.ts before the VS Code extension build. - Document that registries must be HTTPS without credentials. --- Makefile | 2 +- docs/config/server-access.mdx | 4 +-- src/browser/contexts/API.test.tsx | 14 ++++++--- src/browser/contexts/API.tsx | 16 +++++++--- src/browser/features/About/AboutDialog.tsx | 1 + src/common/orpc/schemas/stream.ts | 1 + src/node/orpc/server.ts | 7 +---- .../builtInSkillContent.generated.ts | 4 +-- src/node/services/serverUpdate/activation.ts | 4 +-- .../services/serverUpdate/installLayout.ts | 30 ++++++++++++++++-- .../serverUpdate/serverUpdate.test.ts | 31 +++++++++++++++++-- .../services/serverUpdate/serverUpdater.ts | 13 +++++++- src/node/services/serverUpdate/staging.ts | 9 ++++-- src/node/services/serviceContainer.test.ts | 8 +++++ src/node/services/workspaceService.ts | 11 +++++++ 15 files changed, 127 insertions(+), 28 deletions(-) diff --git a/Makefile b/Makefile index d3347c4f8de..fa787819f13 100644 --- a/Makefile +++ b/Makefile @@ -545,7 +545,7 @@ check-appimage-icons: ## Validate AppImage icon structure (requires prior dist-l ## VS Code Extension (delegates to vscode/Makefile) -vscode-ext: ## Build VS Code extension (.vsix) +vscode-ext: src/version.ts ## Build VS Code extension (.vsix) @$(MAKE) -C vscode build vscode-ext-install: ## Build and install VS Code extension locally diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx index 05abf418696..c393fb28e53 100644 --- a/docs/config/server-access.mdx +++ b/docs/config/server-access.mdx @@ -80,11 +80,11 @@ Equivalent CLI options: ## Updating the server -Open **About** 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. +Open **About** 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); registries that require authentication for metadata report the registry error at check time. 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, queued messages, pending auto-retries, open or starting terminals, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version. +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, forked, or staged, queued messages, pending auto-retries, open or starting terminals, 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**. diff --git a/src/browser/contexts/API.test.tsx b/src/browser/contexts/API.test.tsx index 5f9ba5cdfb1..8247ece4933 100644 --- a/src/browser/contexts/API.test.tsx +++ b/src/browser/contexts/API.test.tsx @@ -241,9 +241,10 @@ describe("API reconnection", () => { expect(MockWebSocket.instances).toHaveLength(0); }); - test.each(["changed", "same", "unreachable", "malformed"])( + 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) => { @@ -259,6 +260,7 @@ describe("API reconnection", () => { : { git_commit: scenario === "changed" ? "different-server-commit" : VERSION.git_commit, + git_describe: scenario === "rebuilt" ? "v9.9.9-rebuilt" : VERSION.git_describe, } ), { status: 200 } @@ -289,10 +291,14 @@ describe("API reconnection", () => { MockWebSocket.lastInstance()!.simulateOpen(); await Promise.resolve(); }); - expect(requests).toEqual(["https://coder.example.com/@u/ws/apps/mux/version"]); - expect(reload).toHaveBeenCalledTimes(scenario === "changed" ? 1 : 0); - if (scenario !== "changed") expect(latestState!.status).toBe("connected"); + 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; } ); diff --git a/src/browser/contexts/API.tsx b/src/browser/contexts/API.tsx index cd21172ec8d..5545be54cc0 100644 --- a/src/browser/contexts/API.tsx +++ b/src/browser/contexts/API.tsx @@ -257,10 +257,17 @@ function ManagedAPIProvider(props: Omit) { client.general .ping("auth-check") .then(async () => { - // A reconnected socket may belong to a newer server than this loaded bundle. - if (hasConnectedRef.current && connectionId === connectionIdRef.current) { + // A reconnected socket may belong to a newer server than this loaded bundle. Only a + // bundle served by that server can be refreshed by reloading, so split-origin setups + // (VITE_BACKEND_URL, extension webviews) skip the probe. + const backendBaseUrl = getBrowserBackendBaseUrl(); + if ( + hasConnectedRef.current && + connectionId === connectionIdRef.current && + new URL(backendBaseUrl).origin === window.location.origin + ) { try { - const response = await fetch(`${getBrowserBackendBaseUrl()}/version`, { + const response = await fetch(`${backendBaseUrl}/version`, { cache: "no-store", signal: AbortSignal.timeout(SERVER_VERSION_CHECK_TIMEOUT_MS), }); @@ -272,7 +279,8 @@ function ManagedAPIProvider(props: Omit) { "git_commit" in version && typeof version.git_commit === "string" && version.git_commit.length > 0 && - version.git_commit !== VERSION.git_commit + (version.git_commit !== VERSION.git_commit || + ("git_describe" in version && version.git_describe !== VERSION.git_describe)) ) { window.location.reload(); return; diff --git a/src/browser/features/About/AboutDialog.tsx b/src/browser/features/About/AboutDialog.tsx index 488192ed2bb..4617ec45da6 100644 --- a/src/browser/features/About/AboutDialog.tsx +++ b/src/browser/features/About/AboutDialog.tsx @@ -19,6 +19,7 @@ const blockerLabels: Record = { "active-streams": "Active streams", "pending-turns": "Pending turns", "workspace-inits": "Workspaces still initializing", + "workspace-lifecycle": "Workspaces being archived, removed, forked, or staged", "queued-messages": "Sessions with queued messages", "auto-retries": "Pending auto-retries", terminals: "Open terminals", diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 86ad6e91fbc..763c4f285f6 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -726,6 +726,7 @@ export const RestartBlockerSchema = z.object({ "active-streams", "pending-turns", "workspace-inits", + "workspace-lifecycle", "queued-messages", "auto-retries", "terminals", diff --git a/src/node/orpc/server.ts b/src/node/orpc/server.ts index cea9818cb86..04abfe4eb91 100644 --- a/src/node/orpc/server.ts +++ b/src/node/orpc/server.ts @@ -742,13 +742,8 @@ function isOAuthCallbackNavigationRequest(req: Pick): boolean { // User rationale: static HTML/CSS/JS must keep loading even when intermediaries rewrite // Origin/forwarded headers, while API and auth endpoints retain strict same-origin checks. - // /version joins the API set because the browser's reconnect probe fetches it cross-origin when - // the frontend is served from a different origin than the backend. return ( - req.path.startsWith("/orpc") || - req.path.startsWith("/api") || - req.path.startsWith("/auth/") || - req.path === "/version" + req.path.startsWith("/orpc") || req.path.startsWith("/api") || req.path.startsWith("/auth/") ); } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 20bbd012fae..b5a7051274d 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4743,11 +4743,11 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## Updating the server", "", - "Open **About** 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.", + "Open **About** 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); registries that require authentication for metadata report the registry error at check time.", "", "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, queued messages, pending auto-retries, open or starting terminals, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version.", + "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, forked, or staged, queued messages, pending auto-retries, open or starting terminals, 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**.", "", diff --git a/src/node/services/serverUpdate/activation.ts b/src/node/services/serverUpdate/activation.ts index 22d8fda9a0c..6a0dcf920f4 100644 --- a/src/node/services/serverUpdate/activation.ts +++ b/src/node/services/serverUpdate/activation.ts @@ -1,11 +1,11 @@ import { lstatSync, realpathSync, renameSync, symlinkSync, unlinkSync } from "node:fs"; import { randomUUID } from "node:crypto"; -import type { InstallLayout } from "./installLayout"; +import { resolveCliEntry, type InstallLayout } from "./installLayout"; export function activateUpdate(layout: InstallLayout, stagedEntry: string): void { if ( !lstatSync(layout.launcher).isSymbolicLink() || - realpathSync(layout.launcher) !== layout.entry + resolveCliEntry(layout.launcher) !== layout.entry ) { throw new Error("Server launcher changed since startup"); } diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index 6b15303d5ee..202c5659a7a 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -1,5 +1,6 @@ import { getErrorMessage } from "@/common/utils/errors"; import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; import * as path from "node:path"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import type { UpdateChannel } from "@/common/types/project"; @@ -28,6 +29,31 @@ export function isExactVersion(value: unknown): value is string { ); } +/** + * Real path of the CLI entry a launcher runs. The published `mux` package is a forwarding shim + * (`bin/mux.js` requiring `@coder/xum`), which the registry module installs by default, so the + * shim is followed to the xum entry it forwards to. + */ +export function resolveCliEntry(file: string): string { + const real = realpathSync(file); + const shimPackage = path.dirname(path.dirname(real)); + if (path.basename(path.dirname(real)) === "bin" && readPackageName(shimPackage) === "mux") { + return realpathSync(createRequire(real).resolve("@coder/xum/dist/cli/index.js")); + } + return real; +} + +function readPackageName(packageDir: string): string | undefined { + try { + const pkg: unknown = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")); + return pkg && typeof pkg === "object" && "name" in pkg && typeof pkg.name === "string" + ? pkg.name + : undefined; + } catch { + return undefined; + } +} + export function readPackageVersion(packageDir: string): string { const pkg: unknown = JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")); if ( @@ -61,8 +87,8 @@ export function resolveInstallLayout( throw new Error("Server must be started through its launcher symlink"); const launcher = path.resolve(resolveXumEnvironmentValue("BINARY", env) ?? running); if (!lstatSync(launcher).isSymbolicLink()) throw new Error("Server launcher must be a symlink"); - const entry = realpathSync(running); - if (realpathSync(launcher) !== entry) + const entry = resolveCliEntry(running); + if (resolveCliEntry(launcher) !== entry) throw new Error("Server launcher does not point to the running entry"); const packageDir = path.dirname(path.dirname(path.dirname(entry))); if (entry !== path.join(packageDir, "dist", "cli", "index.js")) diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 4522578f38d..2470dcb594c 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -33,11 +33,25 @@ async function writePackage( return { entry, bin }; } -async function fixture(manager: InstallLayout["packageManager"] = "bun", version = "1.0.0-next.1") { +async function writeMuxShim(workdir: string): Promise { + const shimDir = path.join(workdir, "node_modules/mux"); + await fs.mkdir(path.join(shimDir, "bin"), { recursive: true }); + await fs.writeFile(path.join(shimDir, "package.json"), JSON.stringify({ name: "mux" })); + const shim = path.join(shimDir, "bin/mux.js"); + await fs.writeFile(shim, 'require("@coder/xum/dist/cli/index.js");'); + return shim; +} + +async function fixture( + manager: InstallLayout["packageManager"] = "bun", + version = "1.0.0-next.1", + launcherTarget: "xum" | "shim" = "xum" +) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "server-update-")); dirs.push(root); const workdir = path.join(root, "npm"); - const { bin } = await writePackage(workdir, version); + const { bin: xumBin } = await writePackage(workdir, version); + const bin = launcherTarget === "shim" ? await writeMuxShim(workdir) : xumBin; if (manager === "pnpm") { const packageDir = path.join(workdir, "node_modules/@coder/xum"); const storeDir = path.join(workdir, "node_modules/.pnpm/xum/node_modules/@coder/xum"); @@ -76,6 +90,16 @@ describe("server install layout", () => { expect(inferChannel("1.0.0")).toBe("stable"); }); } + test("follows the published mux shim to the xum entry it forwards to", async () => { + const { layout, root } = await fixture("bun", "1.0.0-next.1", "shim"); + expect(layout.entry).toBe(path.join(root, "npm/node_modules/@coder/xum/dist/cli/index.js")); + expect(layout.version).toBe("1.0.0-next.1"); + const bin = await stageUpdate(layout, "2.0.0", async (_file, _args, cwd) => { + await writePackage(cwd, "2.0.0"); + }); + activateUpdate(layout, bin); + expect(await fs.readlink(layout.launcher)).toBe(bin); + }); test("requires supervisor, a symlink, and a matching running entry", async () => { const { env, argv, layout, root } = await fixture(); expect(resolveInstallLayout({ MUX_BINARY: env.MUX_BINARY }, argv).supported).toBe(false); @@ -319,6 +343,9 @@ describe("server updater", () => { await download; expect(observed?.aborted).toBe(true); expect(updater.getStatus()).toMatchObject({ type: "error", phase: "download" }); + observed = undefined; + await updater.downloadUpdate(); + expect(observed).toBeUndefined(); }); test("serializes checks and downloads and refuses channel changes while busy", async () => { const { layout } = await fixture(); diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts index 20bd5ab7d04..3bfedcbb1be 100644 --- a/src/node/services/serverUpdate/serverUpdater.ts +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -30,6 +30,7 @@ export class ServerUpdater { private availableVersion: string | null = null; private stagedEntry: string | null = null; private installing = false; + private shuttingDown = false; private download: { abort: AbortController; settled: Promise } | null = null; constructor( @@ -81,6 +82,7 @@ export class ServerUpdater { async checkForUpdates(options?: { source?: "auto" | "manual" }): Promise { if ( !this.layout || + this.shuttingDown || this.installing || this.status.type === "checking" || this.status.type === "downloading" || @@ -110,6 +112,7 @@ export class ServerUpdater { async downloadUpdate(): Promise { if ( !this.layout || + this.shuttingDown || !this.availableVersion || this.stagedEntry || this.installing || @@ -144,12 +147,20 @@ export class ServerUpdater { /** A detached package manager must not outlive the server and keep writing into the stage. */ async beginShutdown(): Promise { + this.shuttingDown = true; this.download?.abort.abort(); await this.download?.settled; } async installUpdate(): Promise { - if (!this.layout || !this.stagedEntry || !this.availableVersion || this.installing) return; + if ( + !this.layout || + this.shuttingDown || + !this.stagedEntry || + !this.availableVersion || + this.installing + ) + return; this.installing = true; try { await this.deps.refreshBlockers?.(); diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index b9baff0f5a4..f5dcf80ab83 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -6,7 +6,12 @@ import { SERVER_UPDATE_SMOKE_TIMEOUT_MS, SERVER_UPDATE_STAGING_PREFIX, } from "@/constants/serverUpdate"; -import { isExactVersion, readPackageVersion, type InstallLayout } from "./installLayout"; +import { + isExactVersion, + readPackageVersion, + resolveCliEntry, + type InstallLayout, +} from "./installLayout"; export function installCommand( layout: InstallLayout, @@ -66,7 +71,7 @@ export async function stageUpdate( ): Promise { const command = installCommand(layout, version); // Pruning must never remove the target of a launcher that was re-pointed behind this process. - if ((await fs.realpath(layout.launcher)) !== layout.entry) + if (resolveCliEntry(layout.launcher) !== layout.entry) throw new Error("Server launcher changed since startup"); const parent = path.dirname(layout.workdir); const active = await fs.realpath(layout.workdir); diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 9a3497fb3b3..f06fe79aadd 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -205,12 +205,17 @@ describe("ServiceContainer", () => { preflightExecCounts: Map; initSettlementPromises: Map>; initAbortControllers: Map; + removingWorkspaces: Set; + archivingWorkspaces: Set; }; try { streams.workspaceStreams.set("streaming", {}); workspace.initSettlementPromises.set("initializing", new Promise(() => undefined)); workspace.initAbortControllers.set("initializing", new AbortController()); workspace.initAbortControllers.set("provisioning", new AbortController()); + workspace.removingWorkspaces.add("removing"); + workspace.archivingWorkspaces.add("archiving"); + workspace.archivingWorkspaces.add("removing"); terminals.pendingSessionCreations.set("terminal-starting", 2); processes.processes.set("running", { status: "running", isForeground: false }); processes.processes.set("foreground", { status: "running", isForeground: true }); @@ -220,6 +225,7 @@ describe("ServiceContainer", () => { expect(services.collectRestartBlockers()).toEqual([ { kind: "pending-turns", count: 1 }, { kind: "workspace-inits", count: 2 }, + { kind: "workspace-lifecycle", count: 2 }, { kind: "background-processes", count: 3 }, { kind: "active-streams", count: 1 }, { kind: "terminals", count: 2 }, @@ -232,6 +238,8 @@ describe("ServiceContainer", () => { workspace.preflightExecCounts.clear(); workspace.initSettlementPromises.clear(); workspace.initAbortControllers.clear(); + workspace.removingWorkspaces.clear(); + workspace.archivingWorkspaces.clear(); } expect(services.collectRestartBlockers()).toEqual([]); }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index bb87509c05a..328880f8a1e 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3970,6 +3970,15 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { count: new Set([...this.initAbortControllers.keys(), ...this.initSettlementPromises.keys()]) .size, }, + { + kind: "workspace-lifecycle", + count: new Set([ + ...this.removingWorkspaces, + ...this.archivingWorkspaces, + ...this.preflightForkCounts.keys(), + ...this.preflightStagingCounts.keys(), + ]).size, + }, { kind: "queued-messages", count: queuedMessages }, { kind: "auto-retries", count: autoRetries }, { @@ -5551,6 +5560,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } private async removeUnlocked(workspaceId: string, force = false): Promise> { + if (this.shuttingDown) return Err("Server is shutting down"); // Idempotent: if already removing, return success to prevent race conditions if (this.removingWorkspaces.has(workspaceId)) { return Ok(undefined); @@ -8396,6 +8406,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { acknowledgedUntrackedPaths?: string[], options?: ArchiveWorkspaceOptions ): Promise> { + if (this.shuttingDown) return Err("Server is shutting down"); this.archivingWorkspaces.add(workspaceId); let admissionHold: Disposable | undefined; From 479d7b80e9fe940443ab2a03d60e501dd65f4c9b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:35:18 +0000 Subject: [PATCH 07/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20pars?= =?UTF-8?q?e-only=20staging=20check,=20argv=20launcher,=20rename=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Verify a staged package with node --check instead of executing it; nothing from the registry runs until the operator activates it. - Always re-point the launcher the process was started through (argv[1]); a declared XUM_BINARY/MUX_BINARY may only confirm that path. - Count renames and context mutations as lifecycle blockers and refuse new renames once shutdown has begun. - Pass --no-global to npm so inherited global config cannot redirect the stage. - Roll the runtime channel back when persisting the preference fails. --- .../services/serverUpdate/installLayout.ts | 16 ++++++------ .../serverUpdate/serverUpdate.test.ts | 14 +++++++---- src/node/services/serverUpdate/staging.ts | 5 ++-- src/node/services/serviceContainer.test.ts | 5 +++- src/node/services/updateService.test.ts | 25 +++++++++++++++++++ src/node/services/updateService.ts | 11 ++++++-- src/node/services/workspaceService.ts | 3 +++ 7 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index 202c5659a7a..75754d65ef0 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -81,15 +81,15 @@ export function resolveInstallLayout( } const running = argv[1]; if (!running) throw new Error("Cannot identify the server launcher"); - // The supervisor relaunches whatever path it started; a direct entry path would keep running - // the old version after the launcher symlink is re-pointed. - if (!lstatSync(path.resolve(running)).isSymbolicLink()) + // The supervisor relaunches argv[1], so that symlink is the one to re-point: a direct entry + // path would keep running the old version, and a declared binary may only confirm the path. + const launcher = path.resolve(running); + if (!lstatSync(launcher).isSymbolicLink()) throw new Error("Server must be started through its launcher symlink"); - const launcher = path.resolve(resolveXumEnvironmentValue("BINARY", env) ?? running); - if (!lstatSync(launcher).isSymbolicLink()) throw new Error("Server launcher must be a symlink"); - const entry = resolveCliEntry(running); - if (resolveCliEntry(launcher) !== entry) - throw new Error("Server launcher does not point to the running entry"); + const declared = resolveXumEnvironmentValue("BINARY", env); + if (declared !== undefined && path.resolve(declared) !== launcher) + throw new Error("Server launcher does not match the declared binary"); + const entry = resolveCliEntry(launcher); const packageDir = path.dirname(path.dirname(path.dirname(entry))); if (entry !== path.join(packageDir, "dist", "cli", "index.js")) throw new Error("Unsupported server entry layout"); diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 2470dcb594c..3b026713ec5 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -109,11 +109,15 @@ describe("server install layout", () => { expect( resolveInstallLayout({ XUM_SERVER_SUPERVISED: "1" }, ["node", layout.launcher]).supported ).toBe(true); - const other = await writePackage(path.join(root, "other"), "2.0.0"); - await fs.writeFile(path.join(root, "other/bun.lock"), ""); - expect(resolveInstallLayout(env, ["node", other.bin]).supported).toBe(false); - // A declared launcher does not excuse starting the entry file directly. + // A declared launcher does not excuse starting the entry file directly, and it must be the + // symlink the process was started through (the one the supervisor relaunches). expect(resolveInstallLayout(env, ["node", layout.entry]).supported).toBe(false); + const twin = path.join(root, "twin-mux"); + await fs.symlink(await fs.readlink(layout.launcher), twin); + expect(resolveInstallLayout(env, ["node", twin]).supported).toBe(false); + expect(resolveInstallLayout({ RESTART_ON_KILL_VALUE: "true" }, ["node", twin]).supported).toBe( + true + ); }); test("honors canonical environment values and registry precedence", async () => { const { env, argv, layout } = await fixture(); @@ -196,7 +200,7 @@ describe("staging and activation", () => { test("verification rejects mismatched versions, missing entrypoints, and failing smoke runs", async () => { const { layout } = await fixture(); await expectFailure(() => verifyStagedPackage(layout.workdir, "9.0.0")); - await fs.writeFile(layout.entry, "process.exit(1)"); + await fs.writeFile(layout.entry, "this is not javascript ("); await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); await fs.unlink(layout.entry); await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index f5dcf80ab83..ae2b65c2b4b 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -21,7 +21,7 @@ export function installCommand( const spec = `@coder/xum@${version}`; const flags = { bun: ["add", "--ignore-scripts", "--exact"], - npm: ["install", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"], + npm: ["install", "--no-global", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"], pnpm: ["add", "--ignore-scripts"], } satisfies Record; return { @@ -40,7 +40,8 @@ export async function verifyStagedPackage( throw new Error("Staged package version does not match the requested update"); const entry = path.join(packageDir, "dist/cli/index.js"); if (!(await fs.stat(entry)).isFile()) throw new Error("Staged CLI entry is not a file"); - using smoke = execFileAsync(process.execPath, [entry, "--version"], { + // Parse-only: nothing from the registry runs until the operator activates it. + using smoke = execFileAsync(process.execPath, ["--check", entry], { timeoutMs: SERVER_UPDATE_SMOKE_TIMEOUT_MS, signal, }); diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index f06fe79aadd..a0ead29d53b 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -207,6 +207,7 @@ describe("ServiceContainer", () => { initAbortControllers: Map; removingWorkspaces: Set; archivingWorkspaces: Set; + renamingWorkspaces: Set; }; try { streams.workspaceStreams.set("streaming", {}); @@ -216,6 +217,7 @@ describe("ServiceContainer", () => { workspace.removingWorkspaces.add("removing"); workspace.archivingWorkspaces.add("archiving"); workspace.archivingWorkspaces.add("removing"); + workspace.renamingWorkspaces.add("renaming"); terminals.pendingSessionCreations.set("terminal-starting", 2); processes.processes.set("running", { status: "running", isForeground: false }); processes.processes.set("foreground", { status: "running", isForeground: true }); @@ -225,7 +227,7 @@ describe("ServiceContainer", () => { expect(services.collectRestartBlockers()).toEqual([ { kind: "pending-turns", count: 1 }, { kind: "workspace-inits", count: 2 }, - { kind: "workspace-lifecycle", count: 2 }, + { kind: "workspace-lifecycle", count: 3 }, { kind: "background-processes", count: 3 }, { kind: "active-streams", count: 1 }, { kind: "terminals", count: 2 }, @@ -240,6 +242,7 @@ describe("ServiceContainer", () => { workspace.initAbortControllers.clear(); workspace.removingWorkspaces.clear(); workspace.archivingWorkspaces.clear(); + workspace.renamingWorkspaces.clear(); } expect(services.collectRestartBlockers()).toEqual([]); }); diff --git a/src/node/services/updateService.test.ts b/src/node/services/updateService.test.ts index 07aa47cd41e..a68af7422ed 100644 --- a/src/node/services/updateService.test.ts +++ b/src/node/services/updateService.test.ts @@ -45,4 +45,29 @@ describe("UpdateService channel persistence", () => { expect(setUpdateChannel).toHaveBeenLastCalledWith("stable"); expect(service.getChannel()).toBe("stable"); }); + + it("restores the runtime channel when persistence fails", async () => { + const { config, setUpdateChannel } = createMockConfig("stable"); + setUpdateChannel.mockRejectedValueOnce(new Error("disk full")); + const service = new UpdateService(config); + const channels: UpdateChannel[] = []; + const internal = service as unknown as { + impl: { setChannel(channel: UpdateChannel): void; getChannel(): UpdateChannel }; + currentStatus: { type: string }; + }; + internal.impl = { + setChannel: (channel) => channels.push(channel), + getChannel: () => channels.at(-1) ?? "stable", + }; + internal.currentStatus = { type: "idle" }; + let failed = false; + try { + await service.setChannel("nightly"); + } catch { + failed = true; + } + expect(failed).toBe(true); + expect(channels).toEqual(["nightly", "stable"]); + expect(service.getChannel()).toBe("stable"); + }); }); diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts index d67ea257913..3c6a8fba135 100644 --- a/src/node/services/updateService.ts +++ b/src/node/services/updateService.ts @@ -131,11 +131,18 @@ export class UpdateService { async setChannel(channel: UpdateChannel): Promise { await this.ready; if (this.impl && this.currentStatus.type === "unsupported") return; - // Let the implementation reject busy-state changes before persisting the preference. + // Let the implementation reject busy-state changes before persisting the preference, and + // roll the runtime back if persistence fails so the two never disagree. + const previous = this.currentChannel; if (this.impl) { this.impl.setChannel(channel); } - await this.config.setUpdateChannel(channel); + try { + await this.config.setUpdateChannel(channel); + } catch (error) { + this.impl?.setChannel(previous); + throw error; + } this.currentChannel = channel; } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 328880f8a1e..1a2b3d32cc5 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3973,8 +3973,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { { kind: "workspace-lifecycle", count: new Set([ + ...this.renamingWorkspaces, ...this.removingWorkspaces, ...this.archivingWorkspaces, + ...this.contextMutationWorkspaces, ...this.preflightForkCounts.keys(), ...this.preflightStagingCounts.keys(), ]).size, @@ -6936,6 +6938,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { async rename(workspaceId: string, newName: string): Promise> { try { + if (this.shuttingDown) return Err("Server is shutting down"); if (this.aiService.isStreaming(workspaceId)) { return Err( "Cannot rename workspace while AI stream is active. Please wait for the stream to complete." From f81412a355b1ad1485edad81e1c76a48f1390f8e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:24:12 +0000 Subject: [PATCH 08/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20gate?= =?UTF-8?q?=20restarts=20on=20projects,=20workflows,=20desktops,=20and=20r?= =?UTF-8?q?equests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restart blockers now count in-process workflow runs, project clones and git inits, live or starting PortableDesktop sessions, and every other RPC call still in flight (an oRPC middleware counts calls; the install call is exempt). ProjectService latches new clones, creations, inits, and removals once teardown starts. Channel rollback restores the updater's inferred channel rather than the config fallback, pnpm staging installs pass --no-global, and the reconnect /version probe runs after the reconnected client is published. --- docs/config/server-access.mdx | 2 +- src/browser/contexts/API.tsx | 73 ++++++++++--------- src/browser/features/About/AboutDialog.tsx | 4 + src/common/orpc/schemas/stream.ts | 4 + src/node/orpc/inFlightProcedures.test.ts | 35 +++++++++ src/node/orpc/inFlightProcedures.ts | 25 +++++++ src/node/orpc/router.ts | 6 +- .../builtInSkillContent.generated.ts | 2 +- .../services/desktop/DesktopSessionManager.ts | 4 + src/node/services/projectService.test.ts | 21 ++++++ src/node/services/projectService.ts | 28 +++++++ src/node/services/serverUpdate/staging.ts | 2 +- src/node/services/serviceContainer.test.ts | 18 +++++ src/node/services/serviceContainer.ts | 7 ++ src/node/services/updateService.ts | 2 +- .../workflows/workflowArchiveAdmission.ts | 7 ++ 16 files changed, 202 insertions(+), 38 deletions(-) create mode 100644 src/node/orpc/inFlightProcedures.test.ts create mode 100644 src/node/orpc/inFlightProcedures.ts diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx index c393fb28e53..ea60770f61a 100644 --- a/docs/config/server-access.mdx +++ b/docs/config/server-access.mdx @@ -84,7 +84,7 @@ Open **About** to check for updates, download, then choose **Install & restart** 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, forked, or staged, queued messages, pending auto-retries, open or starting terminals, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version. +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**. diff --git a/src/browser/contexts/API.tsx b/src/browser/contexts/API.tsx index 5545be54cc0..cf4e05e096d 100644 --- a/src/browser/contexts/API.tsx +++ b/src/browser/contexts/API.tsx @@ -150,6 +150,33 @@ function createBrowserClient( }; } +async function reloadIfServerBuildChanged( + backendBaseUrl: string, + isCurrentConnection: () => boolean +): Promise { + 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) { const [state, setState] = useState({ status: "connecting" }); const [authToken, setAuthToken] = useState(() => { @@ -256,45 +283,14 @@ function ManagedAPIProvider(props: Omit) { client.general .ping("auth-check") - .then(async () => { - // A reconnected socket may belong to a newer server than this loaded bundle. Only a - // bundle served by that server can be refreshed by reloading, so split-origin setups - // (VITE_BACKEND_URL, extension webviews) skip the probe. - const backendBaseUrl = getBrowserBackendBaseUrl(); - if ( - hasConnectedRef.current && - connectionId === connectionIdRef.current && - new URL(backendBaseUrl).origin === window.location.origin - ) { - 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 ( - connectionId === connectionIdRef.current && - 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(); - return; - } - } catch { - // Version discovery must not prevent reconnecting after a transient HTTP failure. - } - } + .then(() => { // Ignore stale connections (e.g., auth-check returned after a new connect()). if (connectionId !== connectionIdRef.current) { cleanup(); return; } + const reconnected = hasConnectedRef.current; authRequiredRef.current = false; hasConnectedRef.current = true; reconnectAttemptRef.current = 0; @@ -303,6 +299,17 @@ function ManagedAPIProvider(props: Omit) { 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) { diff --git a/src/browser/features/About/AboutDialog.tsx b/src/browser/features/About/AboutDialog.tsx index 4617ec45da6..5c1c8bc3743 100644 --- a/src/browser/features/About/AboutDialog.tsx +++ b/src/browser/features/About/AboutDialog.tsx @@ -20,6 +20,10 @@ const blockerLabels: Record = { "pending-turns": "Pending turns", "workspace-inits": "Workspaces still initializing", "workspace-lifecycle": "Workspaces being archived, removed, forked, or staged", + workflows: "Workflow runs in progress", + projects: "Projects being cloned or created", + requests: "Requests in flight", + "desktop-sessions": "Live desktop sessions", "queued-messages": "Sessions with queued messages", "auto-retries": "Pending auto-retries", terminals: "Open terminals", diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 763c4f285f6..3a372c9d141 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -727,6 +727,10 @@ export const RestartBlockerSchema = z.object({ "pending-turns", "workspace-inits", "workspace-lifecycle", + "workflows", + "projects", + "requests", + "desktop-sessions", "queued-messages", "auto-retries", "terminals", diff --git a/src/node/orpc/inFlightProcedures.test.ts b/src/node/orpc/inFlightProcedures.test.ts new file mode 100644 index 00000000000..1a190bebce2 --- /dev/null +++ b/src/node/orpc/inFlightProcedures.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { inFlightProcedureCount, trackInFlightProcedure } from "./inFlightProcedures"; + +describe("in-flight procedure tracking", () => { + test("counts calls for their whole duration and settles on failure too", async () => { + let release!: () => void; + const pending = trackInFlightProcedure( + ["workspace", "remove"], + () => new Promise((resolve) => (release = resolve)) + ); + expect(inFlightProcedureCount()).toBe(1); + release(); + await pending; + expect(inFlightProcedureCount()).toBe(0); + let failed = false; + try { + await trackInFlightProcedure(["project", "create"], () => Promise.reject(new Error("boom"))); + } catch { + failed = true; + } + expect(failed).toBe(true); + expect(inFlightProcedureCount()).toBe(0); + }); + + test("the install call never blocks its own restart", async () => { + let release!: () => void; + const pending = trackInFlightProcedure( + ["update", "install"], + () => new Promise((resolve) => (release = resolve)) + ); + expect(inFlightProcedureCount()).toBe(0); + release(); + await pending; + }); +}); diff --git a/src/node/orpc/inFlightProcedures.ts b/src/node/orpc/inFlightProcedures.ts new file mode 100644 index 00000000000..c5d86e341d3 --- /dev/null +++ b/src/node/orpc/inFlightProcedures.ts @@ -0,0 +1,25 @@ +import { os } from "@orpc/server"; + +// Every RPC-driven mutation (project clone/create/remove, workspace rename, archive, ...) is in +// flight for exactly as long as its procedure call, so counting calls gates restarts on all of +// them without enumerating each operation. Subscriptions return their iterator immediately and +// therefore do not pin the count; the install call itself is the restart and is excluded. +let inFlight = 0; + +export function inFlightProcedureCount(): number { + return inFlight; +} + +export async function trackInFlightProcedure(path: readonly string[], run: () => Promise) { + if (path.join(".") === "update.install") return run(); + inFlight++; + try { + return await run(); + } finally { + inFlight--; + } +} + +export const inFlightProcedureMiddleware = os.middleware(async ({ path, next }) => { + return await trackInFlightProcedure(path, async () => next()); +}); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index ebbc2f51dcc..80b44bf0511 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -87,6 +87,7 @@ import { extractCookieValues, getFirstHeaderValue, } from "./authMiddleware"; +import { inFlightProcedureMiddleware } from "./inFlightProcedures"; import { clearLogsForApi, getLogFilePath } from "@/node/services/log"; import { @@ -169,7 +170,10 @@ async function getCurrentServerAuthSessionId(context: ORPCContext): Promise { - const t = os.$context().use(createAuthMiddleware(authToken)); + const t = os + .$context() + .use(createAuthMiddleware(authToken)) + .use(inFlightProcedureMiddleware); return t.router({ tokenizer: { diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index b5a7051274d..e51fa930293 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4747,7 +4747,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "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, forked, or staged, queued messages, pending auto-retries, open or starting terminals, and running background processes. Finish or stop that work, then retry. There is no automatic restart-when-idle in this version.", + "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**.", "", diff --git a/src/node/services/desktop/DesktopSessionManager.ts b/src/node/services/desktop/DesktopSessionManager.ts index 0c85cf8b2e1..96112d1bee4 100644 --- a/src/node/services/desktop/DesktopSessionManager.ts +++ b/src/node/services/desktop/DesktopSessionManager.ts @@ -24,6 +24,10 @@ import { export class DesktopSessionManager { private readonly sessions = new Map(); private readonly startupPromises = new Map>(); + + getSessionCount(): number { + return new Set([...this.sessions.keys(), ...this.startupPromises.keys()]).size; + } private workspaceArchiveGuard: ((workspaceId: string) => boolean) | undefined; /** diff --git a/src/node/services/projectService.test.ts b/src/node/services/projectService.test.ts index 167fe14dc6a..78b1b96170a 100644 --- a/src/node/services/projectService.test.ts +++ b/src/node/services/projectService.test.ts @@ -2195,6 +2195,27 @@ exit 1 } }); + describe("beginShutdown", () => { + it("refuses new project mutations once teardown starts", async () => { + const projectPath = path.join(tempDir, "late-project"); + await fs.mkdir(projectPath); + service.beginShutdown(); + + const outcomes = await Promise.allSettled([ + service.create(projectPath), + service.gitInit(projectPath), + service.remove(projectPath), + service.cloneWithProgress({ repoUrl: "https://example.invalid/repo.git" }).next(), + ]); + expect( + outcomes.map((outcome) => + outcome.status === "rejected" ? String(outcome.reason) : "fulfilled" + ) + ).toEqual(Array(4).fill("Error: Server is shutting down")); + expect(service.getMutationCount()).toBe(0); + }); + }); + describe("gitInit", () => { it("initializes git repo in non-git directory with initial commit", async () => { const testDir = path.join(tempDir, "new-project"); diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 28a81f401ea..dccd3aad82f 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -397,6 +397,8 @@ export class ProjectService { private readonly fileCompletionsCache = new Map(); /** Canonical paths with git initialization in flight; see create() claim below. */ private readonly activeGitInits = new Set(); + private activeClones = 0; + private shuttingDown = false; private directoryPicker?: (initialPath?: string | null) => Promise; private readonly sshPromptService: SshPromptService | undefined; private workspaceService?: WorkspaceRemover; @@ -475,6 +477,7 @@ export class ProjectService { options: { initGit?: boolean; displayName?: string } | undefined, lock: ProjectRegistrationLockHandle | null ): Promise> { + if (this.shuttingDown) throw new Error("Server is shutting down"); let gitInitClaimKey: string | null = null; try { // Validate input @@ -932,9 +935,32 @@ export class ProjectService { } } + /** Clones and git inits in flight; a restart between them would leave a partial project. */ + getMutationCount(): number { + return this.activeClones + this.activeGitInits.size; + } + + /** A mutation admitted during teardown would be killed half-done by the exit that follows. */ + beginShutdown(): void { + this.shuttingDown = true; + } + async *cloneWithProgress( input: CloneProjectParams, signal?: AbortSignal + ): AsyncGenerator { + if (this.shuttingDown) throw new Error("Server is shutting down"); + this.activeClones++; + try { + yield* this.cloneWithProgressTracked(input, signal); + } finally { + this.activeClones--; + } + } + + private async *cloneWithProgressTracked( + input: CloneProjectParams, + signal?: AbortSignal ): AsyncGenerator { const prepared = this.validateAndPrepareClone(input); if (!prepared.success) { @@ -1347,6 +1373,7 @@ export class ProjectService { } async remove(projectPath: string, force = false): Promise> { + if (this.shuttingDown) throw new Error("Server is shutting down"); try { const normalizedPath = stripTrailingSlashes(projectPath); let config = this.config.loadConfigOrDefault(); @@ -1744,6 +1771,7 @@ export class ProjectService { * Also handles "unborn" repos (git init already run but no commits yet). */ async gitInit(projectPath: string): Promise> { + if (this.shuttingDown) throw new Error("Server is shutting down"); if (typeof projectPath !== "string" || projectPath.trim().length === 0) { return Err("Project path is required"); } diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index ae2b65c2b4b..0efee1bec2d 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -22,7 +22,7 @@ export function installCommand( const flags = { bun: ["add", "--ignore-scripts", "--exact"], npm: ["install", "--no-global", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"], - pnpm: ["add", "--ignore-scripts"], + pnpm: ["add", "--no-global", "--ignore-scripts"], } satisfies Record; return { file: layout.packageManager, diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index a0ead29d53b..b6b768b02be 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -79,6 +79,7 @@ import { type AppTags, } from "@/node/services/di/tags"; import { ServiceContainer } from "./serviceContainer"; +import { registerInProcessWorkflowRun } from "@/node/services/workflows/workflowArchiveAdmission"; /** * Independent field → tag listing for every ORPC context field (the production @@ -200,6 +201,12 @@ describe("ServiceContainer", () => { const processes = services.backgroundProcessManager as unknown as { processes: Map; }; + const desktop = services.desktopSessionManager as unknown as { + sessions: Map; + startupPromises: Map>; + }; + const project = services.projectService as unknown as { activeGitInits: Set }; + let releaseWorkflow: (() => void) | undefined; const workspace = services.workspaceService as unknown as { preflightSendCounts: Map; preflightExecCounts: Map; @@ -218,6 +225,10 @@ describe("ServiceContainer", () => { workspace.archivingWorkspaces.add("archiving"); workspace.archivingWorkspaces.add("removing"); workspace.renamingWorkspaces.add("renaming"); + releaseWorkflow = registerInProcessWorkflowRun("workflow-workspace"); + desktop.sessions.set("desktop-live", {}); + desktop.startupPromises.set("desktop-starting", new Promise(() => undefined)); + project.activeGitInits.add("/tmp/new-project"); terminals.pendingSessionCreations.set("terminal-starting", 2); processes.processes.set("running", { status: "running", isForeground: false }); processes.processes.set("foreground", { status: "running", isForeground: true }); @@ -230,7 +241,10 @@ describe("ServiceContainer", () => { { kind: "workspace-lifecycle", count: 3 }, { kind: "background-processes", count: 3 }, { kind: "active-streams", count: 1 }, + { kind: "workflows", count: 1 }, + { kind: "projects", count: 1 }, { kind: "terminals", count: 2 }, + { kind: "desktop-sessions", count: 2 }, ]); } finally { streams.workspaceStreams.clear(); @@ -243,6 +257,10 @@ describe("ServiceContainer", () => { workspace.removingWorkspaces.clear(); workspace.archivingWorkspaces.clear(); workspace.renamingWorkspaces.clear(); + releaseWorkflow?.(); + desktop.sessions.clear(); + desktop.startupPromises.clear(); + project.activeGitInits.clear(); } expect(services.collectRestartBlockers()).toEqual([]); }); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 77910cd5cb7..6e73c208892 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -1,4 +1,6 @@ import type { RestartBlocker } from "@/common/orpc/types"; +import { inFlightProcedureCount } from "@/node/orpc/inFlightProcedures"; +import { inProcessWorkflowWorkspaceCount } from "@/node/services/workflows/workflowArchiveAdmission"; import { log } from "@/node/services/log"; import type { Config, ConfigStores, WorkspaceSessionLocator } from "@/node/config"; import type { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; @@ -552,7 +554,11 @@ export class ServiceContainer { const blockers = this.workspaceService.collectRestartBlockers(); const counts: Array<[RestartBlocker["kind"], number]> = [ ["active-streams", this.streamManager.getActiveStreams().length], + ["workflows", inProcessWorkflowWorkspaceCount()], + ["projects", this.projectService.getMutationCount()], + ["requests", inFlightProcedureCount()], ["terminals", this.terminalService.getOpenSessionCount()], + ["desktop-sessions", this.desktopSessionManager.getSessionCount()], ["background-processes", this.backgroundProcessManager.getRunningProcessCount()], ]; for (const [kind, count] of counts) { @@ -600,6 +606,7 @@ export class ServiceContainer { // it, and nothing may dispatch through the provider/runtime services torn down below. shutdownStep("workspaceService.beginShutdown", () => this.workspaceService.beginShutdown()); shutdownStep("terminalService.beginShutdown", () => this.terminalService.beginShutdown()); + shutdownStep("projectService.beginShutdown", () => this.projectService.beginShutdown()); await shutdownStep("updateService.beginShutdown", () => this.updateService.beginShutdown()); const housekeepingSettled = this.startupHousekeepingSettled; if (housekeepingSettled != null) { diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts index 3c6a8fba135..eda6392e96e 100644 --- a/src/node/services/updateService.ts +++ b/src/node/services/updateService.ts @@ -133,7 +133,7 @@ export class UpdateService { if (this.impl && this.currentStatus.type === "unsupported") return; // Let the implementation reject busy-state changes before persisting the preference, and // roll the runtime back if persistence fails so the two never disagree. - const previous = this.currentChannel; + const previous = this.impl?.getChannel() ?? this.currentChannel; if (this.impl) { this.impl.setChannel(channel); } diff --git a/src/node/services/workflows/workflowArchiveAdmission.ts b/src/node/services/workflows/workflowArchiveAdmission.ts index 902d6a593d9..65dcb8755da 100644 --- a/src/node/services/workflows/workflowArchiveAdmission.ts +++ b/src/node/services/workflows/workflowArchiveAdmission.ts @@ -69,6 +69,13 @@ export function registerInProcessWorkflowRun(workspaceId: string): () => void { return incrementInProcessWorkflowWork(workspaceId); } +/** Workspaces with a workflow admission or in-process runner, across the whole process. */ +export function inProcessWorkflowWorkspaceCount(): number { + let count = 0; + for (const value of inProcessWorkflowWorkByWorkspace.values()) if (value > 0) count++; + return count; +} + /** Whether any workflow admission or in-process runner exists for this workspace. */ export function hasInProcessWorkflowWork(workspaceId: string): boolean { return (inProcessWorkflowWorkByWorkspace.get(workspaceId) ?? 0) > 0; From a3dc2bfc4c9fc4797f7aba435a842c125c42392a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:07:24 +0000 Subject: [PATCH 09/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20refu?= =?UTF-8?q?se=20RPC=20calls=20during=20teardown,=20keep=20staged=20updates?= =?UTF-8?q?=20on=20channel=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerService.beginShutdown() latches at the start of ServiceContainer.dispose() and the in-flight procedure middleware refuses every new call except update.install with SERVICE_UNAVAILABLE, so no RPC-driven write can start while the socket is still served during teardown. Channel switches persist before the runtime switch and revert the write if the runtime refuses, so a failed write no longer discards a staged update. Dead desktop sessions no longer count as restart blockers. --- src/node/orpc/inFlightProcedures.test.ts | 29 +++++++++++++++++-- src/node/orpc/inFlightProcedures.ts | 25 ++++++++++++---- src/node/orpc/router.test.ts | 22 +++++++++++++- .../services/desktop/DesktopSessionManager.ts | 5 +++- src/node/services/serverService.ts | 13 +++++++++ src/node/services/serviceContainer.test.ts | 5 +++- src/node/services/serviceContainer.ts | 1 + src/node/services/updateService.test.ts | 29 +++++++++++++++++-- src/node/services/updateService.ts | 13 ++++----- 9 files changed, 123 insertions(+), 19 deletions(-) diff --git a/src/node/orpc/inFlightProcedures.test.ts b/src/node/orpc/inFlightProcedures.test.ts index 1a190bebce2..531a25a60b1 100644 --- a/src/node/orpc/inFlightProcedures.test.ts +++ b/src/node/orpc/inFlightProcedures.test.ts @@ -1,11 +1,16 @@ import { describe, expect, test } from "bun:test"; +import { ORPCError } from "@orpc/server"; import { inFlightProcedureCount, trackInFlightProcedure } from "./inFlightProcedures"; +const admit = () => true; +const refuse = () => false; + describe("in-flight procedure tracking", () => { test("counts calls for their whole duration and settles on failure too", async () => { let release!: () => void; const pending = trackInFlightProcedure( ["workspace", "remove"], + admit, () => new Promise((resolve) => (release = resolve)) ); expect(inFlightProcedureCount()).toBe(1); @@ -14,7 +19,9 @@ describe("in-flight procedure tracking", () => { expect(inFlightProcedureCount()).toBe(0); let failed = false; try { - await trackInFlightProcedure(["project", "create"], () => Promise.reject(new Error("boom"))); + await trackInFlightProcedure(["project", "create"], admit, () => + Promise.reject(new Error("boom")) + ); } catch { failed = true; } @@ -22,14 +29,32 @@ describe("in-flight procedure tracking", () => { expect(inFlightProcedureCount()).toBe(0); }); - test("the install call never blocks its own restart", async () => { + test("the install call never blocks its own restart, even once shutdown has begun", async () => { let release!: () => void; const pending = trackInFlightProcedure( ["update", "install"], + refuse, () => new Promise((resolve) => (release = resolve)) ); expect(inFlightProcedureCount()).toBe(0); release(); await pending; }); + + test("refuses every other call once shutdown has begun without running it", async () => { + let ran = false; + let error: unknown; + try { + await trackInFlightProcedure(["workspace", "stageAttachment"], refuse, () => { + ran = true; + return Promise.resolve(); + }); + } catch (caught) { + error = caught; + } + expect(ran).toBe(false); + expect(error).toBeInstanceOf(ORPCError); + expect((error as ORPCError).code).toBe("SERVICE_UNAVAILABLE"); + expect(inFlightProcedureCount()).toBe(0); + }); }); diff --git a/src/node/orpc/inFlightProcedures.ts b/src/node/orpc/inFlightProcedures.ts index c5d86e341d3..457407fd824 100644 --- a/src/node/orpc/inFlightProcedures.ts +++ b/src/node/orpc/inFlightProcedures.ts @@ -1,4 +1,5 @@ -import { os } from "@orpc/server"; +import { ORPCError, os } from "@orpc/server"; +import type { ServerService } from "@/node/services/serverService"; // Every RPC-driven mutation (project clone/create/remove, workspace rename, archive, ...) is in // flight for exactly as long as its procedure call, so counting calls gates restarts on all of @@ -10,8 +11,15 @@ export function inFlightProcedureCount(): number { return inFlight; } -export async function trackInFlightProcedure(path: readonly string[], run: () => Promise) { +export async function trackInFlightProcedure( + path: readonly string[], + admit: () => boolean, + run: () => Promise +) { if (path.join(".") === "update.install") return run(); + // Teardown keeps serving the socket until the very end; a mutation admitted then would be + // killed half-done by the exit, so refuse every new call once shutdown has begun. + if (!admit()) throw new ORPCError("SERVICE_UNAVAILABLE", { message: "Server is shutting down" }); inFlight++; try { return await run(); @@ -20,6 +28,13 @@ export async function trackInFlightProcedure(path: readonly string[], run: () } } -export const inFlightProcedureMiddleware = os.middleware(async ({ path, next }) => { - return await trackInFlightProcedure(path, async () => next()); -}); +// Optional because unit tests assemble partial contexts without a ServerService. +export const inFlightProcedureMiddleware = os + .$context<{ serverService?: Pick }>() + .middleware(async ({ context, path, next }) => { + return await trackInFlightProcedure( + path, + () => !context.serverService?.isShuttingDown(), + async () => next() + ); + }); diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index d7fc3b410ff..fd01d23ca99 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/await-thenable, @typescript-eslint/no-unsafe-argument, @typescript-eslint/require-await, local/no-sync-fs-methods */ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { createRouterClient } from "@orpc/server"; +import { createRouterClient, ORPCError } from "@orpc/server"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -243,4 +243,24 @@ describe("router config transcript mutation", () => { expect((await client.config.getConfig()).chatTranscriptFullWidth).toBe(false); expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); }); + + test("refuses procedure calls once the server has begun shutting down", async () => { + let shuttingDown = false; + const context = { + config, + serverService: { isShuttingDown: () => shuttingDown }, + } as unknown as ORPCContext; + const client = createRouterClient(router(), { context }); + expect(await client.general.ping("alive")).toBe("Pong: alive"); + + shuttingDown = true; + let error: unknown; + try { + await client.general.ping("late"); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(ORPCError); + expect((error as ORPCError).code).toBe("SERVICE_UNAVAILABLE"); + }); }); diff --git a/src/node/services/desktop/DesktopSessionManager.ts b/src/node/services/desktop/DesktopSessionManager.ts index fc28249f2f8..a4f6a1c7622 100644 --- a/src/node/services/desktop/DesktopSessionManager.ts +++ b/src/node/services/desktop/DesktopSessionManager.ts @@ -154,7 +154,10 @@ export class DesktopSessionManager { } getSessionCount(): number { - return new Set([...this.sessions.keys(), ...this.startupPromises.keys()]).size; + const live = new Set(this.startupPromises.keys()); + for (const [workspaceId, session] of this.sessions) + if (session.isAlive()) live.add(workspaceId); + return live.size; } private workspaceArchiveGuard: ((workspaceId: string) => boolean) | undefined; diff --git a/src/node/services/serverService.ts b/src/node/services/serverService.ts index 056ecc299b3..45ebe40d30c 100644 --- a/src/node/services/serverService.ts +++ b/src/node/services/serverService.ts @@ -297,6 +297,19 @@ export class ServerService { private serverInfo: ServerInfo | null = null; private readonly mdnsAdvertiser = new MdnsAdvertiserService(); private sshHost: string | undefined = undefined; + private shuttingDown = false; + + /** + * Process teardown has begun. The HTTP/WS server keeps accepting connections until stopServer() + * runs last, so the RPC layer consults this to refuse new procedure calls in the meantime. + */ + beginShutdown(): void { + this.shuttingDown = true; + } + + isShuttingDown(): boolean { + return this.shuttingDown; + } /** * Set the launch project path diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 101e518f149..a8b2c236075 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -224,7 +224,8 @@ describe("ServiceContainer", () => { workspace.archivingWorkspaces.add("removing"); workspace.renamingWorkspaces.add("renaming"); releaseWorkflow = registerInProcessWorkflowRun("workflow-workspace"); - desktop.sessions.set("desktop-live", {}); + desktop.sessions.set("desktop-live", { isAlive: () => true }); + desktop.sessions.set("desktop-exited", { isAlive: () => false }); desktop.startupPromises.set("desktop-starting", new Promise(() => undefined)); project.activeGitInits.add("/tmp/new-project"); terminals.pendingSessionCreations.set("terminal-starting", 2); @@ -265,7 +266,9 @@ describe("ServiceContainer", () => { it("refuses new sessions, commands, and terminals synchronously during disposal", async () => { services = new ServiceContainer(stores); + expect(services.serverService.isShuttingDown()).toBe(false); const disposal = services.dispose(); + expect(services.serverService.isShuttingDown()).toBe(true); expect(() => services!.workspaceService.getOrCreateSession("cold-workspace")).toThrow( "shutting down" ); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 8e6134e004a..af9c858fe75 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -699,6 +699,7 @@ export class ServiceContainer { // Chat recovery that housekeeping scheduled runs past its own promise and observes neither the // abort nor the join, so latch every session before the wait: nothing may start a stream inside // it, and nothing may dispatch through the provider/runtime services torn down below. + shutdownStep("serverService.beginShutdown", () => this.serverService.beginShutdown()); shutdownStep("workspaceService.beginShutdown", () => this.workspaceService.beginShutdown()); shutdownStep("terminalService.beginShutdown", () => this.terminalService.beginShutdown()); shutdownStep("projectService.beginShutdown", () => this.projectService.beginShutdown()); diff --git a/src/node/services/updateService.test.ts b/src/node/services/updateService.test.ts index a68af7422ed..ca482f0a366 100644 --- a/src/node/services/updateService.test.ts +++ b/src/node/services/updateService.test.ts @@ -46,7 +46,7 @@ describe("UpdateService channel persistence", () => { expect(service.getChannel()).toBe("stable"); }); - it("restores the runtime channel when persistence fails", async () => { + it("leaves the runtime untouched when persistence fails", async () => { const { config, setUpdateChannel } = createMockConfig("stable"); setUpdateChannel.mockRejectedValueOnce(new Error("disk full")); const service = new UpdateService(config); @@ -67,7 +67,32 @@ describe("UpdateService channel persistence", () => { failed = true; } expect(failed).toBe(true); - expect(channels).toEqual(["nightly", "stable"]); + expect(channels).toEqual([]); + expect(service.getChannel()).toBe("stable"); + }); + + it("reverts the persisted channel when the runtime refuses the switch", async () => { + const { config, setUpdateChannel } = createMockConfig("stable"); + const service = new UpdateService(config); + const internal = service as unknown as { + impl: { setChannel(channel: UpdateChannel): void; getChannel(): UpdateChannel }; + currentStatus: { type: string }; + }; + internal.impl = { + setChannel: () => { + throw new Error("An update operation is in progress"); + }, + getChannel: () => "stable", + }; + internal.currentStatus = { type: "downloading" }; + let failed = false; + try { + await service.setChannel("nightly"); + } catch { + failed = true; + } + expect(failed).toBe(true); + expect(setUpdateChannel.mock.calls.map((call) => call[0])).toEqual(["nightly", "stable"]); expect(service.getChannel()).toBe("stable"); }); }); diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts index eda6392e96e..641f3363a75 100644 --- a/src/node/services/updateService.ts +++ b/src/node/services/updateService.ts @@ -131,16 +131,15 @@ export class UpdateService { async setChannel(channel: UpdateChannel): Promise { await this.ready; if (this.impl && this.currentStatus.type === "unsupported") return; - // Let the implementation reject busy-state changes before persisting the preference, and - // roll the runtime back if persistence fails so the two never disagree. + // The runtime switch discards a staged update, so persist first: a failed write then costs + // nothing, and a runtime refusal (operation in progress) reverts the write so the two never + // disagree. const previous = this.impl?.getChannel() ?? this.currentChannel; - if (this.impl) { - this.impl.setChannel(channel); - } + await this.config.setUpdateChannel(channel); try { - await this.config.setUpdateChannel(channel); + this.impl?.setChannel(channel); } catch (error) { - this.impl?.setChannel(previous); + await this.config.setUpdateChannel(previous); throw error; } this.currentChannel = channel; From dc45dc1e1f203147f5f9e10ac55f6765c8a93147 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:37:26 +0000 Subject: [PATCH 10/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20keep?= =?UTF-8?q?=20aborted=20config=20writes=20in=20flight,=20recheck=20shutdow?= =?UTF-8?q?n=20before=20activating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config mutations run in an uninterruptible promise region (atomicPromise), so a client abort defers the handler fiber's exit until the write settles and the in-flight procedure count covers the write itself. The middleware counts each call once even though oRPC 1.14 applies builder middlewares at both the router and procedure level. installUpdate() rechecks the shutdown latch after the blocker refresh so an unrelated teardown never inherits the launcher swap. --- src/node/orpc/inFlightProcedures.ts | 11 +++- src/node/orpc/router.test.ts | 29 ++++++++++ src/node/orpc/router.ts | 56 +++++++++---------- .../serverUpdate/serverUpdate.test.ts | 25 +++++++++ .../services/serverUpdate/serverUpdater.ts | 6 ++ 5 files changed, 94 insertions(+), 33 deletions(-) diff --git a/src/node/orpc/inFlightProcedures.ts b/src/node/orpc/inFlightProcedures.ts index 457407fd824..c718321c131 100644 --- a/src/node/orpc/inFlightProcedures.ts +++ b/src/node/orpc/inFlightProcedures.ts @@ -28,13 +28,18 @@ export async function trackInFlightProcedure( } } -// Optional because unit tests assemble partial contexts without a ServerService. +// oRPC applies builder middlewares at both the router and the procedure level (1.14 dropped the +// leading-middleware dedupe), so the first pass marks the context and the second pass is a no-op. +const TRACKED = "inFlight/tracked"; + +// serverService is optional because unit tests assemble partial contexts without one. export const inFlightProcedureMiddleware = os - .$context<{ serverService?: Pick }>() + .$context<{ serverService?: Pick; [TRACKED]?: true }>() .middleware(async ({ context, path, next }) => { + if (context[TRACKED]) return await next(); return await trackInFlightProcedure( path, () => !context.serverService?.isShuttingDown(), - async () => next() + async () => next({ context: { [TRACKED]: true } }) ); }); diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index fd01d23ca99..2f90ff92a62 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -7,6 +7,7 @@ import * as path from "path"; import { Config } from "@/node/config"; import type { ORPCContext } from "./context"; +import { inFlightProcedureCount } from "./inFlightProcedures"; import { router } from "./router"; describe("router agent skill routes", () => { @@ -263,4 +264,32 @@ describe("router config transcript mutation", () => { expect(error).toBeInstanceOf(ORPCError); expect((error as ORPCError).code).toBe("SERVICE_UNAVAILABLE"); }); + + test("an aborted config mutation stays in flight until its write settles", async () => { + let started!: () => void; + const writeStarted = new Promise((resolve) => (started = resolve)); + let finish!: () => void; + const write = new Promise((resolve) => (finish = resolve)); + const context = { + config: { + markSplashScreenViewed: () => { + started(); + return write; + }, + }, + } as unknown as ORPCContext; + const client = createRouterClient(router(), { context }); + const controller = new AbortController(); + const call = client.splashScreens + .markSplashScreenViewed({ splashId: "late" }, { signal: controller.signal }) + .catch((error: unknown) => error); + await writeStarted; + expect(inFlightProcedureCount()).toBe(1); + controller.abort(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(inFlightProcedureCount()).toBe(1); + finish(); + await call; + expect(inFlightProcedureCount()).toBe(0); + }); }); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 0ac6b7c265d..3db8393a96b 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -171,6 +171,11 @@ async function getCurrentServerAuthSessionId(context: ORPCContext): Promise(thunk: () => Promise) => Effect.uninterruptible(Effect.promise(thunk)); + export const router = (authToken?: string) => { const t = os .$context() @@ -199,11 +204,10 @@ export const router = (authToken?: string) => { // Config-backed procedures ride handlerGen. Interruption posture (also applies to // the `config` and `uiLayouts` namespaces below): reads are single Effect.sync // steps (interruption is a don't-care); mutations wrap the whole pre-Effect - // handler body in one Effect.promise thunk, so they are uninterruptible by - // construction — a client abort interrupts the handler fiber, never the in-flight - // Semaphore(1)-serialized config edit, and multi-step bodies (mutate + notify) - // cannot be torn apart. Rejections become defects, surfacing as the same internal - // error the old async handlers produced. + // handler body in one atomicPromise thunk, so a client abort never interrupts the + // in-flight Semaphore(1)-serialized config edit, multi-step bodies (mutate + notify) + // cannot be torn apart, and the handler settles only once the write has. Rejections + // become defects, surfacing as the same internal error the old async handlers produced. splashScreens: { getViewedSplashScreens: t .input(schemas.splashScreens.getViewedSplashScreens.input) @@ -221,9 +225,7 @@ export const router = (authToken?: string) => { .output(schemas.splashScreens.markSplashScreenViewed.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => - context.config.markSplashScreenViewed(input.splashId) - ); + yield* atomicPromise(async () => context.config.markSplashScreenViewed(input.splashId)); }) ), }, @@ -294,7 +296,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateAgentAiDefaults.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateAgentAiDefaults(input.agentAiDefaults) ); }) @@ -305,7 +307,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateMuxGatewayPrefs.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => { + yield* atomicPromise(async () => { await context.config.updateMuxGatewayPrefs(input); context.providerService.notifyConfigChanged(); }); @@ -316,9 +318,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateRoutePreferences.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => - context.providerService.updateRoutePreferences(input) - ); + yield* atomicPromise(async () => context.providerService.updateRoutePreferences(input)); }) ), @@ -327,7 +327,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateMinThinkingLevels.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateMinThinkingLevels(input.minThinkingLevelByModel) ); }) @@ -338,7 +338,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateModelFallbacks.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateModelFallbacks(input.modelFallbacks) ); }) @@ -349,7 +349,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateModelPreferences.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => context.config.updateModelPreferences(input)); + yield* atomicPromise(async () => context.config.updateModelPreferences(input)); }) ), @@ -358,7 +358,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateCoderPrefs.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => context.config.updateCoderPrefs(input)); + yield* atomicPromise(async () => context.config.updateCoderPrefs(input)); }) ), updateRuntimeEnablement: t @@ -366,7 +366,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateRuntimeEnablement.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => context.config.updateRuntimeEnablement(input)); + yield* atomicPromise(async () => context.config.updateRuntimeEnablement(input)); }) ), @@ -375,7 +375,7 @@ export const router = (authToken?: string) => { .output(schemas.config.saveConfig.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => { + yield* atomicPromise(async () => { await context.config.saveUserConfig(input); await context.taskService.maybeStartQueuedTasks(); }); @@ -387,7 +387,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateChatTranscriptFullWidth.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateChatTranscriptFullWidth(input.enabled) ); }) @@ -397,7 +397,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateLlmDebugLogs.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => context.config.updateLlmDebugLogs(input.enabled)); + yield* atomicPromise(async () => context.config.updateLlmDebugLogs(input.enabled)); }) ), updateHeartbeatDefaultPrompt: t @@ -405,7 +405,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateHeartbeatDefaultPrompt.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateHeartbeatDefaultPrompt(input.defaultPrompt) ); }) @@ -415,7 +415,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateHeartbeatDefaultIntervalMs.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => + yield* atomicPromise(async () => context.config.updateHeartbeatDefaultIntervalMs(input.intervalMs) ); }) @@ -425,9 +425,7 @@ export const router = (authToken?: string) => { .output(schemas.config.updateGoalDefaults.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => - context.config.updateGoalDefaults(input.goalDefaults) - ); + yield* atomicPromise(async () => context.config.updateGoalDefaults(input.goalDefaults)); }) ), unenrollMuxGovernor: t @@ -435,7 +433,7 @@ export const router = (authToken?: string) => { .output(schemas.config.unenrollMuxGovernor.output) .handler( handlerGen(function* ({ context }) { - yield* Effect.promise(async () => { + yield* atomicPromise(async () => { await context.config.unenrollMuxGovernor(); await context.policyService.refreshNow(); }); @@ -514,9 +512,7 @@ export const router = (authToken?: string) => { .output(schemas.uiLayouts.saveAll.output) .handler( handlerGen(function* ({ context }, input) { - yield* Effect.promise(async () => - context.config.saveLayoutPresets(input.layoutPresets) - ); + yield* atomicPromise(async () => context.config.saveLayoutPresets(input.layoutPresets)); }) ), }, diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 3b026713ec5..fff3cea9e3c 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -327,6 +327,31 @@ describe("server updater", () => { await Promise.all([updater.installUpdate(), updater.installUpdate()]); expect(events).toEqual(["refresh", "snapshot", "activate", "restart"]); }); + test("a shutdown that begins while blockers refresh never activates the update", async () => { + const { layout } = await fixture(); + const events: string[] = []; + let releaseRefresh!: () => void; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + refreshBlockers: () => new Promise((resolve) => (releaseRefresh = resolve)), + collectBlockers: () => [], + restart: () => { + events.push("restart"); + return Promise.resolve(); + }, + fetchDistTags: () => Promise.resolve({ next: "2.0.0" }), + runInstall: () => Promise.resolve("/staged"), + activate: () => { + events.push("activate"); + }, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + const install = updater.installUpdate(); + await updater.beginShutdown(); + releaseRefresh(); + await install; + expect(events).toEqual([]); + }); test("shutdown aborts a pending stage and waits for it to settle", async () => { const { layout } = await fixture(); let observed: AbortSignal | undefined; diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts index 3bfedcbb1be..aa98c3b28c2 100644 --- a/src/node/services/serverUpdate/serverUpdater.ts +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -164,6 +164,12 @@ export class ServerUpdater { this.installing = true; try { await this.deps.refreshBlockers?.(); + // An unrelated teardown (SIGTERM) may have begun during the refresh; it must not inherit + // the launcher swap. + if (this.shuttingDown) { + this.installing = false; + return; + } const blockers = this.deps.collectBlockers(); if (blockers.length) { this.installing = false; From 000f71b809da934561e3f24c4f2478c21444e35f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:03:26 +0000 Subject: [PATCH 11/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20forc?= =?UTF-8?q?e=20TLS=20validation=20for=20staged=20downloads,=20add=20update?= =?UTF-8?q?=20palette=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm gets --strict-ssl and pnpm --config.strict-ssl=true (CLI flags outrank npmrc and npm_config_* env), and the install child never inherits NODE_TLS_REJECT_UNAUTHORIZED, so an inherited strict-ssl=false cannot let an on-path attacker substitute the staged package. Command palette actions cover About, Check for Updates, Download Update, Install Update and Restart, and the update channel; each opens the About dialog so status and blockers stay visible. --- docs/config/server-access.mdx | 2 +- src/browser/App.tsx | 4 +- src/browser/utils/commandIds.ts | 5 ++ src/browser/utils/commands/sources.test.ts | 16 +++++++ src/browser/utils/commands/sources.ts | 48 +++++++++++++++++++ .../builtInSkillContent.generated.ts | 2 +- .../serverUpdate/serverUpdate.test.ts | 6 +++ src/node/services/serverUpdate/staging.ts | 17 ++++++- 8 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx index ea60770f61a..dad0b4ef360 100644 --- a/docs/config/server-access.mdx +++ b/docs/config/server-access.mdx @@ -80,7 +80,7 @@ Equivalent CLI options: ## Updating the server -Open **About** 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); registries that require authentication for metadata report the registry error at check time. +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); registries that require authentication for metadata report the registry error at check time. Downloads always validate the registry certificate, ignoring `strict-ssl=false` and `NODE_TLS_REJECT_UNAUTHORIZED`; trust a private CA with `cafile` instead. 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. diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 6fb376dd4c0..8a1befab705 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -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"; @@ -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) => { @@ -1019,6 +1020,7 @@ function AppInner() { onToggleTheme: toggleTheme, onSetTheme: setThemePreference, onOpenSettings: openSettings, + onOpenAbout: openAboutDialog, layoutPresets, onApplyLayoutSlot: (workspaceId, slot) => { void applySlotToWorkspace(workspaceId, slot).catch(() => { diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 81ff199ad92..45f23dab1a8 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -106,6 +106,11 @@ export const CommandIds = { // Help commands helpKeybinds: () => "help:keybinds" as const, + aboutOpen: () => "about:open" as const, + updateCheck: () => "update:check" as const, + updateDownload: () => "update:download" as const, + updateInstall: () => "update:install" as const, + updateChannel: (channel: string) => `update:channel:${channel}` as const, } as const; /** diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index b6ae2b3d289..16eb0369eaa 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -508,6 +508,22 @@ test("Login with Coder command opens providers expanded on Coder and starts the }); }); +test("update commands run the operation and open the About dialog, and need an About opener", async () => { + const onOpenAbout = mock(); + const install = mock(() => Promise.resolve()); + const setChannel = mock(() => Promise.resolve()); + const actions = getActions({ + onOpenAbout, + api: { update: { install, setChannel } } as unknown as APIClient, + }); + await actions.find((a) => a.title === "Install Update and Restart")!.run(); + expect(install).toHaveBeenCalledTimes(1); + await actions.find((a) => a.title === "Update Channel: Nightly")!.run(); + expect(setChannel).toHaveBeenCalledWith({ channel: "nightly" }); + expect(onOpenAbout).toHaveBeenCalledTimes(2); + expect(getActions().some((a) => a.title === "Check for Updates")).toBe(false); +}); + test("Login with Coder command hides itself when a custom provider shadows the coder id", () => { // Regression: an upgraded install can carry a custom OpenAI-compatible // provider named "coder". ProvidersSection hides the OAuth block for diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index fba9c4fb94e..5ba60d66684 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -147,6 +147,7 @@ export interface BuildSourcesParams { onToggleTheme: () => void; onSetTheme: (theme: ThemePreference) => void; onOpenSettings?: (section?: string, options?: OpenSettingsOptions) => void; + onOpenAbout?: () => void; // Layout slots layoutPresets?: LayoutPresetsConfig | null; @@ -1453,6 +1454,53 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, ]); + // Updates: the About dialog owns the controls and shows status, blockers, and errors, so each + // command starts the operation and opens the dialog. + if (p.onOpenAbout) { + const openAbout = p.onOpenAbout; + const updateCommand = (operation: (api: APIClient) => Promise) => () => { + if (p.api) void operation(p.api).catch(console.error); + openAbout(); + }; + actions.push(() => [ + { + id: CommandIds.aboutOpen(), + title: "About", + section: section.help, + keywords: ["version", "update", "release"], + run: () => openAbout(), + }, + { + id: CommandIds.updateCheck(), + title: "Check for Updates", + section: section.help, + keywords: ["update", "upgrade", "version"], + run: updateCommand((api) => api.update.check({ source: "manual" })), + }, + { + id: CommandIds.updateDownload(), + title: "Download Update", + section: section.help, + keywords: ["update", "upgrade"], + run: updateCommand((api) => api.update.download()), + }, + { + id: CommandIds.updateInstall(), + title: "Install Update and Restart", + section: section.help, + keywords: ["update", "upgrade", "restart"], + run: updateCommand((api) => api.update.install()), + }, + ...(["stable", "nightly"] as const).map((channel) => ({ + id: CommandIds.updateChannel(channel), + title: `Update Channel: ${channel === "stable" ? "Stable" : "Nightly"}`, + section: section.help, + keywords: ["update", "channel", channel], + run: updateCommand((api) => api.update.setChannel({ channel })), + })), + ]); + } + // Projects actions.push(() => { const list: CommandAction[] = [ diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index d0ac347c6f1..75c622836d8 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4881,7 +4881,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## Updating the server", "", - "Open **About** 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); registries that require authentication for metadata report the registry error at check time.", + "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); registries that require authentication for metadata report the registry error at check time. Downloads always validate the registry certificate, ignoring `strict-ssl=false` and `NODE_TLS_REJECT_UNAUTHORIZED`; trust a private CA with `cafile` instead.", "", "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.", "", diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index fff3cea9e3c..9fd0ee07e86 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -167,6 +167,12 @@ describe("staging and activation", () => { expect(command.args).toContain("--ignore-scripts"); expect(command.args.slice(-2)).toEqual(["--registry", layout.registry]); } + expect(installCommand({ ...layout, packageManager: "npm" }, "2.0.0").args).toContain( + "--strict-ssl" + ); + expect(installCommand({ ...layout, packageManager: "pnpm" }, "2.0.0").args).toContain( + "--config.strict-ssl=true" + ); expect(() => installCommand(layout, "../../escape")).toThrow(); }); test("prunes only old stages, preserves active and original installs, and swaps atomically", async () => { diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index 0efee1bec2d..73c2128edcd 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -19,10 +19,21 @@ export function installCommand( ): { file: string; args: string[] } { if (!isExactVersion(version)) throw new Error("Invalid update version"); const spec = `@coder/xum@${version}`; + // CLI flags outrank npmrc files and npm_config_* env, so an inherited strict-ssl=false cannot + // disable certificate validation for the download. bun has no such setting; its only TLS knob + // is the env variable runInstall strips. const flags = { bun: ["add", "--ignore-scripts", "--exact"], - npm: ["install", "--no-global", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"], - pnpm: ["add", "--no-global", "--ignore-scripts"], + npm: [ + "install", + "--no-global", + "--no-audit", + "--no-fund", + "--omit=dev", + "--ignore-scripts", + "--strict-ssl", + ], + pnpm: ["add", "--no-global", "--ignore-scripts", "--config.strict-ssl=true"], } satisfies Record; return { file: layout.packageManager, @@ -57,6 +68,8 @@ async function runInstall( ): Promise { using install = execFileAsync(file, args, { cwd, + // Disables TLS validation process-wide in every manager and cannot be outranked by a flag. + env: { NODE_TLS_REJECT_UNAUTHORIZED: undefined }, timeoutMs: SERVER_UPDATE_INSTALL_TIMEOUT_MS, killTreeOnTermination: true, signal, From 4fb921faf7a74f9e1798c970eff37ddad4567985 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:26:19 +0000 Subject: [PATCH 12/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20veri?= =?UTF-8?q?fy=20registry=20TLS=20explicitly,=20require=20a=20launchable=20?= =?UTF-8?q?staged=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dist-tags dispatcher sets rejectUnauthorized for direct and proxied connections, so NODE_TLS_REJECT_UNAUTHORIZED=0 in the server's environment can no longer let an on-path registry point the tags at an older version. Staging verification now requires the CLI entry to start with a shebang and carry the executable bit, because the supervisor execs the launcher symlink directly and a parseable but unlaunchable file would fail every relaunch attempt. --- src/node/services/serverUpdate/registry.ts | 8 +++++++- .../services/serverUpdate/serverUpdate.test.ts | 14 ++++++++++++-- src/node/services/serverUpdate/staging.ts | 15 ++++++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/node/services/serverUpdate/registry.ts b/src/node/services/serverUpdate/registry.ts index 8a93301acd8..9b7e9186793 100644 --- a/src/node/services/serverUpdate/registry.ts +++ b/src/node/services/serverUpdate/registry.ts @@ -9,7 +9,13 @@ export async function fetchDistTags( registry: string, request: (url: string, options: RequestInit) => Promise = fetch ): Promise<{ latest?: string; next?: string }> { - dispatcher ??= new EnvHttpProxyAgent(); + // NODE_TLS_REJECT_UNAUTHORIZED=0 in the server's environment would otherwise let an on-path + // registry rewrite the tags; explicit options win over that process-wide default, for direct + // (connect) and proxied (requestTls) connections alike. + dispatcher ??= new EnvHttpProxyAgent({ + connect: { rejectUnauthorized: true }, + requestTls: { rejectUnauthorized: true }, + }); const options: RequestInit & { dispatcher: Dispatcher } = { dispatcher, signal: AbortSignal.timeout(SERVER_UPDATE_CHECK_TIMEOUT_MS), diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 9fd0ee07e86..79e14590acb 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -26,7 +26,7 @@ async function writePackage( path.join(packageDir, "package.json"), JSON.stringify({ name: "@coder/xum", version }) ); - await fs.writeFile(entry, script); + await fs.writeFile(entry, `#!/usr/bin/env node\n${script}`, { mode: 0o755 }); const bin = path.join(workdir, "node_modules/.bin/mux"); await fs.mkdir(path.dirname(bin), { recursive: true }); await fs.symlink(entry, bin); @@ -205,8 +205,18 @@ describe("staging and activation", () => { }); test("verification rejects mismatched versions, missing entrypoints, and failing smoke runs", async () => { const { layout } = await fixture(); + expect(await verifyStagedPackage(layout.workdir, layout.version)).toBe(layout.entry); await expectFailure(() => verifyStagedPackage(layout.workdir, "9.0.0")); - await fs.writeFile(layout.entry, "this is not javascript ("); + if (process.platform !== "win32") { + await fs.chmod(layout.entry, 0o644); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + await fs.chmod(layout.entry, 0o755); + } + await fs.writeFile(layout.entry, "console.log('no interpreter line')", { mode: 0o755 }); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + await fs.writeFile(layout.entry, "#!/usr/bin/env node\nthis is not javascript (", { + mode: 0o755, + }); await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); await fs.unlink(layout.entry); await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index 73c2128edcd..de107a63b06 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -50,7 +50,20 @@ export async function verifyStagedPackage( if (readPackageVersion(packageDir) !== version) throw new Error("Staged package version does not match the requested update"); const entry = path.join(packageDir, "dist/cli/index.js"); - if (!(await fs.stat(entry)).isFile()) throw new Error("Staged CLI entry is not a file"); + const stat = await fs.stat(entry); + if (!stat.isFile()) throw new Error("Staged CLI entry is not a file"); + // The supervisor execs the launcher symlink directly, so the entry must carry a shebang and be + // executable; a parseable file without them would fail every relaunch attempt. + const handle = await fs.open(entry); + try { + const { buffer, bytesRead } = await handle.read(Buffer.alloc(2), 0, 2, 0); + if (bytesRead < 2 || buffer.toString() !== "#!") + throw new Error("Staged CLI entry has no interpreter line"); + } finally { + await handle.close(); + } + if (process.platform !== "win32" && (stat.mode & 0o111) === 0) + throw new Error("Staged CLI entry is not executable"); // Parse-only: nothing from the registry runs until the operator activates it. using smoke = execFileAsync(process.execPath, ["--check", entry], { timeoutMs: SERVER_UPDATE_SMOKE_TIMEOUT_MS, From 9df0fb87d0bfe54efa1e3a580903edb0e7ee36f7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:13:20 +0000 Subject: [PATCH 13/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20fetc?= =?UTF-8?q?h=20and=20digest-check=20the=20release=20before=20the=20package?= =?UTF-8?q?=20manager=20installs=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry requests refuse redirects, the tarball URL must be HTTPS, the download is verified against the manifest's sha512 digest, and the package manager installs the verified local file. The parse-only check runs node explicitly because bun has no --check. A manual check re-evaluates a staged download instead of no-oping, the palette channel command waits for the switch before opening About, and docs point private CAs at NODE_EXTRA_CA_CERTS. --- docs/config/server-access.mdx | 2 +- src/browser/features/About/AboutDialog.tsx | 2 +- src/browser/utils/commands/sources.test.ts | 15 +- src/browser/utils/commands/sources.ts | 6 +- .../builtInSkillContent.generated.ts | 2 +- .../services/serverUpdate/installLayout.ts | 2 +- src/node/services/serverUpdate/registry.ts | 102 +++++++-- .../serverUpdate/serverUpdate.test.ts | 193 ++++++++++++++++-- .../services/serverUpdate/serverUpdater.ts | 40 ++-- src/node/services/serverUpdate/staging.ts | 37 +++- 10 files changed, 333 insertions(+), 68 deletions(-) diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx index dad0b4ef360..c3f83ae6761 100644 --- a/docs/config/server-access.mdx +++ b/docs/config/server-access.mdx @@ -80,7 +80,7 @@ Equivalent CLI options: ## 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); registries that require authentication for metadata report the registry error at check time. Downloads always validate the registry certificate, ignoring `strict-ssl=false` and `NODE_TLS_REJECT_UNAUTHORIZED`; trust a private CA with `cafile` instead. +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. 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. diff --git a/src/browser/features/About/AboutDialog.tsx b/src/browser/features/About/AboutDialog.tsx index 5c1c8bc3743..53051310580 100644 --- a/src/browser/features/About/AboutDialog.tsx +++ b/src/browser/features/About/AboutDialog.tsx @@ -174,7 +174,7 @@ export function AboutDialog() { api.update .check({ source: "manual" }) .catch(console.error) - // Clear pending if the backend no-ops (e.g. already downloaded) and emits no status event. + // Clear pending if the backend no-ops (e.g. a check is already running) and emits no status event. .finally(() => setPendingAction((prev) => (prev === "check" ? null : prev))); }; diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 16eb0369eaa..9ea73f60662 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -511,15 +511,26 @@ test("Login with Coder command opens providers expanded on Coder and starts the test("update commands run the operation and open the About dialog, and need an About opener", async () => { const onOpenAbout = mock(); const install = mock(() => Promise.resolve()); - const setChannel = mock(() => Promise.resolve()); + let settleChannel!: () => void; + const setChannel = mock( + () => + new Promise((resolve) => { + settleChannel = resolve; + }) + ); const actions = getActions({ onOpenAbout, api: { update: { install, setChannel } } as unknown as APIClient, }); await actions.find((a) => a.title === "Install Update and Restart")!.run(); expect(install).toHaveBeenCalledTimes(1); - await actions.find((a) => a.title === "Update Channel: Nightly")!.run(); + expect(onOpenAbout).toHaveBeenCalledTimes(1); + // About reads the channel when it opens, so the switch must persist before the dialog appears. + const switched = actions.find((a) => a.title === "Update Channel: Nightly")!.run(); expect(setChannel).toHaveBeenCalledWith({ channel: "nightly" }); + expect(onOpenAbout).toHaveBeenCalledTimes(1); + settleChannel(); + await switched; expect(onOpenAbout).toHaveBeenCalledTimes(2); expect(getActions().some((a) => a.title === "Check for Updates")).toBe(false); }); diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 5ba60d66684..0d998a54e92 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1496,7 +1496,11 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi title: `Update Channel: ${channel === "stable" ? "Stable" : "Nightly"}`, section: section.help, keywords: ["update", "channel", channel], - run: updateCommand((api) => api.update.setChannel({ channel })), + // The dialog reads the channel once when it opens, so the change must land first. + run: async () => { + if (p.api) await p.api.update.setChannel({ channel }).catch(console.error); + openAbout(); + }, })), ]); } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 75c622836d8..3d82dcd3c2f 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4881,7 +4881,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## 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); registries that require authentication for metadata report the registry error at check time. Downloads always validate the registry certificate, ignoring `strict-ssl=false` and `NODE_TLS_REJECT_UNAUTHORIZED`; trust a private CA with `cafile` instead.", + "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. 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.", "", diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index 75754d65ef0..946354bd72c 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -119,7 +119,7 @@ export function resolveInstallLayout( env.npm_config_registry ?? "https://registry.npmjs.org"; const url = new URL(registry); - // The staged package is executed by the smoke run, so the registry must be TLS-protected; even + // The registry is the trust root for the release digest, so it must be TLS-protected; even // loopback plaintext can be routed through an inherited HTTP proxy. if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Unsupported update registry URL"); diff --git a/src/node/services/serverUpdate/registry.ts b/src/node/services/serverUpdate/registry.ts index 9b7e9186793..c075e40c71b 100644 --- a/src/node/services/serverUpdate/registry.ts +++ b/src/node/services/serverUpdate/registry.ts @@ -1,31 +1,109 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; import { EnvHttpProxyAgent, type Dispatcher } from "undici"; -import { SERVER_UPDATE_CHECK_TIMEOUT_MS } from "@/constants/serverUpdate"; +import { z } from "zod"; +import { + SERVER_UPDATE_CHECK_TIMEOUT_MS, + SERVER_UPDATE_INSTALL_TIMEOUT_MS, +} from "@/constants/serverUpdate"; import { isExactVersion } from "./installLayout"; +export type RegistryRequest = (url: string, options: RequestInit) => Promise; + +export interface ReleaseArtifact { + version: string; + tarball: string; + integrity: string; +} + // Built on first use: a malformed proxy variable must surface as a check error, not crash startup. let dispatcher: Dispatcher | undefined; -export async function fetchDistTags( - registry: string, - request: (url: string, options: RequestInit) => Promise = fetch -): Promise<{ latest?: string; next?: string }> { +function requestOptions(signal: AbortSignal): RequestInit & { dispatcher: Dispatcher } { // NODE_TLS_REJECT_UNAUTHORIZED=0 in the server's environment would otherwise let an on-path // registry rewrite the tags; explicit options win over that process-wide default, for direct - // (connect) and proxied (requestTls) connections alike. + // (connect) and proxied (requestTls) connections alike. Redirects are refused because a + // redirect target may leave HTTPS; the configured registry must answer every request itself. dispatcher ??= new EnvHttpProxyAgent({ connect: { rejectUnauthorized: true }, requestTls: { rejectUnauthorized: true }, }); - const options: RequestInit & { dispatcher: Dispatcher } = { - dispatcher, - signal: AbortSignal.timeout(SERVER_UPDATE_CHECK_TIMEOUT_MS), - }; - const response = await request(`${registry}/-/package/@coder%2Fxum/dist-tags`, options); + return { dispatcher, redirect: "error", signal }; +} + +async function fetchJson(request: RegistryRequest, url: string): Promise { + const response = await request( + url, + requestOptions(AbortSignal.timeout(SERVER_UPDATE_CHECK_TIMEOUT_MS)) + ); if (!response.ok) throw new Error(`Registry returned HTTP ${response.status}`); - const tags: unknown = await response.json(); + return response.json(); +} + +export async function fetchDistTags( + registry: string, + request: RegistryRequest = fetch +): Promise<{ latest?: string; next?: string }> { + const tags = await fetchJson(request, `${registry}/-/package/@coder%2Fxum/dist-tags`); if (!tags || typeof tags !== "object") throw new Error("Invalid registry dist-tags response"); return { latest: "latest" in tags && isExactVersion(tags.latest) ? tags.latest : undefined, next: "next" in tags && isExactVersion(tags.next) ? tags.next : undefined, }; } + +const manifestSchema = z.object({ + name: z.literal("@coder/xum"), + version: z.string(), + dist: z.object({ + tarball: z.string(), + integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]{86}==$/), + }), +}); + +export async function fetchArtifact( + registry: string, + version: string, + request: RegistryRequest = fetch +): Promise { + if (!isExactVersion(version)) throw new Error("Invalid update version"); + const manifest = manifestSchema.safeParse( + await fetchJson(request, `${registry}/@coder%2Fxum/${version}`) + ); + if (!manifest.success || manifest.data.version !== version) + throw new Error("Registry manifest has no verifiable tarball for the requested version"); + const tarball = new URL(manifest.data.dist.tarball); + if (tarball.protocol !== "https:" || tarball.username || tarball.password) + throw new Error("Registry tarball URL is not HTTPS"); + return { version, tarball: tarball.href, integrity: manifest.data.dist.integrity }; +} + +/** Streams the tarball to `dest` and keeps it only when it matches the manifest's sha512 digest. */ +export async function downloadArtifact( + artifact: ReleaseArtifact, + dest: string, + request: RegistryRequest = fetch, + signal?: AbortSignal +): Promise { + const timeout = AbortSignal.timeout(SERVER_UPDATE_INSTALL_TIMEOUT_MS); + const response = await request( + artifact.tarball, + requestOptions(signal ? AbortSignal.any([signal, timeout]) : timeout) + ); + if (!response.ok || !response.body) throw new Error(`Registry returned HTTP ${response.status}`); + const hash = createHash("sha512"); + const file = await fs.open(dest, "wx"); + try { + const reader = response.body.getReader(); + for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) { + hash.update(chunk.value); + await file.write(chunk.value); + } + } finally { + await file.close(); + } + if (`sha512-${hash.digest("base64")}` !== artifact.integrity) { + await fs.rm(dest, { force: true }); + throw new Error("Downloaded update does not match the registry digest"); + } +} diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 79e14590acb..14d3074fea7 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -6,8 +6,15 @@ import type { RestartBlocker, UpdateStatus } from "@/common/orpc/types"; import { resolveInstallLayout, inferChannel, type InstallLayout } from "./installLayout"; import { activateUpdate } from "./activation"; import { installCommand, stageUpdate, verifyStagedPackage } from "./staging"; -import { fetchDistTags } from "./registry"; +import { + downloadArtifact, + fetchArtifact, + fetchDistTags, + type RegistryRequest, + type ReleaseArtifact, +} from "./registry"; import { ServerUpdater, type ServerUpdaterDeps } from "./serverUpdater"; +import { createHash } from "node:crypto"; const dirs: string[] = []; afterEach(async () => { @@ -70,6 +77,33 @@ async function fixture( return { root, env, argv, layout: result.layout }; } +const sri = (bytes: Uint8Array) => `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + +/** Serves one release: its version manifest and tarball, recording every request's options. */ +function fakeRegistry( + version: string, + bytes = new TextEncoder().encode(`tarball ${version}`), + overrides: Partial<{ tarball: string; integrity: string; version: string }> = {} +) { + const calls: Array<{ url: string; options: RequestInit }> = []; + const tarball = + overrides.tarball ?? `https://registry.example.com/@coder/xum/-/xum-${version}.tgz`; + const request: RegistryRequest = (url, options) => { + calls.push({ url, options }); + if (url === tarball) return Promise.resolve(new Response(bytes)); + return Promise.resolve( + new Response( + JSON.stringify({ + name: "@coder/xum", + version: overrides.version ?? version, + dist: { tarball, integrity: overrides.integrity ?? sri(bytes) }, + }) + ) + ); + }; + return { request, calls, bytes, tarball }; +} + async function expectFailure(run: () => Promise) { let failed = false; try { @@ -94,8 +128,11 @@ describe("server install layout", () => { const { layout, root } = await fixture("bun", "1.0.0-next.1", "shim"); expect(layout.entry).toBe(path.join(root, "npm/node_modules/@coder/xum/dist/cli/index.js")); expect(layout.version).toBe("1.0.0-next.1"); - const bin = await stageUpdate(layout, "2.0.0", async (_file, _args, cwd) => { - await writePackage(cwd, "2.0.0"); + const bin = await stageUpdate(layout, "2.0.0", { + ...fakeRegistry("2.0.0"), + install: async (_file, _args, cwd) => { + await writePackage(cwd, "2.0.0"); + }, }); activateUpdate(layout, bin); expect(await fs.readlink(layout.launcher)).toBe(bin); @@ -158,31 +195,70 @@ describe("server install layout", () => { }); describe("staging and activation", () => { - test("installs an exact version with lifecycle scripts disabled for every manager", async () => { + test("installs the verified local tarball with lifecycle scripts disabled for every manager", async () => { const { layout } = await fixture(); for (const packageManager of ["bun", "npm", "pnpm"] as const) { - const command = installCommand({ ...layout, packageManager }, "2.0.0"); + const command = installCommand({ ...layout, packageManager }, "/stage/xum-2.0.0.tgz"); expect(command.file).toBe(packageManager); - expect(command.args).toContain("@coder/xum@2.0.0"); + expect(command.args).toContain("/stage/xum-2.0.0.tgz"); expect(command.args).toContain("--ignore-scripts"); expect(command.args.slice(-2)).toEqual(["--registry", layout.registry]); } - expect(installCommand({ ...layout, packageManager: "npm" }, "2.0.0").args).toContain( + expect(installCommand({ ...layout, packageManager: "npm" }, "/x.tgz").args).toContain( "--strict-ssl" ); - expect(installCommand({ ...layout, packageManager: "pnpm" }, "2.0.0").args).toContain( + expect(installCommand({ ...layout, packageManager: "pnpm" }, "/x.tgz").args).toContain( "--config.strict-ssl=true" ); - expect(() => installCommand(layout, "../../escape")).toThrow(); + await expectFailure(() => stageUpdate(layout, "../../escape", fakeRegistry("2.0.0"))); + }); + test("stages the digest-checked tarball, refusing a corrupted download before any install", async () => { + const { layout, root } = await fixture(); + const registry = fakeRegistry("2.0.0"); + let installed: string[] = []; + const bin = await stageUpdate(layout, "2.0.0", { + ...registry, + install: async (_file, args, cwd) => { + installed = args; + await writePackage(cwd, "2.0.0"); + }, + }); + const tarball = path.join(root, "xum-staging-2.0.0", "xum-2.0.0.tgz"); + expect(installed).toContain(tarball); + expect(new Uint8Array(await fs.readFile(tarball))).toEqual(registry.bytes); + expect(bin).toBe( + path.join(root, "xum-staging-2.0.0/node_modules/@coder/xum/dist/cli/index.js") + ); + expect(registry.calls.map((call) => call.url)).toEqual([ + `${layout.registry}/@coder%2Fxum/2.0.0`, + registry.tarball, + ]); + expect(registry.calls.every((call) => call.options.redirect === "error")).toBe(true); + const corrupted = fakeRegistry("3.0.0", undefined, { integrity: sri(new Uint8Array([1])) }); + let installs = 0; + await expectFailure(() => + stageUpdate(layout, "3.0.0", { + ...corrupted, + install: () => { + installs++; + return Promise.resolve(); + }, + }) + ); + expect(installs).toBe(0); + expect(await fs.readdir(path.join(root, "xum-staging-3.0.0"))).toEqual(["package.json"]); }); test("prunes only old stages, preserves active and original installs, and swaps atomically", async () => { const { layout, root } = await fixture(); const oldEntry = await fs.readFile(layout.entry, "utf8"); const stale = path.join(root, "xum-staging-0.9.0"); await fs.mkdir(stale); - const bin = await stageUpdate(layout, "2.0.0", async (_file, _args, cwd) => { - await writePackage(cwd, "2.0.0"); - await fs.writeFile(path.join(cwd, "bun.lock"), ""); + const bin = await stageUpdate(layout, "2.0.0", { + ...fakeRegistry("2.0.0"), + install: async (_file, _args, cwd) => { + await writePackage(cwd, "2.0.0"); + await fs.writeFile(path.join(cwd, "bun.lock"), ""); + }, }); expect(await fs.readdir(root)).not.toContain("xum-staging-0.9.0"); activateUpdate(layout, bin); @@ -194,8 +270,11 @@ describe("staging and activation", () => { ); if (!result.supported) throw new Error(result.reason); expect(result.layout.version).toBe("2.0.0"); - await stageUpdate(result.layout, "3.0.0", async (_file, _args, cwd) => { - await writePackage(cwd, "3.0.0"); + await stageUpdate(result.layout, "3.0.0", { + ...fakeRegistry("3.0.0"), + install: async (_file, _args, cwd) => { + await writePackage(cwd, "3.0.0"); + }, }); expect((await fs.readdir(root)).filter((name) => name.startsWith("xum-staging-"))).toHaveLength( 2 @@ -343,6 +422,34 @@ describe("server updater", () => { await Promise.all([updater.installUpdate(), updater.installUpdate()]); expect(events).toEqual(["refresh", "snapshot", "activate", "restart"]); }); + test("a re-check keeps a staged download the channel still points at and drops a stale one", async () => { + const { layout } = await fixture(); + let next = "2.0.0"; + const events: string[] = []; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => Promise.resolve(), + fetchDistTags: () => Promise.resolve({ next }), + runInstall: (_layout, version) => Promise.resolve(`/staged/${version}`), + activate: (_layout, entry) => { + events.push(entry); + throw new Error("failed"); + }, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + await updater.installUpdate(); + expect(updater.getStatus()).toMatchObject({ type: "error", phase: "install" }); + await updater.checkForUpdates(); + expect(updater.getStatus()).toEqual({ type: "downloaded", info: { version: "2.0.0" } }); + next = "2.1.0"; + await updater.checkForUpdates(); + expect(updater.getStatus()).toEqual({ type: "available", info: { version: "2.1.0" } }); + await updater.installUpdate(); + await updater.downloadUpdate(); + await updater.installUpdate(); + expect(events).toEqual(["/staged/2.0.0", "/staged/2.1.0"]); + }); test("a shutdown that begins while blockers refresh never activates the update", async () => { const { layout } = await fixture(); const events: string[] = []; @@ -375,10 +482,10 @@ describe("server updater", () => { collectBlockers: () => [], restart: () => Promise.resolve(), fetchDistTags: () => Promise.resolve({ next: "2.0.0" }), - runInstall: (_layout, _version, _install, signal) => + runInstall: (_layout, _version, options) => new Promise((_resolve, reject) => { - observed = signal; - signal?.addEventListener("abort", () => reject(new Error("aborted"))); + observed = options?.signal; + observed?.addEventListener("abort", () => reject(new Error("aborted"))); }), }); await updater.checkForUpdates(); @@ -433,6 +540,58 @@ describe("registry discovery", () => { expect(hasSignal).toBe(true); expect(tags).toEqual({ latest: "1.0.0", next: undefined }); }); + test("resolves a release to its HTTPS tarball and sha512 digest without following redirects", async () => { + const registry = fakeRegistry("2.0.0"); + const artifact = await fetchArtifact("https://registry.example.com", "2.0.0", registry.request); + expect(artifact).toEqual({ + version: "2.0.0", + tarball: registry.tarball, + integrity: sri(registry.bytes), + }); + expect(registry.calls).toHaveLength(1); + expect(registry.calls[0].url).toBe("https://registry.example.com/@coder%2Fxum/2.0.0"); + expect(registry.calls[0].options.redirect).toBe("error"); + const rejected = [ + fakeRegistry("2.0.0", undefined, { tarball: "http://registry.example.com/xum-2.0.0.tgz" }), + fakeRegistry("2.0.0", undefined, { tarball: "https://user:pw@registry.example.com/x.tgz" }), + fakeRegistry("2.0.0", undefined, { integrity: "sha1-2jmj7l5rSw0yVb/vlWAYkK/YBwk=" }), + fakeRegistry("2.0.0", undefined, { version: "2.0.1" }), + ]; + for (const registry of rejected) + await expectFailure(() => + fetchArtifact("https://registry.example.com", "2.0.0", registry.request) + ); + await expectFailure(() => + fetchArtifact("https://registry.example.com", "latest", fakeRegistry("2.0.0").request) + ); + }); + test("keeps a downloaded tarball only when it matches the digest", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "server-update-")); + dirs.push(root); + const registry = fakeRegistry("2.0.0"); + const artifact: ReleaseArtifact = { + version: "2.0.0", + tarball: registry.tarball, + integrity: sri(registry.bytes), + }; + const dest = path.join(root, "xum-2.0.0.tgz"); + await downloadArtifact(artifact, dest, registry.request); + expect(new Uint8Array(await fs.readFile(dest))).toEqual(registry.bytes); + expect(registry.calls[0].options.redirect).toBe("error"); + await expectFailure(() => downloadArtifact(artifact, dest, registry.request)); + const tampered = path.join(root, "tampered.tgz"); + await expectFailure(() => + downloadArtifact( + { ...artifact, integrity: sri(new Uint8Array([1])) }, + tampered, + registry.request + ) + ); + expect(await fs.readdir(root)).toEqual(["xum-2.0.0.tgz"]); + await expectFailure(() => + downloadArtifact(artifact, tampered, () => Promise.resolve(new Response("", { status: 404 }))) + ); + }); test("rejects HTTP errors and malformed responses", async () => { await expectFailure(() => fetchDistTags("https://registry.example.com", () => diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts index aa98c3b28c2..2b705d0d5a8 100644 --- a/src/node/services/serverUpdate/serverUpdater.ts +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -28,7 +28,7 @@ export class ServerUpdater { private readonly layout: InstallLayout | null; private readonly subscribers = new Set<(status: UpdateStatus) => void>(); private availableVersion: string | null = null; - private stagedEntry: string | null = null; + private staged: { entry: string; version: string } | null = null; private installing = false; private shuttingDown = false; private download: { abort: AbortController; settled: Promise } | null = null; @@ -75,7 +75,7 @@ export class ServerUpdater { throw new Error("An update operation is in progress"); this.channel = channel; this.availableVersion = null; - this.stagedEntry = null; + this.staged = null; this.setStatus({ type: "idle" }); } @@ -85,8 +85,7 @@ export class ServerUpdater { this.shuttingDown || this.installing || this.status.type === "checking" || - this.status.type === "downloading" || - this.stagedEntry + this.status.type === "downloading" ) return; const previous = this.status; @@ -97,8 +96,15 @@ export class ServerUpdater { if (!isExactVersion(version)) throw new Error("Registry has no valid version for the selected channel"); this.availableVersion = version === this.layout.version ? null : version; + // A staged download stays installable while the channel still points at it, so a re-check + // after a failed install returns to the ready state instead of discarding the download. + if (this.staged && this.staged.version !== this.availableVersion) this.staged = null; this.setStatus( - this.availableVersion ? { type: "available", info: { version } } : { type: "up-to-date" } + this.staged + ? { type: "downloaded", info: { version } } + : this.availableVersion + ? { type: "available", info: { version } } + : { type: "up-to-date" } ); } catch (error) { this.setStatus( @@ -114,7 +120,7 @@ export class ServerUpdater { !this.layout || this.shuttingDown || !this.availableVersion || - this.stagedEntry || + this.staged || this.installing || this.status.type === "checking" || this.status.type === "downloading" @@ -133,12 +139,8 @@ export class ServerUpdater { private async stage(layout: InstallLayout, version: string, signal: AbortSignal): Promise { try { - this.stagedEntry = await (this.deps.runInstall ?? stageUpdate)( - layout, - version, - undefined, - signal - ); + const entry = await (this.deps.runInstall ?? stageUpdate)(layout, version, { signal }); + this.staged = { entry, version }; this.setStatus({ type: "downloaded", info: { version } }); } catch (error) { this.setStatus({ type: "error", phase: "download", message: getErrorMessage(error) }); @@ -153,14 +155,8 @@ export class ServerUpdater { } async installUpdate(): Promise { - if ( - !this.layout || - this.shuttingDown || - !this.stagedEntry || - !this.availableVersion || - this.installing - ) - return; + if (!this.layout || this.shuttingDown || !this.staged || this.installing) return; + const staged = this.staged; this.installing = true; try { await this.deps.refreshBlockers?.(); @@ -175,13 +171,13 @@ export class ServerUpdater { this.installing = false; this.setStatus({ type: "install-blocked", - info: { version: this.availableVersion }, + info: { version: staged.version }, blockers, }); return; } // No await between the idle snapshot, atomic swap, and the CLI's shutdown latch. - (this.deps.activate ?? activateUpdate)(this.layout, this.stagedEntry); + (this.deps.activate ?? activateUpdate)(this.layout, staged.entry); await this.deps.restart(); } catch (error) { this.installing = false; diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index de107a63b06..5ea70e83ddc 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -12,18 +12,18 @@ import { resolveCliEntry, type InstallLayout, } from "./installLayout"; +import { downloadArtifact, fetchArtifact, type RegistryRequest } from "./registry"; +/** Installs an already verified local tarball; only its dependencies come from the registry. */ export function installCommand( layout: InstallLayout, - version: string + tarball: string ): { file: string; args: string[] } { - if (!isExactVersion(version)) throw new Error("Invalid update version"); - const spec = `@coder/xum@${version}`; // CLI flags outrank npmrc files and npm_config_* env, so an inherited strict-ssl=false cannot // disable certificate validation for the download. bun has no such setting; its only TLS knob // is the env variable runInstall strips. const flags = { - bun: ["add", "--ignore-scripts", "--exact"], + bun: ["add", "--ignore-scripts"], npm: [ "install", "--no-global", @@ -37,7 +37,7 @@ export function installCommand( } satisfies Record; return { file: layout.packageManager, - args: [...flags[layout.packageManager], spec, "--registry", layout.registry], + args: [...flags[layout.packageManager], tarball, "--registry", layout.registry], }; } @@ -64,8 +64,9 @@ export async function verifyStagedPackage( } if (process.platform !== "win32" && (stat.mode & 0o111) === 0) throw new Error("Staged CLI entry is not executable"); - // Parse-only: nothing from the registry runs until the operator activates it. - using smoke = execFileAsync(process.execPath, ["--check", entry], { + // Parse-only: nothing from the registry runs until the operator activates it. The shebang's + // interpreter is used because process.execPath may be bun, which has no parse-only mode. + using smoke = execFileAsync("node", ["--check", entry], { timeoutMs: SERVER_UPDATE_SMOKE_TIMEOUT_MS, signal, }); @@ -90,13 +91,19 @@ async function runInstall( await install.result; } +export interface StageOptions { + install?: typeof runInstall; + request?: RegistryRequest; + signal?: AbortSignal; +} + export async function stageUpdate( layout: InstallLayout, version: string, - install = runInstall, - signal?: AbortSignal + options: StageOptions = {} ): Promise { - const command = installCommand(layout, version); + const { install = runInstall, request, signal } = options; + if (!isExactVersion(version)) throw new Error("Invalid update version"); // Pruning must never remove the target of a launcher that was re-pointed behind this process. if (resolveCliEntry(layout.launcher) !== layout.entry) throw new Error("Server launcher changed since startup"); @@ -116,6 +123,16 @@ export async function stageUpdate( // Exclusive creation refuses pre-existing links, and never mutates the running installation. await fs.mkdir(dir); await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ private: true })); + // Package managers follow redirects, so the release itself is fetched and digest-checked here + // and only the dependency tree is left to the manager, as in the operator's original install. + const tarball = path.join(dir, `xum-${version}.tgz`); + await downloadArtifact( + await fetchArtifact(layout.registry, version, request), + tarball, + request, + signal + ); + const command = installCommand(layout, tarball); await install(command.file, command.args, dir, signal); return verifyStagedPackage(dir, version, signal); } From a71b8a547264fffd361c265b61077765a0e57876 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:18:23 +0000 Subject: [PATCH 14/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20anch?= =?UTF-8?q?or=20staged=20dependencies=20to=20the=20registry,=20require=20t?= =?UTF-8?q?he=20CLI=20shebang,=20mark=20stages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - after the package manager installs the verified tarball, check every dependency in its lockfile (bun.lock, package-lock.json, pnpm-lock.yaml) against the digest the configured registry publishes over verified HTTPS without redirects; refuse local, plaintext, git, or unpinned resolutions - require the staged entry's first line to be exactly the published `#!/usr/bin/env node` interpreter line - write an ownership marker into new stages and prune only stages this installation created - keep a staged download installable when a later registry check fails --- docs/config/server-access.mdx | 2 +- src/constants/serverUpdate.ts | 9 + .../builtInSkillContent.generated.ts | 2 +- .../services/serverUpdate/installLayout.ts | 11 +- src/node/services/serverUpdate/lockfile.ts | 164 ++++++++++ src/node/services/serverUpdate/registry.ts | 66 ++++- .../serverUpdate/serverUpdate.test.ts | 279 ++++++++++++++++-- .../services/serverUpdate/serverUpdater.ts | 7 + src/node/services/serverUpdate/staging.ts | 51 +++- 9 files changed, 536 insertions(+), 55 deletions(-) create mode 100644 src/node/services/serverUpdate/lockfile.ts diff --git a/docs/config/server-access.mdx b/docs/config/server-access.mdx index c3f83ae6761..aca00a136c8 100644 --- a/docs/config/server-access.mdx +++ b/docs/config/server-access.mdx @@ -80,7 +80,7 @@ Equivalent CLI options: ## 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. 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). +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. diff --git a/src/constants/serverUpdate.ts b/src/constants/serverUpdate.ts index 93397f87e7f..fd1168b9917 100644 --- a/src/constants/serverUpdate.ts +++ b/src/constants/serverUpdate.ts @@ -2,4 +2,13 @@ export const SERVER_UPDATE_CHECK_TIMEOUT_MS = 30_000; export const SERVER_UPDATE_INSTALL_TIMEOUT_MS = 5 * 60_000; export const SERVER_UPDATE_SMOKE_TIMEOUT_MS = 30_000; export const SERVER_UPDATE_STAGING_PREFIX = "xum-staging-"; +export const SERVER_UPDATE_STAGE_MARKER = ".xum-stage.json"; +export const SERVER_UPDATE_LOCKFILES = { + bun: "bun.lock", + npm: "package-lock.json", + pnpm: "pnpm-lock.yaml", +} as const; +export const SERVER_UPDATE_CLI_INTERPRETER = "node"; +export const SERVER_UPDATE_CLI_SHEBANG = `#!/usr/bin/env ${SERVER_UPDATE_CLI_INTERPRETER}`; +export const SERVER_UPDATE_VERIFY_CONCURRENCY = 16; export const SERVER_VERSION_CHECK_TIMEOUT_MS = 5_000; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 3d82dcd3c2f..61c2e42a27a 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4881,7 +4881,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## 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. 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).", + "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.", "", diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index 946354bd72c..f1483d9fd26 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import * as path from "node:path"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import type { UpdateChannel } from "@/common/types/project"; +import { SERVER_UPDATE_LOCKFILES } from "@/constants/serverUpdate"; export interface InstallLayout { launcher: string; @@ -100,10 +101,14 @@ export function resolveInstallLayout( if (path.basename(dir) !== "node_modules") continue; const parent = path.dirname(dir); const managers: Array = []; - if (["bun.lock", "bun.lockb"].some((lock) => existsSync(path.join(parent, lock)))) + if ( + [SERVER_UPDATE_LOCKFILES.bun, "bun.lockb"].some((lock) => + existsSync(path.join(parent, lock)) + ) + ) managers.push("bun"); - if (existsSync(path.join(parent, "package-lock.json"))) managers.push("npm"); - if (existsSync(path.join(parent, "pnpm-lock.yaml"))) managers.push("pnpm"); + if (existsSync(path.join(parent, SERVER_UPDATE_LOCKFILES.npm))) managers.push("npm"); + if (existsSync(path.join(parent, SERVER_UPDATE_LOCKFILES.pnpm))) managers.push("pnpm"); if (managers.length > 1) throw new Error("Ambiguous package manager lockfiles"); if (managers.length === 1) { workdir = parent; diff --git a/src/node/services/serverUpdate/lockfile.ts b/src/node/services/serverUpdate/lockfile.ts new file mode 100644 index 00000000000..cc1da5351e0 --- /dev/null +++ b/src/node/services/serverUpdate/lockfile.ts @@ -0,0 +1,164 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as jsonc from "jsonc-parser"; +import YAML from "yaml"; +import { z } from "zod"; +import { + SERVER_UPDATE_LOCKFILES, + SERVER_UPDATE_VERIFY_CONCURRENCY, +} from "@/constants/serverUpdate"; +import { isExactVersion, type InstallLayout } from "./installLayout"; +import { fetchPublishedDigests, type RegistryRequest } from "./registry"; + +/** One package a manager's lockfile records, before any trust decision. */ +interface LockedPackage { + name: string; + version: string; + integrity?: string; + /** Explicit location (URL or local path); absent when derived from the configured registry. */ + resolved?: string; +} + +/** Splits `name@resolution`; a scoped name keeps its leading `@`. */ +function splitSpec(spec: string): [name: string, resolution: string] { + const at = spec.lastIndexOf("@"); + return at > 0 ? [spec.slice(0, at), spec.slice(at + 1)] : [spec, ""]; +} + +const bunMeta = z.object({ bundled: z.boolean().optional() }); +const bunLock = z.object({ + packages: z.record( + z.string(), + z.union([ + // Registry releases: spec, tarball URL ("" when derived from the registry), meta, sri. + z + .tuple([z.string(), z.string(), bunMeta, z.string().optional()]) + .transform(([spec, registry, meta, integrity]) => ({ + spec, + registry, + bundled: meta.bundled === true, + integrity, + })), + // Every other resolution kind names its location in the spec. + z.tuple([z.string(), bunMeta]).transform(([spec]) => ({ spec })), + ]) + ), +}); +const npmLock = z.object({ + packages: z.record( + z.string(), + z.object({ + version: z.string().optional(), + resolved: z.string().optional(), + integrity: z.string().optional(), + inBundle: z.boolean().optional(), + }) + ), +}); +const pnpmLock = z.object({ + packages: z.record( + z.string(), + z.object({ + version: z.string().optional(), + resolution: z.object({ integrity: z.string().optional(), tarball: z.string().optional() }), + }) + ), +}); + +const lockfileParsers: Record LockedPackage[]> = { + bun: (raw) => + Object.values(bunLock.parse(jsonc.parse(raw)).packages).flatMap((entry): LockedPackage[] => { + const [name, resolution] = splitSpec(entry.spec); + if (!("registry" in entry)) return [{ name, version: "", resolved: resolution }]; + // Bundled packages ship inside their parent's verified tarball. + if (entry.bundled) return []; + return [ + { + name, + version: resolution, + integrity: entry.integrity, + resolved: entry.registry || undefined, + }, + ]; + }), + npm: (raw) => + Object.entries(npmLock.parse(JSON.parse(raw)).packages).flatMap(([key, pkg]) => + key === "" || pkg.inBundle + ? [] + : [ + { + name: key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length), + version: pkg.version ?? "", + integrity: pkg.integrity, + resolved: pkg.resolved, + }, + ] + ), + pnpm: (raw) => + Object.entries(pnpmLock.parse(YAML.parse(raw)).packages).map(([key, pkg]) => { + // v6 keys are `/name@version(peer@x)`; v9 drops the slash. A local tarball entry carries its + // real version separately. + const [name, resolution] = splitSpec(key.replace(/^\//, "").replace(/\(.*$/, "")); + return { + name, + version: pkg.version ?? resolution, + integrity: pkg.resolution.integrity, + resolved: pkg.resolution.tarball, + }; + }), +}; + +const isLocal = (resolved: string) => + /^(file:|\.\.?\/)/.test(resolved) || path.isAbsolute(resolved); + +/** + * Anchors the staged dependency tree to the configured registry. Managers follow redirects (also + * to plaintext) while resolving dependencies and record whatever digest they were served, so the + * lockfile alone proves nothing; each recorded digest must equal the one the registry publishes + * over verified HTTPS without redirects. Managers do verify every tarball against its recorded + * digest, which makes that digest the only link that needs anchoring. Returns the verified count. + */ +export async function verifyStagedDependencies( + layout: InstallLayout, + dir: string, + request: RegistryRequest = fetch, + signal?: AbortSignal +): Promise { + const lockfile = path.join(dir, SERVER_UPDATE_LOCKFILES[layout.packageManager]); + const queue: Array<{ name: string; version: string; integrity: string }> = []; + for (const pkg of lockfileParsers[layout.packageManager](await fs.readFile(lockfile, "utf8"))) { + if (pkg.resolved !== undefined && isLocal(pkg.resolved)) { + // The release tarball itself was digest-checked before the install. + if (pkg.name === "@coder/xum") continue; + throw new Error(`Dependency ${pkg.name} was installed from a local path`); + } + if (pkg.resolved !== undefined && !pkg.resolved.startsWith("https://")) + throw new Error(`Dependency ${pkg.name} was not resolved over HTTPS`); + if (!isExactVersion(pkg.version) || !pkg.integrity) + throw new Error(`Dependency ${pkg.name} is not a digest-pinned registry release`); + queue.push({ name: pkg.name, version: pkg.version, integrity: pkg.integrity }); + } + const total = queue.length; + const worker = async () => { + for (let pkg = queue.shift(); pkg; pkg = queue.shift()) { + try { + const published = await fetchPublishedDigests( + layout.registry, + pkg.name, + pkg.version, + request, + signal + ); + if (!pkg.integrity.split(/\s+/).some((sri) => published.includes(sri))) + throw new Error( + `Registry digest for ${pkg.name}@${pkg.version} differs from the staged lockfile` + ); + } catch (error) { + queue.length = 0; + throw error; + } + } + }; + await Promise.all(Array.from({ length: SERVER_UPDATE_VERIFY_CONCURRENCY }, worker)); + return total; +} diff --git a/src/node/services/serverUpdate/registry.ts b/src/node/services/serverUpdate/registry.ts index c075e40c71b..8045813455b 100644 --- a/src/node/services/serverUpdate/registry.ts +++ b/src/node/services/serverUpdate/registry.ts @@ -31,10 +31,17 @@ function requestOptions(signal: AbortSignal): RequestInit & { dispatcher: Dispat return { dispatcher, redirect: "error", signal }; } -async function fetchJson(request: RegistryRequest, url: string): Promise { +const deadline = (ms: number, signal?: AbortSignal) => + signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms); + +async function fetchJson( + request: RegistryRequest, + url: string, + signal?: AbortSignal +): Promise { const response = await request( url, - requestOptions(AbortSignal.timeout(SERVER_UPDATE_CHECK_TIMEOUT_MS)) + requestOptions(deadline(SERVER_UPDATE_CHECK_TIMEOUT_MS, signal)) ); if (!response.ok) throw new Error(`Registry returned HTTP ${response.status}`); return response.json(); @@ -53,29 +60,63 @@ export async function fetchDistTags( } const manifestSchema = z.object({ - name: z.literal("@coder/xum"), + name: z.string(), version: z.string(), dist: z.object({ tarball: z.string(), - integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]{86}==$/), + integrity: z + .string() + .regex(/^sha512-[A-Za-z0-9+/]{86}==$/) + .optional(), + shasum: z + .string() + .regex(/^[0-9a-f]{40}$/) + .optional(), }), }); -export async function fetchArtifact( +async function fetchManifest( registry: string, + name: string, version: string, - request: RegistryRequest = fetch -): Promise { + request: RegistryRequest, + signal?: AbortSignal +) { if (!isExactVersion(version)) throw new Error("Invalid update version"); const manifest = manifestSchema.safeParse( - await fetchJson(request, `${registry}/@coder%2Fxum/${version}`) + await fetchJson(request, `${registry}/${name.replace("/", "%2F")}/${version}`, signal) ); - if (!manifest.success || manifest.data.version !== version) + if (!manifest.success || manifest.data.name !== name || manifest.data.version !== version) + throw new Error(`Registry manifest for ${name}@${version} is invalid`); + return manifest.data.dist; +} + +export async function fetchArtifact( + registry: string, + version: string, + request: RegistryRequest = fetch +): Promise { + const dist = await fetchManifest(registry, "@coder/xum", version, request); + if (!dist.integrity) throw new Error("Registry manifest has no verifiable tarball for the requested version"); - const tarball = new URL(manifest.data.dist.tarball); + const tarball = new URL(dist.tarball); if (tarball.protocol !== "https:" || tarball.username || tarball.password) throw new Error("Registry tarball URL is not HTTPS"); - return { version, tarball: tarball.href, integrity: manifest.data.dist.integrity }; + return { version, tarball: tarball.href, integrity: dist.integrity }; +} + +/** The SRI digests the registry publishes for a release, including the legacy sha1 shasum. */ +export async function fetchPublishedDigests( + registry: string, + name: string, + version: string, + request: RegistryRequest = fetch, + signal?: AbortSignal +): Promise { + const dist = await fetchManifest(registry, name, version, request, signal); + const digests = dist.integrity ? [dist.integrity] : []; + if (dist.shasum) digests.push(`sha1-${Buffer.from(dist.shasum, "hex").toString("base64")}`); + return digests; } /** Streams the tarball to `dest` and keeps it only when it matches the manifest's sha512 digest. */ @@ -85,10 +126,9 @@ export async function downloadArtifact( request: RegistryRequest = fetch, signal?: AbortSignal ): Promise { - const timeout = AbortSignal.timeout(SERVER_UPDATE_INSTALL_TIMEOUT_MS); const response = await request( artifact.tarball, - requestOptions(signal ? AbortSignal.any([signal, timeout]) : timeout) + requestOptions(deadline(SERVER_UPDATE_INSTALL_TIMEOUT_MS, signal)) ); if (!response.ok || !response.body) throw new Error(`Registry returned HTTP ${response.status}`); const hash = createHash("sha512"); diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 14d3074fea7..d1ea27b35a1 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -6,14 +6,17 @@ import type { RestartBlocker, UpdateStatus } from "@/common/orpc/types"; import { resolveInstallLayout, inferChannel, type InstallLayout } from "./installLayout"; import { activateUpdate } from "./activation"; import { installCommand, stageUpdate, verifyStagedPackage } from "./staging"; +import { verifyStagedDependencies } from "./lockfile"; import { downloadArtifact, fetchArtifact, fetchDistTags, + fetchPublishedDigests, type RegistryRequest, type ReleaseArtifact, } from "./registry"; import { ServerUpdater, type ServerUpdaterDeps } from "./serverUpdater"; +import { SERVER_UPDATE_STAGE_MARKER } from "@/constants/serverUpdate"; import { createHash } from "node:crypto"; const dirs: string[] = []; @@ -78,32 +81,79 @@ async function fixture( } const sri = (bytes: Uint8Array) => `sha512-${createHash("sha512").update(bytes).digest("base64")}`; +const sriOf = (text: string) => sri(new TextEncoder().encode(text)); +const splitSpec = (spec: string) => { + const at = spec.lastIndexOf("@"); + return { name: spec.slice(0, at), version: spec.slice(at + 1) }; +}; -/** Serves one release: its version manifest and tarball, recording every request's options. */ +/** + * Serves one release (version manifest and tarball) plus the manifests of the dependencies in + * `deps` (`name@version` to sri), recording every request's options. + */ function fakeRegistry( version: string, bytes = new TextEncoder().encode(`tarball ${version}`), - overrides: Partial<{ tarball: string; integrity: string; version: string }> = {} + overrides: Partial<{ tarball: string; integrity: string; version: string }> = {}, + deps: Record = {} ) { const calls: Array<{ url: string; options: RequestInit }> = []; const tarball = overrides.tarball ?? `https://registry.example.com/@coder/xum/-/xum-${version}.tgz`; + const json = (body: unknown) => Promise.resolve(new Response(JSON.stringify(body))); const request: RegistryRequest = (url, options) => { calls.push({ url, options }); if (url === tarball) return Promise.resolve(new Response(bytes)); - return Promise.resolve( - new Response( - JSON.stringify({ - name: "@coder/xum", - version: overrides.version ?? version, - dist: { tarball, integrity: overrides.integrity ?? sri(bytes) }, - }) - ) - ); + for (const [spec, integrity] of Object.entries(deps)) { + const { name, version } = splitSpec(spec); + if (url.endsWith(`/${name.replace("/", "%2F")}/${version}`)) + return json({ + name, + version, + dist: { tarball: `https://registry.example.com/${name}/-/${version}.tgz`, integrity }, + }); + } + return json({ + name: "@coder/xum", + version: overrides.version ?? version, + dist: { tarball, integrity: overrides.integrity ?? sri(bytes) }, + }); }; return { request, calls, bytes, tarball }; } +/** A bun.lock as bun 1.2 writes it: the local release tarball plus registry releases. */ +function bunLock(tarball: string, packages: Record = {}, extra = "") { + const entries = Object.entries(packages).map(([spec, integrity]) => + integrity === null + ? ` "${splitSpec(spec).name}": ["${spec}", {}],` + : ` "${splitSpec(spec).name}": ["${spec}", "", {}, "${integrity}"],` + ); + return [ + "{", + ' "lockfileVersion": 1,', + ` "workspaces": { "": { "dependencies": { "@coder/xum": "${tarball}" } } },`, + ' "packages": {', + ` "@coder/xum": ["@coder/xum@${tarball}", { "dependencies": {} }],`, + ...entries, + extra, + " },", + "}", + "", + ].join("\n"); +} + +/** Stands in for the package manager: writes the staged package and the lockfile bun would. */ +const fakeInstall = + (version: string, packages: Record = {}) => + async (_file: string, _args: string[], cwd: string) => { + await writePackage(cwd, version); + await fs.writeFile( + path.join(cwd, "bun.lock"), + bunLock(path.join(cwd, `xum-${version}.tgz`), packages) + ); + }; + async function expectFailure(run: () => Promise) { let failed = false; try { @@ -130,9 +180,7 @@ describe("server install layout", () => { expect(layout.version).toBe("1.0.0-next.1"); const bin = await stageUpdate(layout, "2.0.0", { ...fakeRegistry("2.0.0"), - install: async (_file, _args, cwd) => { - await writePackage(cwd, "2.0.0"); - }, + install: fakeInstall("2.0.0"), }); activateUpdate(layout, bin); expect(await fs.readlink(layout.launcher)).toBe(bin); @@ -214,13 +262,13 @@ describe("staging and activation", () => { }); test("stages the digest-checked tarball, refusing a corrupted download before any install", async () => { const { layout, root } = await fixture(); - const registry = fakeRegistry("2.0.0"); + const registry = fakeRegistry("2.0.0", undefined, {}, { "zod@4.5.4": sriOf("zod") }); let installed: string[] = []; const bin = await stageUpdate(layout, "2.0.0", { ...registry, - install: async (_file, args, cwd) => { + install: async (file, args, cwd) => { installed = args; - await writePackage(cwd, "2.0.0"); + await fakeInstall("2.0.0", { "zod@4.5.4": sriOf("zod") })(file, args, cwd); }, }); const tarball = path.join(root, "xum-staging-2.0.0", "xum-2.0.0.tgz"); @@ -232,6 +280,7 @@ describe("staging and activation", () => { expect(registry.calls.map((call) => call.url)).toEqual([ `${layout.registry}/@coder%2Fxum/2.0.0`, registry.tarball, + `${layout.registry}/zod/4.5.4`, ]); expect(registry.calls.every((call) => call.options.redirect === "error")).toBe(true); const corrupted = fakeRegistry("3.0.0", undefined, { integrity: sri(new Uint8Array([1])) }); @@ -246,21 +295,42 @@ describe("staging and activation", () => { }) ); expect(installs).toBe(0); - expect(await fs.readdir(path.join(root, "xum-staging-3.0.0"))).toEqual(["package.json"]); + expect((await fs.readdir(path.join(root, "xum-staging-3.0.0"))).sort()).toEqual([ + SERVER_UPDATE_STAGE_MARKER, + "package.json", + ]); }); - test("prunes only old stages, preserves active and original installs, and swaps atomically", async () => { + test("prunes only its own old stages, preserves active and original installs, and swaps atomically", async () => { const { layout, root } = await fixture(); const oldEntry = await fs.readFile(layout.entry, "utf8"); const stale = path.join(root, "xum-staging-0.9.0"); await fs.mkdir(stale); + await fs.writeFile( + path.join(stale, SERVER_UPDATE_STAGE_MARKER), + JSON.stringify({ launcher: layout.launcher }) + ); + // Same naming scheme, but not this updater's: an unmarked directory and another + // installation's stage sharing the parent. + await fs.mkdir(path.join(root, "xum-staging-0.8.0")); + await fs.writeFile(path.join(root, "xum-staging-0.8.0/keep"), ""); + await fs.mkdir(path.join(root, "xum-staging-0.7.0")); + await fs.writeFile( + path.join(root, "xum-staging-0.7.0", SERVER_UPDATE_STAGE_MARKER), + JSON.stringify({ launcher: path.join(root, "other-mux") }) + ); + // The updater's own earlier stage carries the marker it wrote and is pruned like the stale one. + await stageUpdate(layout, "1.5.0", { ...fakeRegistry("1.5.0"), install: fakeInstall("1.5.0") }); const bin = await stageUpdate(layout, "2.0.0", { ...fakeRegistry("2.0.0"), - install: async (_file, _args, cwd) => { - await writePackage(cwd, "2.0.0"); - await fs.writeFile(path.join(cwd, "bun.lock"), ""); - }, + install: fakeInstall("2.0.0"), }); - expect(await fs.readdir(root)).not.toContain("xum-staging-0.9.0"); + const remaining = (await fs.readdir(root)).filter((name) => name.startsWith("xum-staging-")); + expect(remaining.sort()).toEqual([ + "xum-staging-0.7.0", + "xum-staging-0.8.0", + "xum-staging-2.0.0", + ]); + expect(await fs.readdir(path.join(root, "xum-staging-0.8.0"))).toEqual(["keep"]); activateUpdate(layout, bin); expect(await fs.readlink(layout.launcher)).toBe(bin); expect(await fs.readFile(layout.entry, "utf8")).toBe(oldEntry); @@ -272,12 +342,10 @@ describe("staging and activation", () => { expect(result.layout.version).toBe("2.0.0"); await stageUpdate(result.layout, "3.0.0", { ...fakeRegistry("3.0.0"), - install: async (_file, _args, cwd) => { - await writePackage(cwd, "3.0.0"); - }, + install: fakeInstall("3.0.0"), }); expect((await fs.readdir(root)).filter((name) => name.startsWith("xum-staging-"))).toHaveLength( - 2 + 4 ); expect(await fs.realpath(layout.launcher)).toBe(await fs.realpath(bin)); expect(await fs.readFile(layout.entry, "utf8")).toBe(oldEntry); @@ -293,6 +361,11 @@ describe("staging and activation", () => { } await fs.writeFile(layout.entry, "console.log('no interpreter line')", { mode: 0o755 }); await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + // Any other interpreter line parses but exits 127 under the supervisor. + await fs.writeFile(layout.entry, "#!/definitely/missing\nconsole.log('x')", { mode: 0o755 }); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); + await fs.writeFile(layout.entry, "#!/usr/bin/env node\r\nconsole.log('x')", { mode: 0o755 }); + await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); await fs.writeFile(layout.entry, "#!/usr/bin/env node\nthis is not javascript (", { mode: 0o755, }); @@ -300,6 +373,112 @@ describe("staging and activation", () => { await fs.unlink(layout.entry); await expectFailure(() => verifyStagedPackage(layout.workdir, layout.version)); }); + test("anchors every locked dependency digest to the registry for each manager's lockfile", async () => { + const { layout, root } = await fixture(); + const deps = { "zod@4.5.4": sriOf("zod"), "inner@1.0.0": sriOf("inner") }; + const stage = async ( + manager: InstallLayout["packageManager"], + lockfile: string, + raw: string + ) => { + const dir = path.join(root, `stage-${manager}`); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, lockfile), raw); + return dir; + }; + const npmLock = JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies: { "@coder/xum": "file:xum-2.0.0.tgz" } }, + "node_modules/@coder/xum": { version: "2.0.0", resolved: "file:xum-2.0.0.tgz" }, + "node_modules/zod": { + version: "4.5.4", + resolved: "https://registry.example.com/zod/-/zod-4.5.4.tgz", + integrity: deps["zod@4.5.4"], + }, + "node_modules/zod/node_modules/inner": { + version: "1.0.0", + resolved: "https://registry.example.com/inner/-/inner-1.0.0.tgz", + integrity: deps["inner@1.0.0"], + }, + "node_modules/bundled": { version: "1.0.0", inBundle: true }, + }, + }); + const pnpmLock = [ + "lockfileVersion: '9.0'", + "packages:", + " '@coder/xum@file:xum-2.0.0.tgz':", + " resolution: {integrity: sha512-unchecked, tarball: file:xum-2.0.0.tgz}", + " version: 2.0.0", + " zod@4.5.4:", + ` resolution: {integrity: ${deps["zod@4.5.4"]}}`, + " '/inner@1.0.0(zod@4.5.4)':", + ` resolution: {integrity: ${deps["inner@1.0.0"]}}`, + "snapshots:", + " zod@4.5.4: {}", + "", + ].join("\n"); + const stages = { + bun: await stage("bun", "bun.lock", bunLock("/stage/xum-2.0.0.tgz", deps)), + npm: await stage("npm", "package-lock.json", npmLock), + pnpm: await stage("pnpm", "pnpm-lock.yaml", pnpmLock), + }; + for (const packageManager of ["bun", "npm", "pnpm"] as const) { + const registry = fakeRegistry("2.0.0", undefined, {}, deps); + const managerLayout = { ...layout, packageManager }; + expect( + await verifyStagedDependencies(managerLayout, stages[packageManager], registry.request) + ).toBe(2); + expect(registry.calls.map((call) => call.url).sort()).toEqual([ + `${layout.registry}/inner/1.0.0`, + `${layout.registry}/zod/4.5.4`, + ]); + expect(registry.calls.every((call) => call.options.redirect === "error")).toBe(true); + // A digest the registry does not publish is the redirect-tampering signature. + const tampered = fakeRegistry( + "2.0.0", + undefined, + {}, + { ...deps, "inner@1.0.0": sriOf("evil") } + ); + await expectFailure(() => + verifyStagedDependencies(managerLayout, stages[packageManager], tampered.request) + ); + } + const refused = { + plaintext: ` "evil": ["evil@1.0.0", "http://mirror.example.com/evil-1.0.0.tgz", {}, "${sriOf("evil")}"],`, + unpinned: ' "evil": ["evil@1.0.0", "", {}],', + remote: ' "evil": ["evil@https://mirror.example.com/evil-1.0.0.tgz", {}],', + local: ' "evil": ["evil@/tmp/evil.tgz", {}],', + git: ' "evil": ["evil@github:evil/evil#abc", {}],', + }; + for (const extra of Object.values(refused)) { + const dir = await stage("bun", "bun.lock", bunLock("/stage/xum-2.0.0.tgz", deps, extra)); + const registry = fakeRegistry( + "2.0.0", + undefined, + {}, + { ...deps, "evil@1.0.0": sriOf("evil") } + ); + await expectFailure(() => verifyStagedDependencies(layout, dir, registry.request)); + expect(registry.calls).toHaveLength(0); + } + // Bundled packages ship inside their parent's verified tarball and are never fetched. + const bundled = + ' "bundled": ["bundled@1.0.0", "", { "bundled": true }, "sha512-unchecked"],'; + const dir = await stage("bun", "bun.lock", bunLock("/stage/xum-2.0.0.tgz", deps, bundled)); + expect( + await verifyStagedDependencies( + layout, + dir, + fakeRegistry("2.0.0", undefined, {}, deps).request + ) + ).toBe(2); + const unreadable = await stage("bun", "bun.lock", "not a lockfile"); + await expectFailure(() => + verifyStagedDependencies(layout, unreadable, fakeRegistry("2.0.0").request) + ); + }); test("activation failure leaves the old link intact", async () => { const { layout } = await fixture(); const original = await fs.readlink(layout.launcher); @@ -450,6 +629,31 @@ describe("server updater", () => { await updater.installUpdate(); expect(events).toEqual(["/staged/2.0.0", "/staged/2.1.0"]); }); + test("a failed check keeps a staged download installable", async () => { + const { layout } = await fixture(); + let offline = false; + const events: string[] = []; + const updater = new ServerUpdater({ supported: true, layout }, undefined, { + collectBlockers: () => [], + restart: () => { + events.push("restart"); + return Promise.resolve(); + }, + fetchDistTags: () => + offline ? Promise.reject(new Error("offline")) : Promise.resolve({ next: "2.0.0" }), + runInstall: () => Promise.resolve("/staged"), + activate: () => { + events.push("activate"); + }, + }); + await updater.checkForUpdates(); + await updater.downloadUpdate(); + offline = true; + await updater.checkForUpdates(); + expect(updater.getStatus()).toEqual({ type: "downloaded", info: { version: "2.0.0" } }); + await updater.installUpdate(); + expect(events).toEqual(["activate", "restart"]); + }); test("a shutdown that begins while blockers refresh never activates the update", async () => { const { layout } = await fixture(); const events: string[] = []; @@ -592,6 +796,25 @@ describe("registry discovery", () => { downloadArtifact(artifact, tampered, () => Promise.resolve(new Response("", { status: 404 }))) ); }); + test("publishes a release's sha512 digest and legacy sha1 shasum, for the named package only", async () => { + const shasum = "0123456789abcdef0123456789abcdef01234567"; + const manifest = (name: string) => + Promise.resolve( + new Response( + JSON.stringify({ + name, + version: "1.0.0", + dist: { tarball: "https://r.example.com/x.tgz", integrity: sriOf("x"), shasum }, + }) + ) + ); + expect( + await fetchPublishedDigests("https://r.example.com", "x", "1.0.0", () => manifest("x")) + ).toEqual([sriOf("x"), `sha1-${Buffer.from(shasum, "hex").toString("base64")}`]); + await expectFailure(() => + fetchPublishedDigests("https://r.example.com", "x", "1.0.0", () => manifest("y")) + ); + }); test("rejects HTTP errors and malformed responses", async () => { await expectFailure(() => fetchDistTags("https://registry.example.com", () => diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts index 2b705d0d5a8..7583c09dbbf 100644 --- a/src/node/services/serverUpdate/serverUpdater.ts +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -107,6 +107,13 @@ export class ServerUpdater { : { type: "up-to-date" } ); } catch (error) { + // A verified stage stays installable while the registry is unreachable; the dialog offers no + // install action on a check error. + if (this.staged) { + log.warn("Update check failed; the staged update remains installable", error); + this.setStatus({ type: "downloaded", info: { version: this.staged.version } }); + return; + } this.setStatus( options?.source === "auto" ? previous diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index 5ea70e83ddc..3f43f6964e9 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -1,9 +1,13 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { z } from "zod"; import { execFileAsync } from "@/node/utils/disposableExec"; import { + SERVER_UPDATE_CLI_INTERPRETER, + SERVER_UPDATE_CLI_SHEBANG, SERVER_UPDATE_INSTALL_TIMEOUT_MS, SERVER_UPDATE_SMOKE_TIMEOUT_MS, + SERVER_UPDATE_STAGE_MARKER, SERVER_UPDATE_STAGING_PREFIX, } from "@/constants/serverUpdate"; import { @@ -12,6 +16,7 @@ import { resolveCliEntry, type InstallLayout, } from "./installLayout"; +import { verifyStagedDependencies } from "./lockfile"; import { downloadArtifact, fetchArtifact, type RegistryRequest } from "./registry"; /** Installs an already verified local tarball; only its dependencies come from the registry. */ @@ -52,13 +57,20 @@ export async function verifyStagedPackage( const entry = path.join(packageDir, "dist/cli/index.js"); const stat = await fs.stat(entry); if (!stat.isFile()) throw new Error("Staged CLI entry is not a file"); - // The supervisor execs the launcher symlink directly, so the entry must carry a shebang and be - // executable; a parseable file without them would fail every relaunch attempt. + // The supervisor execs the launcher symlink directly, so the entry must be executable and name + // the interpreter the running install uses; a parseable file with any other first line would + // fail every relaunch attempt. + const shebang = `${SERVER_UPDATE_CLI_SHEBANG}\n`; const handle = await fs.open(entry); try { - const { buffer, bytesRead } = await handle.read(Buffer.alloc(2), 0, 2, 0); - if (bytesRead < 2 || buffer.toString() !== "#!") - throw new Error("Staged CLI entry has no interpreter line"); + const { buffer, bytesRead } = await handle.read( + Buffer.alloc(shebang.length), + 0, + shebang.length, + 0 + ); + if (buffer.subarray(0, bytesRead).toString() !== shebang) + throw new Error("Staged CLI entry does not start with the expected interpreter line"); } finally { await handle.close(); } @@ -66,7 +78,7 @@ export async function verifyStagedPackage( throw new Error("Staged CLI entry is not executable"); // Parse-only: nothing from the registry runs until the operator activates it. The shebang's // interpreter is used because process.execPath may be bun, which has no parse-only mode. - using smoke = execFileAsync("node", ["--check", entry], { + using smoke = execFileAsync(SERVER_UPDATE_CLI_INTERPRETER, ["--check", entry], { timeoutMs: SERVER_UPDATE_SMOKE_TIMEOUT_MS, signal, }); @@ -97,6 +109,21 @@ export interface StageOptions { signal?: AbortSignal; } +const markerSchema = z.object({ launcher: z.string() }); + +/** Only a stage this installation created may be pruned; a name collision is not ownership. */ +async function ownsStage(candidate: string, layout: InstallLayout): Promise { + try { + const raw: unknown = JSON.parse( + await fs.readFile(path.join(candidate, SERVER_UPDATE_STAGE_MARKER), "utf8") + ); + const marker = markerSchema.safeParse(raw); + return marker.success && marker.data.launcher === layout.launcher; + } catch { + return false; + } +} + export async function stageUpdate( layout: InstallLayout, version: string, @@ -118,13 +145,18 @@ export async function stageUpdate( ) continue; const candidate = path.join(parent, entry.name); - if ((await fs.realpath(candidate)) !== active) await fs.rm(candidate, { recursive: true }); + if ((await fs.realpath(candidate)) !== active && (await ownsStage(candidate, layout))) + await fs.rm(candidate, { recursive: true }); } // Exclusive creation refuses pre-existing links, and never mutates the running installation. await fs.mkdir(dir); + await fs.writeFile( + path.join(dir, SERVER_UPDATE_STAGE_MARKER), + JSON.stringify({ launcher: layout.launcher }) + ); await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ private: true })); - // Package managers follow redirects, so the release itself is fetched and digest-checked here - // and only the dependency tree is left to the manager, as in the operator's original install. + // Package managers follow redirects, so the release itself is fetched and digest-checked here; + // the dependency tree the manager resolves is anchored to the registry afterwards. const tarball = path.join(dir, `xum-${version}.tgz`); await downloadArtifact( await fetchArtifact(layout.registry, version, request), @@ -134,5 +166,6 @@ export async function stageUpdate( ); const command = installCommand(layout, tarball); await install(command.file, command.args, dir, signal); + await verifyStagedDependencies(layout, dir, request, signal); return verifyStagedPackage(dir, version, signal); } From 05d89698207979cd59d7123e5253e13199a57517 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:43:08 +0000 Subject: [PATCH 15/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20mark?= =?UTF-8?q?=20stages=20before=20naming=20them,=20abort=20manifest=20fetche?= =?UTF-8?q?s=20on=20shutdown,=20require=20bun's=20text=20lockfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create each stage under a unique suffix, write the ownership marker, then rename it to its final name; prune every marked stage this installation owns, including suffixed leftovers from an interrupted attempt - thread the stage's abort signal into the release manifest fetch - treat a bun.lockb-only installation as unsupported and pass --save-text-lockfile, since dependency verification reads bun.lock - use npm's recorded package name for aliased lockfile entries --- .../services/serverUpdate/installLayout.ts | 9 ++-- src/node/services/serverUpdate/lockfile.ts | 5 +- src/node/services/serverUpdate/registry.ts | 5 +- .../serverUpdate/serverUpdate.test.ts | 51 ++++++++++++++++--- src/node/services/serverUpdate/staging.ts | 24 ++++----- 5 files changed, 67 insertions(+), 27 deletions(-) diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index f1483d9fd26..ac1581ec798 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -101,12 +101,9 @@ export function resolveInstallLayout( if (path.basename(dir) !== "node_modules") continue; const parent = path.dirname(dir); const managers: Array = []; - if ( - [SERVER_UPDATE_LOCKFILES.bun, "bun.lockb"].some((lock) => - existsSync(path.join(parent, lock)) - ) - ) - managers.push("bun"); + // Only the text lockfile is supported: staged dependencies are verified from the lockfile the + // manager writes, and there is no parser for the legacy binary bun.lockb. + if (existsSync(path.join(parent, SERVER_UPDATE_LOCKFILES.bun))) managers.push("bun"); if (existsSync(path.join(parent, SERVER_UPDATE_LOCKFILES.npm))) managers.push("npm"); if (existsSync(path.join(parent, SERVER_UPDATE_LOCKFILES.pnpm))) managers.push("pnpm"); if (managers.length > 1) throw new Error("Ambiguous package manager lockfiles"); diff --git a/src/node/services/serverUpdate/lockfile.ts b/src/node/services/serverUpdate/lockfile.ts index cc1da5351e0..eb9267bacc0 100644 --- a/src/node/services/serverUpdate/lockfile.ts +++ b/src/node/services/serverUpdate/lockfile.ts @@ -48,6 +48,8 @@ const npmLock = z.object({ packages: z.record( z.string(), z.object({ + // Present when the installed folder name is an alias for another package. + name: z.string().optional(), version: z.string().optional(), resolved: z.string().optional(), integrity: z.string().optional(), @@ -87,7 +89,8 @@ const lockfileParsers: Record ? [] : [ { - name: key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length), + name: + pkg.name ?? key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length), version: pkg.version ?? "", integrity: pkg.integrity, resolved: pkg.resolved, diff --git a/src/node/services/serverUpdate/registry.ts b/src/node/services/serverUpdate/registry.ts index 8045813455b..964285fc4dd 100644 --- a/src/node/services/serverUpdate/registry.ts +++ b/src/node/services/serverUpdate/registry.ts @@ -94,9 +94,10 @@ async function fetchManifest( export async function fetchArtifact( registry: string, version: string, - request: RegistryRequest = fetch + request: RegistryRequest = fetch, + signal?: AbortSignal ): Promise { - const dist = await fetchManifest(registry, "@coder/xum", version, request); + const dist = await fetchManifest(registry, "@coder/xum", version, request, signal); if (!dist.integrity) throw new Error("Registry manifest has no verifiable tarball for the requested version"); const tarball = new URL(dist.tarball); diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index d1ea27b35a1..df4400c2f40 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -235,7 +235,10 @@ describe("server install layout", () => { await fs.unlink(path.join(layout.workdir, "package-lock.json")); await fs.unlink(path.join(layout.workdir, "bun.lock")); expect(resolveInstallLayout(env, argv).supported).toBe(false); + // The legacy binary lockfile cannot be verified after staging. await fs.writeFile(path.join(layout.workdir, "bun.lockb"), ""); + expect(resolveInstallLayout(env, argv).supported).toBe(false); + await fs.writeFile(path.join(layout.workdir, "bun.lock"), ""); expect(resolveInstallLayout(env, argv).supported).toBe(true); await fs.writeFile(path.join(layout.workdir, "node_modules/@coder/xum/package.json"), "{}"); expect(resolveInstallLayout(env, argv).supported).toBe(false); @@ -258,6 +261,9 @@ describe("staging and activation", () => { expect(installCommand({ ...layout, packageManager: "pnpm" }, "/x.tgz").args).toContain( "--config.strict-ssl=true" ); + expect(installCommand({ ...layout, packageManager: "bun" }, "/x.tgz").args).toContain( + "--save-text-lockfile" + ); await expectFailure(() => stageUpdate(layout, "../../escape", fakeRegistry("2.0.0"))); }); test("stages the digest-checked tarball, refusing a corrupted download before any install", async () => { @@ -318,6 +324,13 @@ describe("staging and activation", () => { path.join(root, "xum-staging-0.7.0", SERVER_UPDATE_STAGE_MARKER), JSON.stringify({ launcher: path.join(root, "other-mux") }) ); + // A stage that crashed before taking its final name is marked and pruned as well. + const crashed = path.join(root, "xum-staging-1.4.0.a1b2c3"); + await fs.mkdir(crashed); + await fs.writeFile( + path.join(crashed, SERVER_UPDATE_STAGE_MARKER), + JSON.stringify({ launcher: layout.launcher }) + ); // The updater's own earlier stage carries the marker it wrote and is pruned like the stale one. await stageUpdate(layout, "1.5.0", { ...fakeRegistry("1.5.0"), install: fakeInstall("1.5.0") }); const bin = await stageUpdate(layout, "2.0.0", { @@ -349,6 +362,16 @@ describe("staging and activation", () => { ); expect(await fs.realpath(layout.launcher)).toBe(await fs.realpath(bin)); expect(await fs.readFile(layout.entry, "utf8")).toBe(oldEntry); + // A populated foreign directory under the target name fails the stage and is left intact. + await fs.mkdir(path.join(root, "xum-staging-4.0.0")); + await fs.writeFile(path.join(root, "xum-staging-4.0.0/keep"), ""); + await expectFailure(() => + stageUpdate(result.layout, "4.0.0", { + ...fakeRegistry("4.0.0"), + install: fakeInstall("4.0.0"), + }) + ); + expect(await fs.readdir(path.join(root, "xum-staging-4.0.0"))).toEqual(["keep"]); }); test("verification rejects mismatched versions, missing entrypoints, and failing smoke runs", async () => { const { layout } = await fixture(); @@ -376,6 +399,8 @@ describe("staging and activation", () => { test("anchors every locked dependency digest to the registry for each manager's lockfile", async () => { const { layout, root } = await fixture(); const deps = { "zod@4.5.4": sriOf("zod"), "inner@1.0.0": sriOf("inner") }; + // npm records an aliased install (`aliaspkg@npm:realpkg@1.0.0`) under the alias folder. + const alias = { "realpkg@1.0.0": sriOf("realpkg") }; const stage = async ( manager: InstallLayout["packageManager"], lockfile: string, @@ -402,6 +427,12 @@ describe("staging and activation", () => { integrity: deps["inner@1.0.0"], }, "node_modules/bundled": { version: "1.0.0", inBundle: true }, + "node_modules/aliaspkg": { + name: "realpkg", + version: "1.0.0", + resolved: "https://registry.example.com/realpkg/-/realpkg-1.0.0.tgz", + integrity: alias["realpkg@1.0.0"], + }, }, }); const pnpmLock = [ @@ -424,15 +455,14 @@ describe("staging and activation", () => { pnpm: await stage("pnpm", "pnpm-lock.yaml", pnpmLock), }; for (const packageManager of ["bun", "npm", "pnpm"] as const) { - const registry = fakeRegistry("2.0.0", undefined, {}, deps); + const registry = fakeRegistry("2.0.0", undefined, {}, { ...deps, ...alias }); const managerLayout = { ...layout, packageManager }; + const expected = [`${layout.registry}/inner/1.0.0`, `${layout.registry}/zod/4.5.4`]; + if (packageManager === "npm") expected.push(`${layout.registry}/realpkg/1.0.0`); expect( await verifyStagedDependencies(managerLayout, stages[packageManager], registry.request) - ).toBe(2); - expect(registry.calls.map((call) => call.url).sort()).toEqual([ - `${layout.registry}/inner/1.0.0`, - `${layout.registry}/zod/4.5.4`, - ]); + ).toBe(expected.length); + expect(registry.calls.map((call) => call.url).sort()).toEqual(expected.sort()); expect(registry.calls.every((call) => call.options.redirect === "error")).toBe(true); // A digest the registry does not publish is the redirect-tampering signature. const tampered = fakeRegistry( @@ -796,6 +826,15 @@ describe("registry discovery", () => { downloadArtifact(artifact, tampered, () => Promise.resolve(new Response("", { status: 404 }))) ); }); + test("the stage's abort signal reaches the manifest request", async () => { + const registry = fakeRegistry("2.0.0"); + const abort = new AbortController(); + abort.abort(); + await fetchArtifact("https://registry.example.com", "2.0.0", registry.request, abort.signal); + expect(registry.calls[0].options.signal?.aborted).toBe(true); + await fetchArtifact("https://registry.example.com", "2.0.0", registry.request); + expect(registry.calls[1].options.signal?.aborted).toBe(false); + }); test("publishes a release's sha512 digest and legacy sha1 shasum, for the named package only", async () => { const shasum = "0123456789abcdef0123456789abcdef01234567"; const manifest = (name: string) => diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index 3f43f6964e9..980eb45ab0d 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -26,9 +26,10 @@ export function installCommand( ): { file: string; args: string[] } { // CLI flags outrank npmrc files and npm_config_* env, so an inherited strict-ssl=false cannot // disable certificate validation for the download. bun has no such setting; its only TLS knob - // is the env variable runInstall strips. + // is the env variable runInstall strips. The text lockfile is required for dependency + // verification, so an older bun that only writes bun.lockb must fail here. const flags = { - bun: ["add", "--ignore-scripts"], + bun: ["add", "--ignore-scripts", "--save-text-lockfile"], npm: [ "install", "--no-global", @@ -138,28 +139,27 @@ export async function stageUpdate( const active = await fs.realpath(layout.workdir); const dir = path.join(parent, `${SERVER_UPDATE_STAGING_PREFIX}${version}`); for (const entry of await fs.readdir(parent, { withFileTypes: true })) { - if ( - !entry.isDirectory() || - !entry.name.startsWith(SERVER_UPDATE_STAGING_PREFIX) || - !isExactVersion(entry.name.slice(SERVER_UPDATE_STAGING_PREFIX.length)) - ) - continue; + if (!entry.isDirectory() || !entry.name.startsWith(SERVER_UPDATE_STAGING_PREFIX)) continue; const candidate = path.join(parent, entry.name); if ((await fs.realpath(candidate)) !== active && (await ownsStage(candidate, layout))) await fs.rm(candidate, { recursive: true }); } - // Exclusive creation refuses pre-existing links, and never mutates the running installation. - await fs.mkdir(dir); + // The marker lands before the directory takes its final name, so a crash in between leaves a + // suffixed, marked directory the next attempt prunes rather than an unmarked one that would + // block this version. The rename refuses a pre-existing link or populated directory and never + // mutates the running installation. + const partial = await fs.mkdtemp(`${dir}.`); await fs.writeFile( - path.join(dir, SERVER_UPDATE_STAGE_MARKER), + path.join(partial, SERVER_UPDATE_STAGE_MARKER), JSON.stringify({ launcher: layout.launcher }) ); + await fs.rename(partial, dir); await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ private: true })); // Package managers follow redirects, so the release itself is fetched and digest-checked here; // the dependency tree the manager resolves is anchored to the registry afterwards. const tarball = path.join(dir, `xum-${version}.tgz`); await downloadArtifact( - await fetchArtifact(layout.registry, version, request), + await fetchArtifact(layout.registry, version, request, signal), tarball, request, signal From 02ca8342cd1f54a6bfeb1d842c36ecc1ba9b9302 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:12:41 +0000 Subject: [PATCH 16/22] =?UTF-8?q?=F0=9F=A4=96=20fix(server-update):=20fini?= =?UTF-8?q?sh=20short=20tarball=20writes,=20generate=20version.ts=20for=20?= =?UTF-8?q?every=20VS=20Code=20build=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - keep writing each downloaded chunk until every byte is persisted so the digest always covers the bytes on disk - give the delegated VS Code build its own src/version.ts prerequisite so vscode-ext, vscode-ext-install, and a direct make -C vscode all generate it --- Makefile | 2 +- src/node/services/serverUpdate/registry.ts | 4 +- .../serverUpdate/serverUpdate.test.ts | 48 ++++++++++++++++++- vscode/Makefile | 7 ++- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 0b0580d0511..23033945f09 100644 --- a/Makefile +++ b/Makefile @@ -545,7 +545,7 @@ check-appimage-icons: ## Validate AppImage icon structure (requires prior dist-l ## VS Code Extension (delegates to vscode/Makefile) -vscode-ext: src/version.ts ## Build VS Code extension (.vsix) +vscode-ext: ## Build VS Code extension (.vsix) @$(MAKE) -C vscode build vscode-ext-install: ## Build and install VS Code extension locally diff --git a/src/node/services/serverUpdate/registry.ts b/src/node/services/serverUpdate/registry.ts index 964285fc4dd..72a228866f6 100644 --- a/src/node/services/serverUpdate/registry.ts +++ b/src/node/services/serverUpdate/registry.ts @@ -138,7 +138,9 @@ export async function downloadArtifact( const reader = response.body.getReader(); for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) { hash.update(chunk.value); - await file.write(chunk.value); + // A write may persist fewer bytes than offered; the digest must cover what reached disk. + for (let offset = 0; offset < chunk.value.length; ) + offset += (await file.write(chunk.value, offset)).bytesWritten; } } finally { await file.close(); diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index df4400c2f40..e851539d9f6 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as os from "node:os"; @@ -826,6 +826,52 @@ describe("registry discovery", () => { downloadArtifact(artifact, tampered, () => Promise.resolve(new Response("", { status: 404 }))) ); }); + test("finishes writing a chunk the file handle only partially accepted", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "server-update-")); + dirs.push(dir); + interface Writer { + write: ( + buffer: Uint8Array, + offset?: number, + length?: number + ) => Promise<{ bytesWritten: number }>; + } + const probe = await fs.open(path.join(dir, "probe"), "w"); + const proto = Object.getPrototypeOf(probe) as Writer; + await probe.close(); + const write = proto.write; + let shortened = 0; + const spy = spyOn(proto, "write").mockImplementation(function ( + this: Writer, + buffer, + offset = 0, + length + ) { + // Persist a single byte the first time a multi-byte chunk arrives. + if (shortened === 0 && buffer.length - offset > 1) { + shortened++; + return write.call(this, buffer, offset, 1); + } + return write.call(this, buffer, offset, length); + }); + try { + const registry = fakeRegistry( + "2.0.0", + new TextEncoder().encode("a tarball with several bytes") + ); + const artifact = await fetchArtifact( + "https://registry.example.com", + "2.0.0", + registry.request + ); + const dest = path.join(dir, "xum.tgz"); + await downloadArtifact(artifact, dest, registry.request); + expect(shortened).toBe(1); + expect(new Uint8Array(await fs.readFile(dest))).toEqual(registry.bytes); + } finally { + spy.mockRestore(); + } + }); test("the stage's abort signal reaches the manifest request", async () => { const registry = fakeRegistry("2.0.0"); const abort = new AbortController(); diff --git a/vscode/Makefile b/vscode/Makefile index 24fc429ec4b..e651935b228 100644 --- a/vscode/Makefile +++ b/vscode/Makefile @@ -21,8 +21,13 @@ node_modules/.installed: package.json @bun install @touch node_modules/.installed +# The webview imports the root's generated version module, so every build path (including a +# direct `make -C vscode`) must generate it first. +../src/version.ts: + @$(MAKE) -C .. src/version.ts + ## Build extension package -build: node_modules/.installed ## Build VS Code extension (.vsix) +build: node_modules/.installed ../src/version.ts ## Build VS Code extension (.vsix) @echo "Building VS Code extension with esbuild..." @rm -rf out mux-0.1.0.vsix @bun run compile From 849e0d6adea0a621a218049d7721493be05ac59e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:46:44 +0000 Subject: [PATCH 17/22] server-update: require every lockfile digest to be published, force lockfiles, regenerate version metadata - verifyStagedDependencies accepts a dependency only when every token in its recorded integrity set is registry-published; a manager accepts a tarball matching any token of the strongest algorithm, so one published digest cannot vouch for a foreign one listed beside it. - npm gets --package-lock=true and pnpm --config.lockfile=true so an inherited package-lock=false / lockfile=false cannot suppress the lockfile that verification reads; runInstall strips BUN_CONFIG_SKIP_SAVE_LOCKFILE for bun. - vscode/Makefile delegates version generation through a phony target so a stale src/version.ts from an earlier checkout is regenerated on every build. --- src/node/services/serverUpdate/lockfile.ts | 11 +++++--- .../serverUpdate/serverUpdate.test.ts | 25 ++++++++++++++----- src/node/services/serverUpdate/staging.ts | 22 +++++++++++----- vscode/Makefile | 11 ++++---- 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/node/services/serverUpdate/lockfile.ts b/src/node/services/serverUpdate/lockfile.ts index eb9267bacc0..d2cf42f62f4 100644 --- a/src/node/services/serverUpdate/lockfile.ts +++ b/src/node/services/serverUpdate/lockfile.ts @@ -117,9 +117,9 @@ const isLocal = (resolved: string) => /** * Anchors the staged dependency tree to the configured registry. Managers follow redirects (also * to plaintext) while resolving dependencies and record whatever digest they were served, so the - * lockfile alone proves nothing; each recorded digest must equal the one the registry publishes - * over verified HTTPS without redirects. Managers do verify every tarball against its recorded - * digest, which makes that digest the only link that needs anchoring. Returns the verified count. + * lockfile alone proves nothing; every recorded digest must be one the registry publishes over + * verified HTTPS without redirects. Managers do verify every tarball against its recorded digest, + * which makes that digest the only link that needs anchoring. Returns the verified count. */ export async function verifyStagedDependencies( layout: InstallLayout, @@ -152,7 +152,10 @@ export async function verifyStagedDependencies( request, signal ); - if (!pkg.integrity.split(/\s+/).some((sri) => published.includes(sri))) + // A manager accepts a tarball matching any recorded digest of its strongest algorithm, so + // one published digest cannot vouch for a foreign one listed beside it. + const recorded = pkg.integrity.trim().split(/\s+/); + if (!recorded.every((sri) => published.includes(sri))) throw new Error( `Registry digest for ${pkg.name}@${pkg.version} differs from the staged lockfile` ); diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index e851539d9f6..a1e6245fd25 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -255,12 +255,12 @@ describe("staging and activation", () => { expect(command.args).toContain("--ignore-scripts"); expect(command.args.slice(-2)).toEqual(["--registry", layout.registry]); } - expect(installCommand({ ...layout, packageManager: "npm" }, "/x.tgz").args).toContain( - "--strict-ssl" - ); - expect(installCommand({ ...layout, packageManager: "pnpm" }, "/x.tgz").args).toContain( - "--config.strict-ssl=true" - ); + const npmArgs = installCommand({ ...layout, packageManager: "npm" }, "/x.tgz").args; + expect(npmArgs).toContain("--strict-ssl"); + expect(npmArgs).toContain("--package-lock=true"); + const pnpmArgs = installCommand({ ...layout, packageManager: "pnpm" }, "/x.tgz").args; + expect(pnpmArgs).toContain("--config.strict-ssl=true"); + expect(pnpmArgs).toContain("--config.lockfile=true"); expect(installCommand({ ...layout, packageManager: "bun" }, "/x.tgz").args).toContain( "--save-text-lockfile" ); @@ -475,6 +475,19 @@ describe("staging and activation", () => { verifyStagedDependencies(managerLayout, stages[packageManager], tampered.request) ); } + // A published digest listed beside a foreign one must not vouch for it: managers accept a + // tarball matching either. + const mixed = await stage( + "bun", + "bun.lock", + bunLock("/stage/xum-2.0.0.tgz", { + ...deps, + "inner@1.0.0": `${sriOf("evil")} ${deps["inner@1.0.0"]}`, + }) + ); + await expectFailure(() => + verifyStagedDependencies(layout, mixed, fakeRegistry("2.0.0", undefined, {}, deps).request) + ); const refused = { plaintext: ` "evil": ["evil@1.0.0", "http://mirror.example.com/evil-1.0.0.tgz", {}, "${sriOf("evil")}"],`, unpinned: ' "evil": ["evil@1.0.0", "", {}],', diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index 980eb45ab0d..564dbf7184f 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -25,9 +25,11 @@ export function installCommand( tarball: string ): { file: string; args: string[] } { // CLI flags outrank npmrc files and npm_config_* env, so an inherited strict-ssl=false cannot - // disable certificate validation for the download. bun has no such setting; its only TLS knob - // is the env variable runInstall strips. The text lockfile is required for dependency - // verification, so an older bun that only writes bun.lockb must fail here. + // disable certificate validation for the download and an inherited package-lock=false or + // lockfile=false cannot suppress the lockfile that dependency verification reads. bun has no + // such flags; its TLS and lockfile knobs are env variables runInstall strips (a global bunfig + // that disables lockfile saving still makes verification fail closed). The text lockfile is + // required, so an older bun that only writes bun.lockb must fail here. const flags = { bun: ["add", "--ignore-scripts", "--save-text-lockfile"], npm: [ @@ -38,8 +40,15 @@ export function installCommand( "--omit=dev", "--ignore-scripts", "--strict-ssl", + "--package-lock=true", + ], + pnpm: [ + "add", + "--no-global", + "--ignore-scripts", + "--config.strict-ssl=true", + "--config.lockfile=true", ], - pnpm: ["add", "--no-global", "--ignore-scripts", "--config.strict-ssl=true"], } satisfies Record; return { file: layout.packageManager, @@ -95,8 +104,9 @@ async function runInstall( ): Promise { using install = execFileAsync(file, args, { cwd, - // Disables TLS validation process-wide in every manager and cannot be outranked by a flag. - env: { NODE_TLS_REJECT_UNAUTHORIZED: undefined }, + // The TLS variable disables certificate validation in every manager and the bun variable + // suppresses the lockfile; neither can be outranked by a flag. + env: { NODE_TLS_REJECT_UNAUTHORIZED: undefined, BUN_CONFIG_SKIP_SAVE_LOCKFILE: undefined }, timeoutMs: SERVER_UPDATE_INSTALL_TIMEOUT_MS, killTreeOnTermination: true, signal, diff --git a/vscode/Makefile b/vscode/Makefile index e651935b228..8248dd94e87 100644 --- a/vscode/Makefile +++ b/vscode/Makefile @@ -2,7 +2,7 @@ # ============================== # Isolated build system for the mux VS Code/Cursor extension -.PHONY: all build install clean test test-integration test-orpc help +.PHONY: all build install clean test test-integration test-orpc help version # Default target all: build @@ -21,13 +21,14 @@ node_modules/.installed: package.json @bun install @touch node_modules/.installed -# The webview imports the root's generated version module, so every build path (including a -# direct `make -C vscode`) must generate it first. -../src/version.ts: +# The webview imports the root's generated version module. Phony like the root's `version` +# target, so every build path (including a direct `make -C vscode`) regenerates it instead of +# trusting a stale file left by an earlier checkout. +version: @$(MAKE) -C .. src/version.ts ## Build extension package -build: node_modules/.installed ../src/version.ts ## Build VS Code extension (.vsix) +build: node_modules/.installed version ## Build VS Code extension (.vsix) @echo "Building VS Code extension with esbuild..." @rm -rf out mux-0.1.0.vsix @bun run compile From 3b01d8eae916d8b5fd780776a0b964faa873a6c4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:04:31 +0000 Subject: [PATCH 18/22] server-update: anchor dependency identity to the requested name Redirected metadata can name a dependency anything and point it at any published tarball or at the release tarball itself, so a manifest-supplied name let the attacker choose which registry digest an entry was compared against. Every parser now verifies the name the dependent requested and rejects aliases: npm's folder-derived name must match any recorded "name", pnpm's dependency edges must be plain versions, and the local exemption applies only to the top-level release entry by lockfile key. Bundled flags no longer exempt an entry; the release ships no aliased or bundled packages. --- src/node/services/serverUpdate/lockfile.ts | 94 +++++---- .../serverUpdate/serverUpdate.test.ts | 198 +++++++++++++----- 2 files changed, 203 insertions(+), 89 deletions(-) diff --git a/src/node/services/serverUpdate/lockfile.ts b/src/node/services/serverUpdate/lockfile.ts index d2cf42f62f4..facad2fa4fd 100644 --- a/src/node/services/serverUpdate/lockfile.ts +++ b/src/node/services/serverUpdate/lockfile.ts @@ -10,13 +10,21 @@ import { import { isExactVersion, type InstallLayout } from "./installLayout"; import { fetchPublishedDigests, type RegistryRequest } from "./registry"; -/** One package a manager's lockfile records, before any trust decision. */ +/** + * One package a manager's lockfile records, before any trust decision. `name` is the name the + * dependent requested, never one the served metadata supplied: redirected metadata can call a + * dependency anything and point it at any published tarball, so a manifest-supplied name would let + * the attacker choose which registry digest the entry is compared against. Parsers therefore + * reject aliases (a package installed under another name) outright; the release has none. + */ interface LockedPackage { name: string; version: string; integrity?: string; /** Explicit location (URL or local path); absent when derived from the configured registry. */ resolved?: string; + /** The top-level install of the release tarball, identified by its lockfile key. */ + root: boolean; } /** Splits `name@resolution`; a scoped name keeps its leading `@`. */ @@ -25,7 +33,7 @@ function splitSpec(spec: string): [name: string, resolution: string] { return at > 0 ? [spec.slice(0, at), spec.slice(at + 1)] : [spec, ""]; } -const bunMeta = z.object({ bundled: z.boolean().optional() }); +const bunMeta = z.object({}); const bunLock = z.object({ packages: z.record( z.string(), @@ -33,12 +41,7 @@ const bunLock = z.object({ // Registry releases: spec, tarball URL ("" when derived from the registry), meta, sri. z .tuple([z.string(), z.string(), bunMeta, z.string().optional()]) - .transform(([spec, registry, meta, integrity]) => ({ - spec, - registry, - bundled: meta.bundled === true, - integrity, - })), + .transform(([spec, registry, , integrity]) => ({ spec, registry, integrity })), // Every other resolution kind names its location in the spec. z.tuple([z.string(), bunMeta]).transform(([spec]) => ({ spec })), ]) @@ -48,57 +51,76 @@ const npmLock = z.object({ packages: z.record( z.string(), z.object({ - // Present when the installed folder name is an alias for another package. + // Present only when the installed package's name differs from its folder. name: z.string().optional(), version: z.string().optional(), resolved: z.string().optional(), integrity: z.string().optional(), - inBundle: z.boolean().optional(), }) ), }); +// Dependency edges: `requested-name: version(peers)` when the package resolved under its own +// name, `requested-name: name@version` (v9) or `/name@version` (v6) when it did not. +const pnpmEdges = z.record(z.string(), z.string()).optional(); const pnpmLock = z.object({ packages: z.record( z.string(), z.object({ version: z.string().optional(), resolution: z.object({ integrity: z.string().optional(), tarball: z.string().optional() }), + dependencies: pnpmEdges, + optionalDependencies: pnpmEdges, }) ), + snapshots: z + .record(z.string(), z.object({ dependencies: pnpmEdges, optionalDependencies: pnpmEdges })) + .optional(), }); const lockfileParsers: Record LockedPackage[]> = { + // A bun spec names the package bun asked the registry for (the alias target for an alias), + // never the name the served metadata supplied. bun: (raw) => - Object.values(bunLock.parse(jsonc.parse(raw)).packages).flatMap((entry): LockedPackage[] => { + Object.entries(bunLock.parse(jsonc.parse(raw)).packages).map(([key, entry]): LockedPackage => { const [name, resolution] = splitSpec(entry.spec); - if (!("registry" in entry)) return [{ name, version: "", resolved: resolution }]; - // Bundled packages ship inside their parent's verified tarball. - if (entry.bundled) return []; + const root = key === "@coder/xum"; + if (!("registry" in entry)) return { name, version: "", resolved: resolution, root }; + return { + name, + version: resolution, + integrity: entry.integrity, + resolved: entry.registry || undefined, + root, + }; + }), + npm: (raw) => + Object.entries(npmLock.parse(JSON.parse(raw)).packages).flatMap(([key, pkg]) => { + if (key === "") return []; + const name = key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length); + if (pkg.name !== undefined && pkg.name !== name) + throw new Error(`Dependency ${name} was installed under another package's name`); return [ { name, - version: resolution, - integrity: entry.integrity, - resolved: entry.registry || undefined, + version: pkg.version ?? "", + integrity: pkg.integrity, + resolved: pkg.resolved, + root: key === "node_modules/@coder/xum", }, ]; }), - npm: (raw) => - Object.entries(npmLock.parse(JSON.parse(raw)).packages).flatMap(([key, pkg]) => - key === "" || pkg.inBundle - ? [] - : [ - { - name: - pkg.name ?? key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length), - version: pkg.version ?? "", - integrity: pkg.integrity, - resolved: pkg.resolved, - }, - ] - ), - pnpm: (raw) => - Object.entries(pnpmLock.parse(YAML.parse(raw)).packages).map(([key, pkg]) => { + pnpm: (raw) => { + const lock = pnpmLock.parse(YAML.parse(raw)); + // Package keys carry the name the metadata supplied, so the edges must prove it is also the + // requested one: only a plain version may appear on the right-hand side. + for (const entry of [...Object.values(lock.packages), ...Object.values(lock.snapshots ?? {})]) + for (const [requested, ref] of Object.entries({ + ...entry.dependencies, + ...entry.optionalDependencies, + })) + if (!isExactVersion(ref.replace(/\(.*$/, ""))) + throw new Error(`Dependency ${requested} was installed under another package's name`); + return Object.entries(lock.packages).map(([key, pkg]) => { // v6 keys are `/name@version(peer@x)`; v9 drops the slash. A local tarball entry carries its // real version separately. const [name, resolution] = splitSpec(key.replace(/^\//, "").replace(/\(.*$/, "")); @@ -107,8 +129,10 @@ const lockfileParsers: Record version: pkg.version ?? resolution, integrity: pkg.resolution.integrity, resolved: pkg.resolution.tarball, + root: name === "@coder/xum" && resolution.startsWith("file:"), }; - }), + }); + }, }; const isLocal = (resolved: string) => @@ -132,7 +156,7 @@ export async function verifyStagedDependencies( for (const pkg of lockfileParsers[layout.packageManager](await fs.readFile(lockfile, "utf8"))) { if (pkg.resolved !== undefined && isLocal(pkg.resolved)) { // The release tarball itself was digest-checked before the install. - if (pkg.name === "@coder/xum") continue; + if (pkg.root) continue; throw new Error(`Dependency ${pkg.name} was installed from a local path`); } if (pkg.resolved !== undefined && !pkg.resolved.startsWith("https://")) diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index a1e6245fd25..be1c6148ad0 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -399,66 +399,62 @@ describe("staging and activation", () => { test("anchors every locked dependency digest to the registry for each manager's lockfile", async () => { const { layout, root } = await fixture(); const deps = { "zod@4.5.4": sriOf("zod"), "inner@1.0.0": sriOf("inner") }; - // npm records an aliased install (`aliaspkg@npm:realpkg@1.0.0`) under the alias folder. - const alias = { "realpkg@1.0.0": sriOf("realpkg") }; - const stage = async ( - manager: InstallLayout["packageManager"], - lockfile: string, - raw: string - ) => { - const dir = path.join(root, `stage-${manager}`); + const stage = async (label: string, lockfile: string, raw: string) => { + const dir = path.join(root, `stage-${label}`); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, lockfile), raw); return dir; }; - const npmLock = JSON.stringify({ - lockfileVersion: 3, - packages: { - "": { dependencies: { "@coder/xum": "file:xum-2.0.0.tgz" } }, - "node_modules/@coder/xum": { version: "2.0.0", resolved: "file:xum-2.0.0.tgz" }, - "node_modules/zod": { - version: "4.5.4", - resolved: "https://registry.example.com/zod/-/zod-4.5.4.tgz", - integrity: deps["zod@4.5.4"], - }, - "node_modules/zod/node_modules/inner": { - version: "1.0.0", - resolved: "https://registry.example.com/inner/-/inner-1.0.0.tgz", - integrity: deps["inner@1.0.0"], - }, - "node_modules/bundled": { version: "1.0.0", inBundle: true }, - "node_modules/aliaspkg": { - name: "realpkg", - version: "1.0.0", - resolved: "https://registry.example.com/realpkg/-/realpkg-1.0.0.tgz", - integrity: alias["realpkg@1.0.0"], + const npmLock = (packages: Record = {}) => + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies: { "@coder/xum": "file:xum-2.0.0.tgz" } }, + "node_modules/@coder/xum": { version: "2.0.0", resolved: "file:xum-2.0.0.tgz" }, + "node_modules/zod": { + version: "4.5.4", + resolved: "https://registry.example.com/zod/-/zod-4.5.4.tgz", + integrity: deps["zod@4.5.4"], + }, + "node_modules/zod/node_modules/inner": { + version: "1.0.0", + resolved: "https://registry.example.com/inner/-/inner-1.0.0.tgz", + integrity: deps["inner@1.0.0"], + }, + ...packages, }, - }, - }); - const pnpmLock = [ - "lockfileVersion: '9.0'", - "packages:", - " '@coder/xum@file:xum-2.0.0.tgz':", - " resolution: {integrity: sha512-unchecked, tarball: file:xum-2.0.0.tgz}", - " version: 2.0.0", - " zod@4.5.4:", - ` resolution: {integrity: ${deps["zod@4.5.4"]}}`, - " '/inner@1.0.0(zod@4.5.4)':", - ` resolution: {integrity: ${deps["inner@1.0.0"]}}`, - "snapshots:", - " zod@4.5.4: {}", - "", - ].join("\n"); + }); + const pnpmLock = (packages: string[] = [], snapshots: string[] = []) => + [ + "lockfileVersion: '9.0'", + "packages:", + " '@coder/xum@file:xum-2.0.0.tgz':", + " resolution: {integrity: sha512-unchecked, tarball: file:xum-2.0.0.tgz}", + " version: 2.0.0", + " zod@4.5.4:", + ` resolution: {integrity: ${deps["zod@4.5.4"]}}`, + " '/inner@1.0.0(zod@4.5.4)':", + ` resolution: {integrity: ${deps["inner@1.0.0"]}}`, + ...packages, + "snapshots:", + " '@coder/xum@file:xum-2.0.0.tgz':", + " dependencies:", + " zod: 4.5.4", + " zod@4.5.4:", + " dependencies:", + " inner: 1.0.0(zod@4.5.4)", + ...snapshots, + "", + ].join("\n"); const stages = { bun: await stage("bun", "bun.lock", bunLock("/stage/xum-2.0.0.tgz", deps)), - npm: await stage("npm", "package-lock.json", npmLock), - pnpm: await stage("pnpm", "pnpm-lock.yaml", pnpmLock), + npm: await stage("npm", "package-lock.json", npmLock()), + pnpm: await stage("pnpm", "pnpm-lock.yaml", pnpmLock()), }; for (const packageManager of ["bun", "npm", "pnpm"] as const) { - const registry = fakeRegistry("2.0.0", undefined, {}, { ...deps, ...alias }); + const registry = fakeRegistry("2.0.0", undefined, {}, deps); const managerLayout = { ...layout, packageManager }; const expected = [`${layout.registry}/inner/1.0.0`, `${layout.registry}/zod/4.5.4`]; - if (packageManager === "npm") expected.push(`${layout.registry}/realpkg/1.0.0`); expect( await verifyStagedDependencies(managerLayout, stages[packageManager], registry.request) ).toBe(expected.length); @@ -506,17 +502,111 @@ describe("staging and activation", () => { await expectFailure(() => verifyStagedDependencies(layout, dir, registry.request)); expect(registry.calls).toHaveLength(0); } - // Bundled packages ship inside their parent's verified tarball and are never fetched. - const bundled = - ' "bundled": ["bundled@1.0.0", "", { "bundled": true }, "sha512-unchecked"],'; + // Redirected metadata can present any published package, or the release tarball itself, as + // the dependency a package requested; only the requested name may be verified, so a lockfile + // recording a different installed name is refused before any registry lookup. + const substituted: Array<[InstallLayout["packageManager"], string]> = [ + [ + "npm", + await stage( + "npm-renamed", + "package-lock.json", + npmLock({ + "node_modules/is-odd": { + name: "is-number", + version: "6.0.0", + resolved: "https://registry.example.com/is-number/-/is-number-6.0.0.tgz", + integrity: sriOf("is-number"), + }, + }) + ), + ], + [ + "npm", + await stage( + "npm-release", + "package-lock.json", + npmLock({ + "node_modules/commander": { + name: "@coder/xum", + version: "2.0.0", + resolved: "file:xum-2.0.0.tgz", + }, + }) + ), + ], + [ + "npm", + await stage( + "npm-nested-release", + "package-lock.json", + npmLock({ + "node_modules/zod/node_modules/@coder/xum": { + version: "2.0.0", + resolved: "file:xum-2.0.0.tgz", + }, + }) + ), + ], + [ + "npm", + await stage( + "npm-bundled", + "package-lock.json", + npmLock({ "node_modules/bundled": { version: "1.0.0", inBundle: true } }) + ), + ], + [ + "pnpm", + await stage( + "pnpm-renamed", + "pnpm-lock.yaml", + pnpmLock( + [" is-number@6.0.0:", ` resolution: {integrity: ${sriOf("is-number")}}`], + [ + " is-number@6.0.0: {}", + " inner@1.0.0(zod@4.5.4):", + " dependencies:", + " is-odd: is-number@6.0.0", + ] + ) + ), + ], + [ + "bun", + await stage( + "bun-release", + "bun.lock", + bunLock( + "/stage/xum-2.0.0.tgz", + deps, + ' "commander": ["@coder/xum@/stage/xum-2.0.0.tgz", {}],' + ) + ), + ], + ]; + for (const [packageManager, dir] of substituted) { + const registry = fakeRegistry( + "2.0.0", + undefined, + {}, + { ...deps, "is-number@6.0.0": sriOf("is-number") } + ); + await expectFailure(() => + verifyStagedDependencies({ ...layout, packageManager }, dir, registry.request) + ); + expect(registry.calls).toHaveLength(0); + } + // A bundled flag is metadata too and exempts nothing: the entry is verified like any other. + const bundled = ` "bundled": ["bundled@1.0.0", "", { "bundled": true }, "${sriOf("bundled")}"],`; const dir = await stage("bun", "bun.lock", bunLock("/stage/xum-2.0.0.tgz", deps, bundled)); expect( await verifyStagedDependencies( layout, dir, - fakeRegistry("2.0.0", undefined, {}, deps).request + fakeRegistry("2.0.0", undefined, {}, { ...deps, "bundled@1.0.0": sriOf("bundled") }).request ) - ).toBe(2); + ).toBe(3); const unreadable = await stage("bun", "bun.lock", "not a lockfile"); await expectFailure(() => verifyStagedDependencies(layout, unreadable, fakeRegistry("2.0.0").request) From 6f8cd7e0209e62613040ed2ab09b5d014c551e4d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:15:45 +0000 Subject: [PATCH 19/22] server-update: keep the unique stage name and serialize channel changes - The stage directory keeps its mkdtemp name for its whole life instead of being renamed to xum-staging-; rename() would replace an empty foreign directory of that name, and a populated one used to block the stage. Pruning already handles suffixed names. - UpdateService.setChannel chains each change behind the previous one so persist, runtime switch, and rollback cannot interleave across callers and leave the config and the runtime on different channels. --- .../serverUpdate/serverUpdate.test.ts | 38 +++++++++++------- src/node/services/serverUpdate/staging.ts | 13 +++--- src/node/services/updateService.test.ts | 40 +++++++++++++++++++ src/node/services/updateService.ts | 9 +++++ 4 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index be1c6148ad0..2d995e8ca8a 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -154,6 +154,13 @@ const fakeInstall = ); }; +/** The stage directory a staged CLI entry lives in. */ +const stageOf = (entry: string) => entry.slice(0, entry.indexOf("/node_modules/")); + +/** Names of every directory staged for `version` under `parent`, including foreign ones. */ +const stagesIn = async (parent: string, version: string) => + (await fs.readdir(parent)).filter((name) => name.startsWith(`xum-staging-${version}`)).sort(); + async function expectFailure(run: () => Promise) { let failed = false; try { @@ -277,12 +284,13 @@ describe("staging and activation", () => { await fakeInstall("2.0.0", { "zod@4.5.4": sriOf("zod") })(file, args, cwd); }, }); - const tarball = path.join(root, "xum-staging-2.0.0", "xum-2.0.0.tgz"); + const stageDir = stageOf(bin); + expect(path.dirname(stageDir)).toBe(root); + expect(path.basename(stageDir)).toMatch(/^xum-staging-2\.0\.0\./); + const tarball = path.join(stageDir, "xum-2.0.0.tgz"); expect(installed).toContain(tarball); expect(new Uint8Array(await fs.readFile(tarball))).toEqual(registry.bytes); - expect(bin).toBe( - path.join(root, "xum-staging-2.0.0/node_modules/@coder/xum/dist/cli/index.js") - ); + expect(bin).toBe(path.join(stageDir, "node_modules/@coder/xum/dist/cli/index.js")); expect(registry.calls.map((call) => call.url)).toEqual([ `${layout.registry}/@coder%2Fxum/2.0.0`, registry.tarball, @@ -301,7 +309,8 @@ describe("staging and activation", () => { }) ); expect(installs).toBe(0); - expect((await fs.readdir(path.join(root, "xum-staging-3.0.0"))).sort()).toEqual([ + const [aborted] = await stagesIn(root, "3.0.0"); + expect((await fs.readdir(path.join(root, aborted))).sort()).toEqual([ SERVER_UPDATE_STAGE_MARKER, "package.json", ]); @@ -341,7 +350,7 @@ describe("staging and activation", () => { expect(remaining.sort()).toEqual([ "xum-staging-0.7.0", "xum-staging-0.8.0", - "xum-staging-2.0.0", + path.basename(stageOf(bin)), ]); expect(await fs.readdir(path.join(root, "xum-staging-0.8.0"))).toEqual(["keep"]); activateUpdate(layout, bin); @@ -362,16 +371,15 @@ describe("staging and activation", () => { ); expect(await fs.realpath(layout.launcher)).toBe(await fs.realpath(bin)); expect(await fs.readFile(layout.entry, "utf8")).toBe(oldEntry); - // A populated foreign directory under the target name fails the stage and is left intact. + // A foreign directory carrying the version's name, even an empty one that rename() would + // silently replace, is neither touched nor an obstacle. await fs.mkdir(path.join(root, "xum-staging-4.0.0")); - await fs.writeFile(path.join(root, "xum-staging-4.0.0/keep"), ""); - await expectFailure(() => - stageUpdate(result.layout, "4.0.0", { - ...fakeRegistry("4.0.0"), - install: fakeInstall("4.0.0"), - }) - ); - expect(await fs.readdir(path.join(root, "xum-staging-4.0.0"))).toEqual(["keep"]); + await stageUpdate(result.layout, "4.0.0", { + ...fakeRegistry("4.0.0"), + install: fakeInstall("4.0.0"), + }); + expect(await fs.readdir(path.join(root, "xum-staging-4.0.0"))).toEqual([]); + expect(await stagesIn(root, "4.0.0")).toHaveLength(2); }); test("verification rejects mismatched versions, missing entrypoints, and failing smoke runs", async () => { const { layout } = await fixture(); diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index 564dbf7184f..55830edeacf 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -147,23 +147,20 @@ export async function stageUpdate( throw new Error("Server launcher changed since startup"); const parent = path.dirname(layout.workdir); const active = await fs.realpath(layout.workdir); - const dir = path.join(parent, `${SERVER_UPDATE_STAGING_PREFIX}${version}`); for (const entry of await fs.readdir(parent, { withFileTypes: true })) { if (!entry.isDirectory() || !entry.name.startsWith(SERVER_UPDATE_STAGING_PREFIX)) continue; const candidate = path.join(parent, entry.name); if ((await fs.realpath(candidate)) !== active && (await ownsStage(candidate, layout))) await fs.rm(candidate, { recursive: true }); } - // The marker lands before the directory takes its final name, so a crash in between leaves a - // suffixed, marked directory the next attempt prunes rather than an unmarked one that would - // block this version. The rename refuses a pre-existing link or populated directory and never - // mutates the running installation. - const partial = await fs.mkdtemp(`${dir}.`); + // A fresh, uniquely named directory: a foreign directory sharing the version's name can neither + // block the stage nor be replaced by it (rename would replace an empty one), and the marker + // lands first so a crash at any later point leaves a directory the next attempt prunes. + const dir = await fs.mkdtemp(path.join(parent, `${SERVER_UPDATE_STAGING_PREFIX}${version}.`)); await fs.writeFile( - path.join(partial, SERVER_UPDATE_STAGE_MARKER), + path.join(dir, SERVER_UPDATE_STAGE_MARKER), JSON.stringify({ launcher: layout.launcher }) ); - await fs.rename(partial, dir); await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ private: true })); // Package managers follow redirects, so the release itself is fetched and digest-checked here; // the dependency tree the manager resolves is anchored to the registry afterwards. diff --git a/src/node/services/updateService.test.ts b/src/node/services/updateService.test.ts index ca482f0a366..c6e09a1770c 100644 --- a/src/node/services/updateService.test.ts +++ b/src/node/services/updateService.test.ts @@ -95,4 +95,44 @@ describe("UpdateService channel persistence", () => { expect(setUpdateChannel.mock.calls.map((call) => call[0])).toEqual(["nightly", "stable"]); expect(service.getChannel()).toBe("stable"); }); + + it("serializes concurrent changes so a rollback cannot land after a later switch", async () => { + const { config, setUpdateChannel, getUpdateChannel } = createMockConfig("stable"); + // Config writes complete in order but asynchronously, like the real FIFO editor. + let queue = Promise.resolve(); + setUpdateChannel.mockImplementation((channel) => { + queue = queue.then(() => new Promise((resolve) => setTimeout(resolve, 1))); + return queue.then(() => { + getUpdateChannel.mockReturnValue(channel); + }); + }); + const service = new UpdateService(config); + const channels: UpdateChannel[] = []; + const internal = service as unknown as { + impl: { setChannel(channel: UpdateChannel): void; getChannel(): UpdateChannel }; + currentStatus: { type: string }; + }; + let refusals = 1; + internal.impl = { + setChannel: (channel) => { + if (refusals-- > 0) throw new Error("An update operation is in progress"); + channels.push(channel); + }, + getChannel: () => channels.at(-1) ?? "stable", + }; + internal.currentStatus = { type: "idle" }; + const [first, second] = await Promise.allSettled([ + service.setChannel("nightly"), + service.setChannel("nightly"), + ]); + expect(first.status).toBe("rejected"); + expect(second.status).toBe("fulfilled"); + expect(setUpdateChannel.mock.calls.map((call) => call[0])).toEqual([ + "nightly", + "stable", + "nightly", + ]); + expect(getUpdateChannel()).toBe("nightly"); + expect(service.getChannel()).toBe("nightly"); + }); }); diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts index 641f3363a75..32eb823362a 100644 --- a/src/node/services/updateService.ts +++ b/src/node/services/updateService.ts @@ -29,6 +29,7 @@ export class UpdateService { private currentChannel: UpdateChannel; private subscribers = new Set<(status: UpdateStatus) => void>(); private readonly ready: Promise; + private channelChange: Promise = Promise.resolve(); constructor(private readonly config: Config) { this.currentChannel = this.config.getUpdateChannel(); @@ -129,6 +130,14 @@ export class UpdateService { } async setChannel(channel: UpdateChannel): Promise { + // Persist, switch, and roll back run as one transaction: a second change interleaving with + // them could leave the runtime on one channel and the config on another. + const change = this.channelChange.then(() => this.changeChannel(channel)); + this.channelChange = change.catch(() => undefined); + await change; + } + + private async changeChannel(channel: UpdateChannel): Promise { await this.ready; if (this.impl && this.currentStatus.type === "unsupported") return; // The runtime switch discards a staged update, so persist first: a failed write then costs From 9194e6d18d3277794d9c1bef486faa48f7de6eaf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:27:02 +0000 Subject: [PATCH 20/22] server-update: report Windows as unsupported and force npm optional installs Activation relies on POSIX rename() replacing the launcher symlink and the staged entry check on a POSIX executable bit, so resolveInstallLayout now refuses Windows up front instead of letting every install attempt fail. npm gets --include=optional so an inherited omit cannot drop the optional platform packages; pnpm and bun have no flag that overrides an inherited optional=false, which the installCommand comment records. --- src/node/services/serverUpdate/installLayout.ts | 6 +++++- .../services/serverUpdate/serverUpdate.test.ts | 3 +++ src/node/services/serverUpdate/staging.ts | 16 +++++++++------- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/node/services/serverUpdate/installLayout.ts b/src/node/services/serverUpdate/installLayout.ts index ac1581ec798..89f43fdcb54 100644 --- a/src/node/services/serverUpdate/installLayout.ts +++ b/src/node/services/serverUpdate/installLayout.ts @@ -72,9 +72,13 @@ export function readPackageVersion(packageDir: string): string { export function resolveInstallLayout( env: NodeJS.ProcessEnv, - argv: readonly string[] + argv: readonly string[], + platform: NodeJS.Platform = process.platform ): LayoutResult { try { + // Activation replaces the launcher symlink with rename(), which is atomic only on POSIX, and + // the staged entry is checked for a POSIX executable bit. + if (platform === "win32") throw new Error("Server updates are not supported on Windows"); // coder/mux registry module v1.5 declares its restart loop through RESTART_ON_KILL_VALUE. const supervised = resolveXumEnvironmentValue("SERVER_SUPERVISED", env); if (!(/^(1|true|yes)$/i.test(supervised ?? "") || env.RESTART_ON_KILL_VALUE === "true")) { diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 2d995e8ca8a..5790fa922f3 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -195,6 +195,8 @@ describe("server install layout", () => { test("requires supervisor, a symlink, and a matching running entry", async () => { const { env, argv, layout, root } = await fixture(); expect(resolveInstallLayout({ MUX_BINARY: env.MUX_BINARY }, argv).supported).toBe(false); + expect(resolveInstallLayout(env, argv, "win32").supported).toBe(false); + expect(resolveInstallLayout(env, argv, "linux").supported).toBe(true); expect( resolveInstallLayout({ RESTART_ON_KILL_VALUE: "true" }, ["node", layout.entry]).supported ).toBe(false); @@ -265,6 +267,7 @@ describe("staging and activation", () => { const npmArgs = installCommand({ ...layout, packageManager: "npm" }, "/x.tgz").args; expect(npmArgs).toContain("--strict-ssl"); expect(npmArgs).toContain("--package-lock=true"); + expect(npmArgs).toContain("--include=optional"); const pnpmArgs = installCommand({ ...layout, packageManager: "pnpm" }, "/x.tgz").args; expect(pnpmArgs).toContain("--config.strict-ssl=true"); expect(pnpmArgs).toContain("--config.lockfile=true"); diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index 55830edeacf..a1d2896ebc0 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -25,11 +25,13 @@ export function installCommand( tarball: string ): { file: string; args: string[] } { // CLI flags outrank npmrc files and npm_config_* env, so an inherited strict-ssl=false cannot - // disable certificate validation for the download and an inherited package-lock=false or - // lockfile=false cannot suppress the lockfile that dependency verification reads. bun has no - // such flags; its TLS and lockfile knobs are env variables runInstall strips (a global bunfig - // that disables lockfile saving still makes verification fail closed). The text lockfile is - // required, so an older bun that only writes bun.lockb must fail here. + // disable certificate validation for the download, an inherited package-lock=false or + // lockfile=false cannot suppress the lockfile that dependency verification reads, and npm's + // include beats any inherited omit of the optional platform packages. bun has no such flags; + // its TLS and lockfile knobs are env variables runInstall strips (a global bunfig that disables + // lockfile saving still makes verification fail closed). Neither bun nor pnpm offers a flag + // that overrides an inherited optional=false. The text lockfile is required, so an older bun + // that only writes bun.lockb must fail here. const flags = { bun: ["add", "--ignore-scripts", "--save-text-lockfile"], npm: [ @@ -41,6 +43,7 @@ export function installCommand( "--ignore-scripts", "--strict-ssl", "--package-lock=true", + "--include=optional", ], pnpm: [ "add", @@ -84,8 +87,7 @@ export async function verifyStagedPackage( } finally { await handle.close(); } - if (process.platform !== "win32" && (stat.mode & 0o111) === 0) - throw new Error("Staged CLI entry is not executable"); + if ((stat.mode & 0o111) === 0) throw new Error("Staged CLI entry is not executable"); // Parse-only: nothing from the registry runs until the operator activates it. The shebang's // interpreter is used because process.execPath may be bun, which has no parse-only mode. using smoke = execFileAsync(SERVER_UPDATE_CLI_INTERPRETER, ["--check", entry], { From a25c2f45e2ddf279ffad68cdd132669d89f47b09 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:33:34 +0000 Subject: [PATCH 21/22] server-update: force pnpm to install optional dependencies An inherited optional=false (npm_config_optional or a user .npmrc) makes pnpm omit the optional platform packages while still recording them in pnpm-lock.yaml, so verification cannot notice; --config.optional=true overrides the inherited value. --- src/node/services/serverUpdate/serverUpdate.test.ts | 1 + src/node/services/serverUpdate/staging.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 5790fa922f3..9b5ba755233 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -271,6 +271,7 @@ describe("staging and activation", () => { const pnpmArgs = installCommand({ ...layout, packageManager: "pnpm" }, "/x.tgz").args; expect(pnpmArgs).toContain("--config.strict-ssl=true"); expect(pnpmArgs).toContain("--config.lockfile=true"); + expect(pnpmArgs).toContain("--config.optional=true"); expect(installCommand({ ...layout, packageManager: "bun" }, "/x.tgz").args).toContain( "--save-text-lockfile" ); diff --git a/src/node/services/serverUpdate/staging.ts b/src/node/services/serverUpdate/staging.ts index a1d2896ebc0..1b9d7ddf7bb 100644 --- a/src/node/services/serverUpdate/staging.ts +++ b/src/node/services/serverUpdate/staging.ts @@ -26,12 +26,12 @@ export function installCommand( ): { file: string; args: string[] } { // CLI flags outrank npmrc files and npm_config_* env, so an inherited strict-ssl=false cannot // disable certificate validation for the download, an inherited package-lock=false or - // lockfile=false cannot suppress the lockfile that dependency verification reads, and npm's - // include beats any inherited omit of the optional platform packages. bun has no such flags; - // its TLS and lockfile knobs are env variables runInstall strips (a global bunfig that disables - // lockfile saving still makes verification fail closed). Neither bun nor pnpm offers a flag - // that overrides an inherited optional=false. The text lockfile is required, so an older bun - // that only writes bun.lockb must fail here. + // lockfile=false cannot suppress the lockfile that dependency verification reads, and an + // inherited omit of the optional platform packages loses to npm's include and pnpm's + // optional=true. bun has no such flags; its TLS and lockfile knobs are env variables runInstall + // strips, while a global bunfig that disables lockfile saving still fails closed at + // verification and one that disables optional dependencies has no override. The text lockfile + // is required, so an older bun that only writes bun.lockb must fail here. const flags = { bun: ["add", "--ignore-scripts", "--save-text-lockfile"], npm: [ @@ -51,6 +51,7 @@ export function installCommand( "--ignore-scripts", "--config.strict-ssl=true", "--config.lockfile=true", + "--config.optional=true", ], } satisfies Record; return { From 7afdf6078023f0376d70d2c10363ca937768714c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:21:32 +0000 Subject: [PATCH 22/22] server-update: persist the channel choice on unsupported layouts The palette command reported success while nothing was saved, so an operator who then met the reported requirement restarted on the old channel. The preference is now persisted and recorded; status stays unsupported and nothing else runs. --- .../services/serverUpdate/serverUpdate.test.ts | 4 ++-- .../services/serverUpdate/serverUpdater.ts | 5 ++++- src/node/services/updateService.test.ts | 18 ++++++++++++++++++ src/node/services/updateService.ts | 1 - 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/node/services/serverUpdate/serverUpdate.test.ts b/src/node/services/serverUpdate/serverUpdate.test.ts index 9b5ba755233..90fc04cd536 100644 --- a/src/node/services/serverUpdate/serverUpdate.test.ts +++ b/src/node/services/serverUpdate/serverUpdate.test.ts @@ -637,7 +637,7 @@ describe("staging and activation", () => { }); describe("server updater", () => { - test("unsupported actions have no effects", async () => { + test("unsupported actions have no effects beyond recording the channel preference", async () => { const effect = () => { throw new Error("must not run"); }; @@ -653,7 +653,7 @@ describe("server updater", () => { await updater.installUpdate(); updater.setChannel("nightly"); expect(updater.getStatus().type).toBe("unsupported"); - expect(updater.getChannel()).toBe("stable"); + expect(updater.getChannel()).toBe("nightly"); }); test("selects dist-tags by effective channel, including downgrades, and resets staged updates", async () => { const { layout } = await fixture("bun", "2.0.0-next.1"); diff --git a/src/node/services/serverUpdate/serverUpdater.ts b/src/node/services/serverUpdate/serverUpdater.ts index 7583c09dbbf..b017608683e 100644 --- a/src/node/services/serverUpdate/serverUpdater.ts +++ b/src/node/services/serverUpdate/serverUpdater.ts @@ -70,10 +70,13 @@ export class ServerUpdater { } setChannel(channel: UpdateChannel): void { - if (!this.layout || channel === this.channel) return; + if (channel === this.channel) return; if (this.installing || this.status.type === "checking" || this.status.type === "downloading") throw new Error("An update operation is in progress"); + // An unsupported layout still records the preference so it applies once the operator has + // met the reported requirement and restarted. this.channel = channel; + if (!this.layout) return; this.availableVersion = null; this.staged = null; this.setStatus({ type: "idle" }); diff --git a/src/node/services/updateService.test.ts b/src/node/services/updateService.test.ts index c6e09a1770c..1845b0fd755 100644 --- a/src/node/services/updateService.test.ts +++ b/src/node/services/updateService.test.ts @@ -96,6 +96,24 @@ describe("UpdateService channel persistence", () => { expect(service.getChannel()).toBe("stable"); }); + it("persists the channel while the updater reports an unsupported layout", async () => { + const { config, setUpdateChannel } = createMockConfig("stable"); + const service = new UpdateService(config); + const channels: UpdateChannel[] = []; + const internal = service as unknown as { + impl: { setChannel(channel: UpdateChannel): void; getChannel(): UpdateChannel }; + currentStatus: { type: string }; + }; + internal.impl = { + setChannel: (channel) => channels.push(channel), + getChannel: () => channels.at(-1) ?? "stable", + }; + internal.currentStatus = { type: "unsupported" }; + await service.setChannel("nightly"); + expect(setUpdateChannel).toHaveBeenLastCalledWith("nightly"); + expect(service.getChannel()).toBe("nightly"); + }); + it("serializes concurrent changes so a rollback cannot land after a later switch", async () => { const { config, setUpdateChannel, getUpdateChannel } = createMockConfig("stable"); // Config writes complete in order but asynchronously, like the real FIFO editor. diff --git a/src/node/services/updateService.ts b/src/node/services/updateService.ts index 32eb823362a..dba38e2c859 100644 --- a/src/node/services/updateService.ts +++ b/src/node/services/updateService.ts @@ -139,7 +139,6 @@ export class UpdateService { private async changeChannel(channel: UpdateChannel): Promise { await this.ready; - if (this.impl && this.currentStatus.type === "unsupported") return; // The runtime switch discards a staged update, so persist first: a failed write then costs // nothing, and a runtime refusal (operation in progress) reverts the write so the two never // disagree.