diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a8801a..ef80cd1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Fixed + +- Fixed WebSocket connections not receiving headers from the configured header command + (`coder.headerCommand`), which could cause authentication failures with remote workspaces. + ## [v1.11.2](https://github.com/coder/vscode-coder/releases/tag/v1.11.2) 2025-10-07 ### Changed diff --git a/src/agentMetadataHelper.ts b/src/api/agentMetadataHelper.ts similarity index 91% rename from src/agentMetadataHelper.ts rename to src/api/agentMetadataHelper.ts index 0a976411..4de804ad 100644 --- a/src/agentMetadataHelper.ts +++ b/src/api/agentMetadataHelper.ts @@ -5,8 +5,8 @@ import { type AgentMetadataEvent, AgentMetadataEventSchemaArray, errToStr, -} from "./api/api-helper"; -import { type CoderApi } from "./api/coderApi"; +} from "./api-helper"; +import { type CoderApi } from "./coderApi"; export type AgentMetadataWatcher = { onChange: vscode.EventEmitter["event"]; @@ -19,11 +19,11 @@ export type AgentMetadataWatcher = { * Opens a websocket connection to watch metadata for a given workspace agent. * Emits onChange when metadata updates or an error occurs. */ -export function createAgentMetadataWatcher( +export async function createAgentMetadataWatcher( agentId: WorkspaceAgent["id"], client: CoderApi, -): AgentMetadataWatcher { - const socket = client.watchAgentMetadata(agentId); +): Promise { + const socket = await client.watchAgentMetadata(agentId); let disposed = false; const onChange = new vscode.EventEmitter(); diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 1d523b60..99976ff7 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -67,7 +67,7 @@ export class CoderApi extends Api { return client; } - watchInboxNotifications = ( + watchInboxNotifications = async ( watchTemplates: string[], watchTargets: string[], options?: ClientOptions, @@ -83,14 +83,14 @@ export class CoderApi extends Api { }); }; - watchWorkspace = (workspace: Workspace, options?: ClientOptions) => { + watchWorkspace = async (workspace: Workspace, options?: ClientOptions) => { return this.createWebSocket({ apiRoute: `/api/v2/workspaces/${workspace.id}/watch-ws`, options, }); }; - watchAgentMetadata = ( + watchAgentMetadata = async ( agentId: WorkspaceAgent["id"], options?: ClientOptions, ) => { @@ -100,21 +100,22 @@ export class CoderApi extends Api { }); }; - watchBuildLogsByBuildId = (buildId: string, logs: ProvisionerJobLog[]) => { + watchBuildLogsByBuildId = async ( + buildId: string, + logs: ProvisionerJobLog[], + ) => { const searchParams = new URLSearchParams({ follow: "true" }); if (logs.length) { searchParams.append("after", logs[logs.length - 1].id.toString()); } - const socket = this.createWebSocket({ + return this.createWebSocket({ apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`, searchParams, }); - - return socket; }; - private createWebSocket( + private async createWebSocket( configs: Omit, ) { const baseUrlRaw = this.getAxiosInstance().defaults.baseURL; @@ -127,7 +128,15 @@ export class CoderApi extends Api { coderSessionTokenHeader ] as string | undefined; - const httpAgent = createHttpAgent(vscode.workspace.getConfiguration()); + const headers = await getHeaders( + baseUrlRaw, + getHeaderCommand(vscode.workspace.getConfiguration()), + this.output, + ); + + const httpAgent = await createHttpAgent( + vscode.workspace.getConfiguration(), + ); const webSocket = new OneWayWebSocket({ location: baseUrl, ...configs, @@ -137,6 +146,7 @@ export class CoderApi extends Api { headers: { ...(token ? { [coderSessionTokenHeader]: token } : {}), ...configs.options?.headers, + ...headers, }, ...configs.options, }, @@ -191,7 +201,7 @@ function setupInterceptors( // Configure proxy and TLS. // Note that by default VS Code overrides the agent. To prevent this, set // `http.proxySupport` to `on` or `off`. - const agent = createHttpAgent(vscode.workspace.getConfiguration()); + const agent = await createHttpAgent(vscode.workspace.getConfiguration()); config.httpsAgent = agent; config.httpAgent = agent; config.proxy = false; diff --git a/src/api/utils.ts b/src/api/utils.ts index 91a18885..0f13288e 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,4 +1,4 @@ -import fs from "fs"; +import fs from "fs/promises"; import { ProxyAgent } from "proxy-agent"; import { type WorkspaceConfiguration } from "vscode"; @@ -23,7 +23,9 @@ export function needToken(cfg: WorkspaceConfiguration): boolean { * Create a new HTTP agent based on the current VS Code settings. * Configures proxy, TLS certificates, and security options. */ -export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent { +export async function createHttpAgent( + cfg: WorkspaceConfiguration, +): Promise { const insecure = Boolean(cfg.get("coder.insecure")); const certFile = expandPath( String(cfg.get("coder.tlsCertFile") ?? "").trim(), @@ -32,6 +34,12 @@ export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent { const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim()); const altHost = expandPath(String(cfg.get("coder.tlsAltHost") ?? "").trim()); + const [cert, key, ca] = await Promise.all([ + certFile === "" ? Promise.resolve(undefined) : fs.readFile(certFile), + keyFile === "" ? Promise.resolve(undefined) : fs.readFile(keyFile), + caFile === "" ? Promise.resolve(undefined) : fs.readFile(caFile), + ]); + return new ProxyAgent({ // Called each time a request is made. getProxyForUrl: (url: string) => { @@ -41,9 +49,9 @@ export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent { cfg.get("coder.proxyBypass"), ); }, - cert: certFile === "" ? undefined : fs.readFileSync(certFile), - key: keyFile === "" ? undefined : fs.readFileSync(keyFile), - ca: caFile === "" ? undefined : fs.readFileSync(caFile), + cert, + key, + ca, servername: altHost === "" ? undefined : altHost, // rejectUnauthorized defaults to true, so we need to explicitly set it to // false if we want to allow self-signed certificates. diff --git a/src/api/workspace.ts b/src/api/workspace.ts index c2e20c0c..cb03d9fc 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -95,12 +95,12 @@ export async function waitForBuild( const logs = await client.getWorkspaceBuildLogs(workspace.latest_build.id); logs.forEach((log) => writeEmitter.fire(log.output + "\r\n")); - await new Promise((resolve, reject) => { - const socket = client.watchBuildLogsByBuildId( - workspace.latest_build.id, - logs, - ); + const socket = await client.watchBuildLogsByBuildId( + workspace.latest_build.id, + logs, + ); + await new Promise((resolve, reject) => { socket.addEventListener("message", (data) => { if (data.parseError) { writeEmitter.fire( diff --git a/src/headers.ts b/src/headers.ts index f5f45301..6c69258c 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -24,7 +24,7 @@ export function getHeaderCommand( config.get("coder.headerCommand")?.trim() || process.env.CODER_HEADER_COMMAND?.trim(); - return cmd ? cmd : undefined; + return cmd || undefined; } export function getHeaderArgs(config: WorkspaceConfiguration): string[] { @@ -44,16 +44,13 @@ export function getHeaderArgs(config: WorkspaceConfiguration): string[] { return ["--header-command", escapeSubcommand(command)]; } -// TODO: getHeaders might make more sense to directly implement on Storage -// but it is difficult to test Storage right now since we use vitest instead of -// the standard extension testing framework which would give us access to vscode -// APIs. We should revert the testing framework then consider moving this. - -// getHeaders executes the header command and parses the headers from stdout. -// Both stdout and stderr are logged on error but stderr is otherwise ignored. -// Throws an error if the process exits with non-zero or the JSON is invalid. -// Returns undefined if there is no header command set. No effort is made to -// validate the JSON other than making sure it can be parsed. +/** + * getHeaders executes the header command and parses the headers from stdout. + * Both stdout and stderr are logged on error but stderr is otherwise ignored. + * Throws an error if the process exits with non-zero or the JSON is invalid. + * Returns undefined if there is no header command set. No effort is made to + * validate the JSON other than making sure it can be parsed. + */ export async function getHeaders( url: string | undefined, command: string | undefined, @@ -90,8 +87,8 @@ export async function getHeaders( return headers; } const lines = result.stdout.replace(/\r?\n$/, "").split(/\r?\n/); - for (let i = 0; i < lines.length; ++i) { - const [key, value] = lines[i].split(/=(.*)/); + for (const line of lines) { + const [key, value] = line.split(/=(.*)/); // Header names cannot be blank or contain whitespace and the Coder CLI // requires that there be an equals sign (the value can be blank though). if ( @@ -100,7 +97,7 @@ export async function getHeaders( typeof value === "undefined" ) { throw new Error( - `Malformed line from header command: [${lines[i]}] (out: ${result.stdout})`, + `Malformed line from header command: [${line}] (out: ${result.stdout})`, ); } headers[key] = value; diff --git a/src/inbox.ts b/src/inbox.ts index 61a780bb..8dff573f 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -16,12 +16,21 @@ const TEMPLATE_WORKSPACE_OUT_OF_MEMORY = "a9d027b4-ac49-4fb1-9f6d-45af15f64e7a"; const TEMPLATE_WORKSPACE_OUT_OF_DISK = "f047f6a3-5713-40f7-85aa-0394cce9fa3a"; export class Inbox implements vscode.Disposable { - readonly #logger: Logger; - #disposed = false; - #socket: OneWayWebSocket; + private socket: OneWayWebSocket | undefined; + private disposed = false; - constructor(workspace: Workspace, client: CoderApi, logger: Logger) { - this.#logger = logger; + private constructor(private readonly logger: Logger) {} + + /** + * Factory method to create and initialize an Inbox. + * Use this instead of the constructor to properly handle async websocket initialization. + */ + static async create( + workspace: Workspace, + client: CoderApi, + logger: Logger, + ): Promise { + const inbox = new Inbox(logger); const watchTemplates = [ TEMPLATE_WORKSPACE_OUT_OF_DISK, @@ -30,33 +39,40 @@ export class Inbox implements vscode.Disposable { const watchTargets = [workspace.id]; - this.#socket = client.watchInboxNotifications(watchTemplates, watchTargets); + const socket = await client.watchInboxNotifications( + watchTemplates, + watchTargets, + ); - this.#socket.addEventListener("open", () => { - this.#logger.info("Listening to Coder Inbox"); + socket.addEventListener("open", () => { + logger.info("Listening to Coder Inbox"); }); - this.#socket.addEventListener("error", () => { + socket.addEventListener("error", () => { // Errors are already logged internally - this.dispose(); + inbox.dispose(); }); - this.#socket.addEventListener("message", (data) => { + socket.addEventListener("message", (data) => { if (data.parseError) { - this.#logger.error("Failed to parse inbox message", data.parseError); + logger.error("Failed to parse inbox message", data.parseError); } else { vscode.window.showInformationMessage( data.parsedMessage.notification.title, ); } }); + + inbox.socket = socket; + + return inbox; } dispose() { - if (!this.#disposed) { - this.#logger.info("No longer listening to Coder Inbox"); - this.#socket.close(); - this.#disposed = true; + if (!this.disposed) { + this.logger.info("No longer listening to Coder Inbox"); + this.socket?.close(); + this.disposed = true; } } } diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 832a8086..97cb858e 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -18,7 +18,7 @@ import { getEventValue, formatEventLabel, formatMetadataError, -} from "../agentMetadataHelper"; +} from "../api/agentMetadataHelper"; import { createWorkspaceIdentifier, extractAgents } from "../api/api-helper"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; @@ -135,9 +135,7 @@ export class Remote { let attempts = 0; function initWriteEmitterAndTerminal(): vscode.EventEmitter { - if (!writeEmitter) { - writeEmitter = new vscode.EventEmitter(); - } + writeEmitter ??= new vscode.EventEmitter(); if (!terminal) { terminal = vscode.window.createTerminal({ name: "Build Log", @@ -295,16 +293,14 @@ export class Remote { if (result.type === "login") { return this.setup(remoteAuthority, firstConnect); + } else if (!result.userChoice) { + // User declined to log in. + await this.closeRemote(); + return; } else { - if (!result.userChoice) { - // User declined to log in. - await this.closeRemote(); - return; - } else { - // Log in then try again. - await this.commands.login({ url: baseUrlRaw, label: parts.label }); - return this.setup(remoteAuthority, firstConnect); - } + // Log in then try again. + await this.commands.login({ url: baseUrlRaw, label: parts.label }); + return this.setup(remoteAuthority, firstConnect); } }; @@ -543,7 +539,7 @@ export class Remote { } // Watch the workspace for changes. - const monitor = new WorkspaceMonitor( + const monitor = await WorkspaceMonitor.create( workspace, workspaceClient, this.logger, @@ -556,7 +552,7 @@ export class Remote { ); // Watch coder inbox for messages - const inbox = new Inbox(workspace, workspaceClient, this.logger); + const inbox = await Inbox.create(workspace, workspaceClient, this.logger); disposables.push(inbox); // Wait for the agent to connect. @@ -668,7 +664,7 @@ export class Remote { agent.name, ); }), - ...this.createAgentMetadataStatusBar(agent, workspaceClient), + ...(await this.createAgentMetadataStatusBar(agent, workspaceClient)), ); } catch (ex) { // Whatever error happens, make sure we clean up the disposables in case of failure @@ -858,8 +854,7 @@ export class Remote { "UserKnownHostsFile", "StrictHostKeyChecking", ]; - for (let i = 0; i < keysToMatch.length; i++) { - const key = keysToMatch[i]; + for (const key of keysToMatch) { if (computedProperties[key] === sshValues[key]) { continue; } @@ -1005,7 +1000,7 @@ export class Remote { // this to find the SSH process that is powering this connection. That SSH // process will be logging network information periodically to a file. const text = await fs.readFile(logPath, "utf8"); - const port = await findPort(text); + const port = findPort(text); if (!port) { return; } @@ -1064,16 +1059,16 @@ export class Remote { * The status bar item updates dynamically based on changes to the agent's metadata, * and hides itself if no metadata is available or an error occurs. */ - private createAgentMetadataStatusBar( + private async createAgentMetadataStatusBar( agent: WorkspaceAgent, client: CoderApi, - ): vscode.Disposable[] { + ): Promise { const statusBarItem = vscode.window.createStatusBarItem( "agentMetadata", vscode.StatusBarAlignment.Left, ); - const agentWatcher = createAgentMetadataWatcher(agent.id, client); + const agentWatcher = await createAgentMetadataWatcher(agent.id, client); const onChangeDisposable = agentWatcher.onChange(() => { if (agentWatcher.error) { diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 0b154f75..a761249a 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -17,12 +17,12 @@ import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; * workspace status is also shown in the status bar menu. */ export class WorkspaceMonitor implements vscode.Disposable { - private socket: OneWayWebSocket; + private socket: OneWayWebSocket | undefined; private disposed = false; // How soon in advance to notify about autostop and deletion. - private autostopNotifyTime = 1000 * 60 * 30; // 30 minutes. - private deletionNotifyTime = 1000 * 60 * 60 * 24; // 24 hours. + private readonly autostopNotifyTime = 1000 * 60 * 30; // 30 minutes. + private readonly deletionNotifyTime = 1000 * 60 * 60 * 24; // 24 hours. // Only notify once. private notifiedAutostop = false; @@ -36,7 +36,7 @@ export class WorkspaceMonitor implements vscode.Disposable { // For logging. private readonly name: string; - constructor( + private constructor( workspace: Workspace, private readonly client: CoderApi, private readonly logger: Logger, @@ -45,43 +45,67 @@ export class WorkspaceMonitor implements vscode.Disposable { private readonly contextManager: ContextManager, ) { this.name = createWorkspaceIdentifier(workspace); - const socket = this.client.watchWorkspace(workspace); + + const statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 999, + ); + statusBarItem.name = "Coder Workspace Update"; + statusBarItem.text = "$(fold-up) Update Workspace"; + statusBarItem.command = "coder.workspace.update"; + + // Store so we can update when the workspace data updates. + this.statusBarItem = statusBarItem; + + this.update(workspace); // Set initial state. + } + + /** + * Factory method to create and initialize a WorkspaceMonitor. + * Use this instead of the constructor to properly handle async websocket initialization. + */ + static async create( + workspace: Workspace, + client: CoderApi, + logger: Logger, + vscodeProposed: typeof vscode, + contextManager: ContextManager, + ): Promise { + const monitor = new WorkspaceMonitor( + workspace, + client, + logger, + vscodeProposed, + contextManager, + ); + + // Initialize websocket connection + const socket = await client.watchWorkspace(workspace); socket.addEventListener("open", () => { - this.logger.info(`Monitoring ${this.name}...`); + logger.info(`Monitoring ${monitor.name}...`); }); socket.addEventListener("message", (event) => { try { if (event.parseError) { - this.notifyError(event.parseError); + monitor.notifyError(event.parseError); return; } // Perhaps we need to parse this and validate it. const newWorkspaceData = event.parsedMessage.data as Workspace; - this.update(newWorkspaceData); - this.maybeNotify(newWorkspaceData); - this.onChange.fire(newWorkspaceData); + monitor.update(newWorkspaceData); + monitor.maybeNotify(newWorkspaceData); + monitor.onChange.fire(newWorkspaceData); } catch (error) { - this.notifyError(error); + monitor.notifyError(error); } }); // Store so we can close in dispose(). - this.socket = socket; - - const statusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 999, - ); - statusBarItem.name = "Coder Workspace Update"; - statusBarItem.text = "$(fold-up) Update Workspace"; - statusBarItem.command = "coder.workspace.update"; + monitor.socket = socket; - // Store so we can update when the workspace data updates. - this.statusBarItem = statusBarItem; - - this.update(workspace); // Set initial state. + return monitor; } /** @@ -91,7 +115,7 @@ export class WorkspaceMonitor implements vscode.Disposable { if (!this.disposed) { this.logger.info(`Unmonitoring ${this.name}...`); this.statusBarItem.dispose(); - this.socket.close(); + this.socket?.close(); this.disposed = true; } } diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index b83e4f84..2dffec13 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -11,7 +11,7 @@ import { createAgentMetadataWatcher, formatEventLabel, formatMetadataError, -} from "../agentMetadataHelper"; +} from "../api/agentMetadataHelper"; import { type AgentMetadataEvent, extractAgents, @@ -38,8 +38,10 @@ export class WorkspaceProvider { // Undefined if we have never fetched workspaces before. private workspaces: WorkspaceTreeItem[] | undefined; - private agentWatchers: Map = - new Map(); + private readonly agentWatchers: Map< + WorkspaceAgent["id"], + AgentMetadataWatcher + > = new Map(); private timeout: NodeJS.Timeout | undefined; private fetching = false; private visible = false; @@ -130,14 +132,17 @@ export class WorkspaceProvider const showMetadata = this.getWorkspacesQuery === WorkspaceQuery.Mine; if (showMetadata) { const agents = extractAllAgents(resp.workspaces); - agents.forEach((agent) => { + agents.forEach(async (agent) => { // If we have an existing watcher, re-use it. const oldWatcher = this.agentWatchers.get(agent.id); if (oldWatcher) { reusedWatcherIds.push(agent.id); } else { // Otherwise create a new watcher. - const watcher = createAgentMetadataWatcher(agent.id, this.client); + const watcher = await createAgentMetadataWatcher( + agent.id, + this.client, + ); watcher.onChange(() => this.refresh()); this.agentWatchers.set(agent.id, watcher); }