From e9e8beed1990881fed77406af5e7c02d4a03fd90 Mon Sep 17 00:00:00 2001 From: Itay Dafna Date: Fri, 15 May 2026 10:55:56 -0700 Subject: [PATCH 1/3] Add `alternativeWebUrl` param --- package.json | 5 ++ src/commands.ts | 25 +++++--- src/login/loginCoordinator.ts | 5 +- src/util.ts | 15 +++++ src/webviews/chat/chatPanelProvider.ts | 8 ++- src/webviews/tasks/tasksPanelProvider.ts | 11 ++-- test/unit/util.test.ts | 63 +++++++++++++++++++ .../webviews/chat/chatPanelProvider.test.ts | 24 +++++++ .../webviews/tasks/tasksPanelProvider.test.ts | 47 ++++++++++++++ 9 files changed, 189 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 1e65216691..e3dc096ad1 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,11 @@ "type": "string", "default": "" }, + "coder.alternativeWebUrl": { + "markdownDescription": "An alternative URL to use when opening Coder pages in the browser. When set, this replaces the connection URL for browser links only (dashboard, workspace pages, token authentication page). The connection URL is still used for API calls, SSH, and CLI operations. Useful when the Coder API runs on a port that browsers restrict (e.g., 7004) but the web UI is accessible on a standard port (e.g., 443).", + "type": "string", + "default": "" + }, "coder.autologin": { "markdownDescription": "Automatically log into the default URL when the extension is activated. coder.defaultUrl is preferred, otherwise the CODER_URL environment variable will be used. This setting has no effect if neither is set.", "type": "boolean", diff --git a/src/commands.ts b/src/commands.ts index 1f48d36a40..a16406a599 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -26,7 +26,7 @@ import { applySettingOverrides, } from "./remote/sshOverrides"; import { resolveCliAuth } from "./settings/cli"; -import { toRemoteAuthority, toSafeHost } from "./util"; +import { resolveBrowserUrl, toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; import { parseSpeedtestResult } from "./webviews/speedtest/types"; import { @@ -118,6 +118,17 @@ export class Commands { return url; } + /** + * Get the remote workspace deployment URL, throwing if not connected. + */ + private requireRemoteBaseUrl(): string { + const url = this.remoteWorkspaceClient?.getAxiosInstance().defaults.baseURL; + if (!url) { + throw new Error("No remote workspace connection"); + } + return url; + } + /** * Log into a deployment. If already authenticated, this is a no-op. * If no URL is provided, shows a menu of recent URLs plus defaults. @@ -573,7 +584,7 @@ export class Commands { * Must only be called if currently logged in. */ public async createWorkspace(): Promise { - const baseUrl = this.requireExtensionBaseUrl(); + const baseUrl = resolveBrowserUrl(this.requireExtensionBaseUrl()); const uri = baseUrl + "/templates"; await vscode.commands.executeCommand("vscode.open", uri); } @@ -588,13 +599,12 @@ export class Commands { */ public async navigateToWorkspace(item?: OpenableTreeItem) { if (item) { - const baseUrl = this.requireExtensionBaseUrl(); + const baseUrl = resolveBrowserUrl(this.requireExtensionBaseUrl()); const workspaceId = createWorkspaceIdentifier(item.workspace); const uri = baseUrl + `/@${workspaceId}`; await vscode.commands.executeCommand("vscode.open", uri); } else if (this.workspace && this.remoteWorkspaceClient) { - const baseUrl = - this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL; + const baseUrl = resolveBrowserUrl(this.requireRemoteBaseUrl()); const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}`; await vscode.commands.executeCommand("vscode.open", uri); } else { @@ -612,13 +622,12 @@ export class Commands { */ public async navigateToWorkspaceSettings(item?: OpenableTreeItem) { if (item) { - const baseUrl = this.requireExtensionBaseUrl(); + const baseUrl = resolveBrowserUrl(this.requireExtensionBaseUrl()); const workspaceId = createWorkspaceIdentifier(item.workspace); const uri = baseUrl + `/@${workspaceId}/settings`; await vscode.commands.executeCommand("vscode.open", uri); } else if (this.workspace && this.remoteWorkspaceClient) { - const baseUrl = - this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL; + const baseUrl = resolveBrowserUrl(this.requireRemoteBaseUrl()); const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}/settings`; await vscode.commands.executeCommand("vscode.open", uri); } else { diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index 018d9558ee..1457d1fda3 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -10,6 +10,7 @@ import { buildOAuthTokenData } from "../oauth/utils"; import { withOptionalProgress } from "../progress"; import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils"; import { isKeyringEnabled } from "../settings/cli"; +import { resolveBrowserUrl } from "../util"; import { vscodeProposed } from "../vscodeProposed"; import type { User } from "coder/site/src/api/typesGenerated"; @@ -361,7 +362,9 @@ export class LoginCoordinator implements vscode.Disposable { } // This prompt is for convenience; do not error if they close it since // they may already have a token or already have the page opened. - await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`)); + await vscode.env.openExternal( + vscode.Uri.parse(`${resolveBrowserUrl(url)}/cli-auth`), + ); // For token auth, start with the existing token in the prompt or the last // used token. Once submitted, if there is a failure we will keep asking diff --git a/src/util.ts b/src/util.ts index 9b3ec7ff30..1348ee711e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,6 @@ import * as os from "node:os"; import url from "node:url"; +import * as vscode from "vscode"; export interface AuthorityParts { agent: string | undefined; @@ -202,6 +203,20 @@ export async function renameWithRetry( } } +/** + * Return the URL for opening Coder pages in the browser. Uses the + * `coder.alternativeWebUrl` setting when configured, otherwise returns + * the connection URL unchanged. + */ +export function resolveBrowserUrl(connectionUrl: string): string { + const alt = vscode.workspace + .getConfiguration("coder") + .get("alternativeWebUrl") + ?.trim() + .replace(/\/+$/, ""); + return alt || connectionUrl; +} + export function escapeCommandArg(arg: string): string { const escapedString = arg.replaceAll('"', String.raw`\"`); return `"${escapedString}"`; diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 00bdb81d24..78f9f03404 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -8,6 +8,7 @@ import { import { type CoderApi } from "../../api/coderApi"; import { type Logger } from "../../logging/logger"; +import { resolveBrowserUrl } from "../../util"; import { dispatchCommand, dispatchRequest, @@ -154,7 +155,12 @@ export class ChatPanelProvider const resolved = new URL(url, coderUrl); const expected = new URL(coderUrl); if (resolved.origin === expected.origin) { - void vscode.env.openExternal(vscode.Uri.parse(resolved.toString())); + const browserBase = resolveBrowserUrl(coderUrl); + const browserUrl = new URL( + resolved.pathname + resolved.search + resolved.hash, + browserBase, + ); + void vscode.env.openExternal(vscode.Uri.parse(browserUrl.toString())); } } catch { this.logger.warn(`Chat: invalid navigate URL: ${url}`); diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts index 46614665f6..cfab63860d 100644 --- a/src/webviews/tasks/tasksPanelProvider.ts +++ b/src/webviews/tasks/tasksPanelProvider.ts @@ -25,6 +25,7 @@ import { streamBuildLogs, } from "../../api/workspace"; import { type Logger } from "../../logging/logger"; +import { resolveBrowserUrl } from "../../util"; import { vscodeProposed } from "../../vscodeProposed"; import { dispatchCommand, @@ -308,9 +309,10 @@ export class TasksPanelProvider } private async handleViewInCoder(taskId: string): Promise { - const baseUrl = this.client.getHost(); - if (!baseUrl) return; + const connUrl = this.client.getHost(); + if (!connUrl) return; + const baseUrl = resolveBrowserUrl(connUrl); const task = await this.client.getTask("me", taskId); vscode.env.openExternal( vscode.Uri.parse(`${baseUrl}/tasks/${task.owner_name}/${task.id}`), @@ -318,9 +320,10 @@ export class TasksPanelProvider } private async handleViewLogs(taskId: string): Promise { - const baseUrl = this.client.getHost(); - if (!baseUrl) return; + const connUrl = this.client.getHost(); + if (!connUrl) return; + const baseUrl = resolveBrowserUrl(connUrl); const task = await this.client.getTask("me", taskId); vscode.env.openExternal(vscode.Uri.parse(getTaskBuildUrl(baseUrl, task))); } diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index e8ef1f0f37..7ac6effd92 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -1,5 +1,6 @@ import os from "node:os"; import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import * as vscode from "vscode"; import { type AuthorityParts, @@ -9,6 +10,7 @@ import { findPort, parseRemoteAuthority, renameWithRetry, + resolveBrowserUrl, tempFilePath, toSafeHost, } from "@/util"; @@ -389,4 +391,65 @@ describe("renameWithRetry", () => { }, ); }); + + describe("resolveBrowserUrl", () => { + function mockAlternativeWebUrl(value: string | undefined): void { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(value), + } as unknown as vscode.WorkspaceConfiguration); + } + + afterEach(() => { + vi.mocked(vscode.workspace.getConfiguration).mockReset(); + }); + + it("returns connection URL when setting is not configured", () => { + mockAlternativeWebUrl(undefined); + expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com:7004", + ); + }); + + it("returns connection URL when setting is empty", () => { + mockAlternativeWebUrl(""); + expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com:7004", + ); + }); + + it("returns connection URL when setting is whitespace", () => { + mockAlternativeWebUrl(" "); + expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com:7004", + ); + }); + + it("returns alternative URL when configured", () => { + mockAlternativeWebUrl("https://coder.example.com"); + expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com", + ); + }); + + it("strips trailing slashes from alternative URL", () => { + mockAlternativeWebUrl("https://coder.example.com/"); + expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com", + ); + }); + + it("strips multiple trailing slashes from alternative URL", () => { + mockAlternativeWebUrl("https://coder.example.com///"); + expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com", + ); + }); + + it("trims whitespace from alternative URL", () => { + mockAlternativeWebUrl(" https://coder.example.com "); + expect(resolveBrowserUrl("https://coder.example.com:7004")).toBe( + "https://coder.example.com", + ); + }); + }); }); diff --git a/test/unit/webviews/chat/chatPanelProvider.test.ts b/test/unit/webviews/chat/chatPanelProvider.test.ts index 586ee24a79..6026d86b66 100644 --- a/test/unit/webviews/chat/chatPanelProvider.test.ts +++ b/test/unit/webviews/chat/chatPanelProvider.test.ts @@ -87,6 +87,13 @@ describe("ChatPanelProvider", () => { beforeEach(() => { vi.resetAllMocks(); windowMock.__setActiveColorThemeKind(vscode.ColorThemeKind.Dark); + + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn(), + has: vi.fn().mockReturnValue(false), + inspect: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.WorkspaceConfiguration); }); describe("theme sync", () => { @@ -171,6 +178,23 @@ describe("ChatPanelProvider", () => { ); }); + it("uses alternative web URL for navigation when configured", () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue("https://web.example.com"), + } as unknown as vscode.WorkspaceConfiguration); + + const { sendFromWebview } = createHarness(); + + sendFromWebview({ + method: "coder:navigate", + params: { url: "/templates" }, + }); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://web.example.com/templates"), + ); + }); + it("ignores navigate without url payload", () => { const { sendFromWebview } = createHarness(); diff --git a/test/unit/webviews/tasks/tasksPanelProvider.test.ts b/test/unit/webviews/tasks/tasksPanelProvider.test.ts index 99526d2af7..2103c0287e 100644 --- a/test/unit/webviews/tasks/tasksPanelProvider.test.ts +++ b/test/unit/webviews/tasks/tasksPanelProvider.test.ts @@ -203,6 +203,13 @@ describe("TasksPanelProvider", () => { beforeEach(() => { // Reset shared vscode mocks between tests vi.resetAllMocks(); + + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn(), + has: vi.fn().mockReturnValue(false), + inspect: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.WorkspaceConfiguration); }); describe("getTasks", () => { @@ -678,6 +685,46 @@ describe("TasksPanelProvider", () => { expect(vscode.env.openExternal).not.toHaveBeenCalled(); }); + + it("viewInCoder uses alternative web URL when configured", async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue("https://coder.example.com:443"), + } as unknown as vscode.WorkspaceConfiguration); + + const h = createHarness(); + h.client.getTask.mockResolvedValue( + task({ id: "task-1", owner_name: "alice" }), + ); + + await h.command(TasksApi.viewInCoder, { taskId: "task-1" }); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://coder.example.com:443/tasks/alice/task-1"), + ); + }); + + it("viewLogs uses alternative web URL when configured", async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue("https://coder.example.com:443"), + } as unknown as vscode.WorkspaceConfiguration); + + const h = createHarness(); + h.client.getTask.mockResolvedValue( + task({ + owner_name: "alice", + workspace_name: "my-ws", + workspace_build_number: 42, + }), + ); + + await h.command(TasksApi.viewLogs, { taskId: "task-1" }); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse( + "https://coder.example.com:443/@alice/my-ws/builds/42", + ), + ); + }); }); describe("downloadLogs", () => { From 2e2a970eb7c43540a1cd203cbf52a208d21301da Mon Sep 17 00:00:00 2001 From: Itay Dafna Date: Fri, 15 May 2026 11:11:41 -0700 Subject: [PATCH 2/3] Codex feedback: preserve path prefix in alt web URL --- src/webviews/chat/chatPanelProvider.ts | 10 +++++----- .../webviews/chat/chatPanelProvider.test.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/webviews/chat/chatPanelProvider.ts b/src/webviews/chat/chatPanelProvider.ts index 78f9f03404..55f482c01c 100644 --- a/src/webviews/chat/chatPanelProvider.ts +++ b/src/webviews/chat/chatPanelProvider.ts @@ -156,11 +156,11 @@ export class ChatPanelProvider const expected = new URL(coderUrl); if (resolved.origin === expected.origin) { const browserBase = resolveBrowserUrl(coderUrl); - const browserUrl = new URL( - resolved.pathname + resolved.search + resolved.hash, - browserBase, - ); - void vscode.env.openExternal(vscode.Uri.parse(browserUrl.toString())); + // Concatenate rather than `new URL(path, base)` so a path prefix on + // the alternative URL (e.g. a reverse proxy at https://host/coder) + // is preserved. + const browserUrl = `${browserBase}${resolved.pathname}${resolved.search}${resolved.hash}`; + void vscode.env.openExternal(vscode.Uri.parse(browserUrl)); } } catch { this.logger.warn(`Chat: invalid navigate URL: ${url}`); diff --git a/test/unit/webviews/chat/chatPanelProvider.test.ts b/test/unit/webviews/chat/chatPanelProvider.test.ts index 6026d86b66..ff107ef181 100644 --- a/test/unit/webviews/chat/chatPanelProvider.test.ts +++ b/test/unit/webviews/chat/chatPanelProvider.test.ts @@ -195,6 +195,23 @@ describe("ChatPanelProvider", () => { ); }); + it("preserves path prefix in alternative web URL", () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue("https://proxy.example.com/coder"), + } as unknown as vscode.WorkspaceConfiguration); + + const { sendFromWebview } = createHarness(); + + sendFromWebview({ + method: "coder:navigate", + params: { url: "/templates" }, + }); + + expect(vscode.env.openExternal).toHaveBeenCalledWith( + vscode.Uri.parse("https://proxy.example.com/coder/templates"), + ); + }); + it("ignores navigate without url payload", () => { const { sendFromWebview } = createHarness(); From 5f3441095c7efb024a4f62593a8b413d4e1b9865 Mon Sep 17 00:00:00 2001 From: Itay Dafna Date: Fri, 15 May 2026 11:45:56 -0700 Subject: [PATCH 3/3] Codex review: apply `alternativeWebUrl` to OAuth base authorization URL --- package.json | 10 +++++----- src/oauth/authorizer.ts | 9 ++++++++- test/unit/oauth/authorizer.test.ts | 28 ++++++++++++++++++++++++++++ test/unit/oauth/testUtils.ts | 3 ++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index e3dc096ad1..e2910d16ab 100644 --- a/package.json +++ b/package.json @@ -121,11 +121,11 @@ "type": "string", "default": "" }, - "coder.alternativeWebUrl": { - "markdownDescription": "An alternative URL to use when opening Coder pages in the browser. When set, this replaces the connection URL for browser links only (dashboard, workspace pages, token authentication page). The connection URL is still used for API calls, SSH, and CLI operations. Useful when the Coder API runs on a port that browsers restrict (e.g., 7004) but the web UI is accessible on a standard port (e.g., 443).", - "type": "string", - "default": "" - }, + "coder.alternativeWebUrl": { + "markdownDescription": "An alternative URL to use when opening Coder pages in the browser. When set, this replaces the connection URL for browser links only (dashboard, workspace pages, token authentication page, OAuth authorization page). The connection URL is still used for API calls, SSH, and CLI operations. Useful when the Coder API runs on a port that browsers restrict (e.g., 7004) but the web UI is accessible on a standard port (e.g., 443).", + "type": "string", + "default": "" + }, "coder.autologin": { "markdownDescription": "Automatically log into the default URL when the extension is activated. coder.defaultUrl is preferred, otherwise the CODER_URL environment variable will be used. This setting has no effect if neither is set.", "type": "boolean", diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index 7280e74202..8823699ead 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; +import { resolveBrowserUrl } from "../util"; import { AUTH_GRANT_TYPE, @@ -201,7 +202,13 @@ export class OAuthAuthorizer implements vscode.Disposable { code_challenge_method: PKCE_CHALLENGE_METHOD, }); - const url = `${metadata.authorization_endpoint}?${params.toString()}`; + // The server-advertised endpoint is authoritative for the path, but the + // origin may be unreachable from a browser (e.g. blocked port). When + // `coder.alternativeWebUrl` is set, swap the origin so the user lands on + // a reachable host while preserving the path the server told us to use. + const endpoint = new URL(metadata.authorization_endpoint); + const browserBase = resolveBrowserUrl(endpoint.origin); + const url = `${browserBase}${endpoint.pathname}?${params.toString()}`; this.logger.debug("Built OAuth authorization URL:", { client_id: clientId, diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index 55560d84e9..fc0ad4aaf2 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -260,6 +260,34 @@ describe("OAuthAuthorizer", () => { "fetching user...", ]); }); + + it("rewrites authorization endpoint origin when alternativeWebUrl is set", async () => { + const { + mockAdapter, + configurationProvider, + startLogin, + completeLogin, + } = createTestContext(); + configurationProvider.set( + "coder.alternativeWebUrl", + "https://web.example.com", + ); + setupAxiosMockRoutes(mockAdapter, { + "/.well-known/oauth-authorization-server": createMockOAuthMetadata( + "https://coder.example.com:7004", + ), + "/oauth2/register": createMockClientRegistration(), + "/oauth2/token": createMockTokenResponse(), + "/api/v2/users/me": { username: "test-user" }, + }); + + const { loginPromise, authUrl, state } = await startLogin(); + expect(authUrl.origin).toBe("https://web.example.com"); + expect(authUrl.pathname).toBe("/oauth2/authorize"); + + await completeLogin(state); + await loginPromise; + }); }); describe("callback handling", () => { diff --git a/test/unit/oauth/testUtils.ts b/test/unit/oauth/testUtils.ts index 714c581197..ce4736122e 100644 --- a/test/unit/oauth/testUtils.ts +++ b/test/unit/oauth/testUtils.ts @@ -123,7 +123,7 @@ export function createBaseTestContext() { vi.mocked(getHeaders).mockResolvedValue({}); // Constructor sets up vscode.workspace mock - const _configurationProvider = new MockConfigurationProvider(); + const configurationProvider = new MockConfigurationProvider(); const secretStorage = new InMemorySecretStorage(); const memento = new InMemoryMemento(); @@ -148,5 +148,6 @@ export function createBaseTestContext() { oauthCallback, logger, setupOAuthRoutes, + configurationProvider, }; }