Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 17 additions & 8 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -573,7 +584,7 @@ export class Commands {
* Must only be called if currently logged in.
*/
public async createWorkspace(): Promise<void> {
const baseUrl = this.requireExtensionBaseUrl();
const baseUrl = resolveBrowserUrl(this.requireExtensionBaseUrl());
const uri = baseUrl + "/templates";
await vscode.commands.executeCommand("vscode.open", uri);
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion src/login/loginCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`),
);
Comment on lines +365 to +367
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply alternative URL to OAuth login

This only switches the legacy token page to coder.alternativeWebUrl; when coder.experimental.oauth is enabled and the user chooses OAuth, loginWithOAuth still goes through OAuthAuthorizer.startAuthorization, which opens the discovered authorization URL directly. In deployments where the connection URL uses a browser-restricted/unreachable port and the web UI is available via the alternative URL, OAuth login still opens the restricted connection URL and cannot complete, while token login works.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't touch the OAuth path deliberately, but I think the case highlighted here is worth addressing so we have consistency. Will submit a fix shortly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed!


// 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
Expand Down
15 changes: 15 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveUiUrl makes more sense to me and fits the setting better coder.alternativeWebUrl

const alt = vscode.workspace
.getConfiguration("coder")
.get<string>("alternativeWebUrl")
?.trim()
.replace(/\/+$/, "");
return alt || connectionUrl;
}

export function escapeCommandArg(arg: string): string {
const escapedString = arg.replaceAll('"', String.raw`\"`);
return `"${escapedString}"`;
Expand Down
8 changes: 7 additions & 1 deletion src/webviews/chat/chatPanelProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {

import { type CoderApi } from "../../api/coderApi";
import { type Logger } from "../../logging/logger";
import { resolveBrowserUrl } from "../../util";
import {
dispatchCommand,
dispatchRequest,
Expand Down Expand Up @@ -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,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve path prefixes in chat navigation

When coder.alternativeWebUrl contains a path prefix (for example a reverse proxy at https://proxy.example.com/coder), constructing new URL(resolved.pathname..., browserBase) drops that prefix because the first argument starts with /; a chat navigation to /templates opens https://proxy.example.com/templates instead of https://proxy.example.com/coder/templates. Other browser links concatenate onto the resolved base and preserve such prefixes, so this makes chat links fail only for path-based alternative web URLs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed!

void vscode.env.openExternal(vscode.Uri.parse(browserUrl.toString()));
}
} catch {
this.logger.warn(`Chat: invalid navigate URL: ${url}`);
Expand Down
11 changes: 7 additions & 4 deletions src/webviews/tasks/tasksPanelProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -308,19 +309,21 @@ export class TasksPanelProvider
}

private async handleViewInCoder(taskId: string): Promise<void> {
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}`),
);
}

private async handleViewLogs(taskId: string): Promise<void> {
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)));
Comment on lines +326 to 328
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of always doing resolveXUrl then vscode.open or vscode.env.openExternal, how about we just add openInBrowser(connectionUrl, path) helper

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could even do it in a safer way like vscode.Uri.joinPath + Uri.with({ query, fragment }) so that we do not have to use some regex magic and concatenation

}
Expand Down
63 changes: 63 additions & 0 deletions test/unit/util.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,6 +10,7 @@ import {
findPort,
parseRemoteAuthority,
renameWithRetry,
resolveBrowserUrl,
tempFilePath,
toSafeHost,
} from "@/util";
Expand Down Expand Up @@ -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",
);
});
});
});
24 changes: 24 additions & 0 deletions test/unit/webviews/chat/chatPanelProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ describe("ChatPanelProvider", () => {
beforeEach(() => {
vi.resetAllMocks();
windowMock.__setActiveColorThemeKind(vscode.ColorThemeKind.Dark);

vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
get: vi.fn(),
Comment on lines +91 to +92
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do use MockConfigurationProvider instead of mocking this directly (here and in tasksPanelProvider.test.ts

has: vi.fn().mockReturnValue(false),
inspect: vi.fn(),
update: vi.fn().mockResolvedValue(undefined),
} as unknown as vscode.WorkspaceConfiguration);
});

describe("theme sync", () => {
Expand Down Expand Up @@ -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();

Expand Down
47 changes: 47 additions & 0 deletions test/unit/webviews/tasks/tasksPanelProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down