Skip to content

Commit a401805

Browse files
authored
Fixed WebSocket connections not receiving headers from the configured header command (coder#619)
Closes coder#618
1 parent 2cd05a3 commit a401805

File tree

10 files changed

+167
-107
lines changed

10 files changed

+167
-107
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
### Fixed
6+
7+
- Fixed WebSocket connections not receiving headers from the configured header command
8+
(`coder.headerCommand`), which could cause authentication failures with remote workspaces.
9+
510
## [v1.11.2](https://github.com/coder/vscode-coder/releases/tag/v1.11.2) 2025-10-07
611

712
### Changed
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
type AgentMetadataEvent,
66
AgentMetadataEventSchemaArray,
77
errToStr,
8-
} from "./api/api-helper";
9-
import { type CoderApi } from "./api/coderApi";
8+
} from "./api-helper";
9+
import { type CoderApi } from "./coderApi";
1010

1111
export type AgentMetadataWatcher = {
1212
onChange: vscode.EventEmitter<null>["event"];
@@ -19,11 +19,11 @@ export type AgentMetadataWatcher = {
1919
* Opens a websocket connection to watch metadata for a given workspace agent.
2020
* Emits onChange when metadata updates or an error occurs.
2121
*/
22-
export function createAgentMetadataWatcher(
22+
export async function createAgentMetadataWatcher(
2323
agentId: WorkspaceAgent["id"],
2424
client: CoderApi,
25-
): AgentMetadataWatcher {
26-
const socket = client.watchAgentMetadata(agentId);
25+
): Promise<AgentMetadataWatcher> {
26+
const socket = await client.watchAgentMetadata(agentId);
2727

2828
let disposed = false;
2929
const onChange = new vscode.EventEmitter<null>();

src/api/coderApi.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export class CoderApi extends Api {
6767
return client;
6868
}
6969

70-
watchInboxNotifications = (
70+
watchInboxNotifications = async (
7171
watchTemplates: string[],
7272
watchTargets: string[],
7373
options?: ClientOptions,
@@ -83,14 +83,14 @@ export class CoderApi extends Api {
8383
});
8484
};
8585

86-
watchWorkspace = (workspace: Workspace, options?: ClientOptions) => {
86+
watchWorkspace = async (workspace: Workspace, options?: ClientOptions) => {
8787
return this.createWebSocket<ServerSentEvent>({
8888
apiRoute: `/api/v2/workspaces/${workspace.id}/watch-ws`,
8989
options,
9090
});
9191
};
9292

93-
watchAgentMetadata = (
93+
watchAgentMetadata = async (
9494
agentId: WorkspaceAgent["id"],
9595
options?: ClientOptions,
9696
) => {
@@ -100,21 +100,22 @@ export class CoderApi extends Api {
100100
});
101101
};
102102

103-
watchBuildLogsByBuildId = (buildId: string, logs: ProvisionerJobLog[]) => {
103+
watchBuildLogsByBuildId = async (
104+
buildId: string,
105+
logs: ProvisionerJobLog[],
106+
) => {
104107
const searchParams = new URLSearchParams({ follow: "true" });
105108
if (logs.length) {
106109
searchParams.append("after", logs[logs.length - 1].id.toString());
107110
}
108111

109-
const socket = this.createWebSocket<ProvisionerJobLog>({
112+
return this.createWebSocket<ProvisionerJobLog>({
110113
apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`,
111114
searchParams,
112115
});
113-
114-
return socket;
115116
};
116117

117-
private createWebSocket<TData = unknown>(
118+
private async createWebSocket<TData = unknown>(
118119
configs: Omit<OneWayWebSocketInit, "location">,
119120
) {
120121
const baseUrlRaw = this.getAxiosInstance().defaults.baseURL;
@@ -127,7 +128,15 @@ export class CoderApi extends Api {
127128
coderSessionTokenHeader
128129
] as string | undefined;
129130

130-
const httpAgent = createHttpAgent(vscode.workspace.getConfiguration());
131+
const headers = await getHeaders(
132+
baseUrlRaw,
133+
getHeaderCommand(vscode.workspace.getConfiguration()),
134+
this.output,
135+
);
136+
137+
const httpAgent = await createHttpAgent(
138+
vscode.workspace.getConfiguration(),
139+
);
131140
const webSocket = new OneWayWebSocket<TData>({
132141
location: baseUrl,
133142
...configs,
@@ -137,6 +146,7 @@ export class CoderApi extends Api {
137146
headers: {
138147
...(token ? { [coderSessionTokenHeader]: token } : {}),
139148
...configs.options?.headers,
149+
...headers,
140150
},
141151
...configs.options,
142152
},
@@ -191,7 +201,7 @@ function setupInterceptors(
191201
// Configure proxy and TLS.
192202
// Note that by default VS Code overrides the agent. To prevent this, set
193203
// `http.proxySupport` to `on` or `off`.
194-
const agent = createHttpAgent(vscode.workspace.getConfiguration());
204+
const agent = await createHttpAgent(vscode.workspace.getConfiguration());
195205
config.httpsAgent = agent;
196206
config.httpAgent = agent;
197207
config.proxy = false;

src/api/utils.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import fs from "fs";
1+
import fs from "fs/promises";
22
import { ProxyAgent } from "proxy-agent";
33
import { type WorkspaceConfiguration } from "vscode";
44

@@ -23,7 +23,9 @@ export function needToken(cfg: WorkspaceConfiguration): boolean {
2323
* Create a new HTTP agent based on the current VS Code settings.
2424
* Configures proxy, TLS certificates, and security options.
2525
*/
26-
export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent {
26+
export async function createHttpAgent(
27+
cfg: WorkspaceConfiguration,
28+
): Promise<ProxyAgent> {
2729
const insecure = Boolean(cfg.get("coder.insecure"));
2830
const certFile = expandPath(
2931
String(cfg.get("coder.tlsCertFile") ?? "").trim(),
@@ -32,6 +34,12 @@ export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent {
3234
const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim());
3335
const altHost = expandPath(String(cfg.get("coder.tlsAltHost") ?? "").trim());
3436

37+
const [cert, key, ca] = await Promise.all([
38+
certFile === "" ? Promise.resolve(undefined) : fs.readFile(certFile),
39+
keyFile === "" ? Promise.resolve(undefined) : fs.readFile(keyFile),
40+
caFile === "" ? Promise.resolve(undefined) : fs.readFile(caFile),
41+
]);
42+
3543
return new ProxyAgent({
3644
// Called each time a request is made.
3745
getProxyForUrl: (url: string) => {
@@ -41,9 +49,9 @@ export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent {
4149
cfg.get("coder.proxyBypass"),
4250
);
4351
},
44-
cert: certFile === "" ? undefined : fs.readFileSync(certFile),
45-
key: keyFile === "" ? undefined : fs.readFileSync(keyFile),
46-
ca: caFile === "" ? undefined : fs.readFileSync(caFile),
52+
cert,
53+
key,
54+
ca,
4755
servername: altHost === "" ? undefined : altHost,
4856
// rejectUnauthorized defaults to true, so we need to explicitly set it to
4957
// false if we want to allow self-signed certificates.

src/api/workspace.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,12 @@ export async function waitForBuild(
9595
const logs = await client.getWorkspaceBuildLogs(workspace.latest_build.id);
9696
logs.forEach((log) => writeEmitter.fire(log.output + "\r\n"));
9797

98-
await new Promise<void>((resolve, reject) => {
99-
const socket = client.watchBuildLogsByBuildId(
100-
workspace.latest_build.id,
101-
logs,
102-
);
98+
const socket = await client.watchBuildLogsByBuildId(
99+
workspace.latest_build.id,
100+
logs,
101+
);
103102

103+
await new Promise<void>((resolve, reject) => {
104104
socket.addEventListener("message", (data) => {
105105
if (data.parseError) {
106106
writeEmitter.fire(

src/headers.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function getHeaderCommand(
2424
config.get<string>("coder.headerCommand")?.trim() ||
2525
process.env.CODER_HEADER_COMMAND?.trim();
2626

27-
return cmd ? cmd : undefined;
27+
return cmd || undefined;
2828
}
2929

3030
export function getHeaderArgs(config: WorkspaceConfiguration): string[] {
@@ -44,16 +44,13 @@ export function getHeaderArgs(config: WorkspaceConfiguration): string[] {
4444
return ["--header-command", escapeSubcommand(command)];
4545
}
4646

47-
// TODO: getHeaders might make more sense to directly implement on Storage
48-
// but it is difficult to test Storage right now since we use vitest instead of
49-
// the standard extension testing framework which would give us access to vscode
50-
// APIs. We should revert the testing framework then consider moving this.
51-
52-
// getHeaders executes the header command and parses the headers from stdout.
53-
// Both stdout and stderr are logged on error but stderr is otherwise ignored.
54-
// Throws an error if the process exits with non-zero or the JSON is invalid.
55-
// Returns undefined if there is no header command set. No effort is made to
56-
// validate the JSON other than making sure it can be parsed.
47+
/**
48+
* getHeaders executes the header command and parses the headers from stdout.
49+
* Both stdout and stderr are logged on error but stderr is otherwise ignored.
50+
* Throws an error if the process exits with non-zero or the JSON is invalid.
51+
* Returns undefined if there is no header command set. No effort is made to
52+
* validate the JSON other than making sure it can be parsed.
53+
*/
5754
export async function getHeaders(
5855
url: string | undefined,
5956
command: string | undefined,
@@ -90,8 +87,8 @@ export async function getHeaders(
9087
return headers;
9188
}
9289
const lines = result.stdout.replace(/\r?\n$/, "").split(/\r?\n/);
93-
for (let i = 0; i < lines.length; ++i) {
94-
const [key, value] = lines[i].split(/=(.*)/);
90+
for (const line of lines) {
91+
const [key, value] = line.split(/=(.*)/);
9592
// Header names cannot be blank or contain whitespace and the Coder CLI
9693
// requires that there be an equals sign (the value can be blank though).
9794
if (
@@ -100,7 +97,7 @@ export async function getHeaders(
10097
typeof value === "undefined"
10198
) {
10299
throw new Error(
103-
`Malformed line from header command: [${lines[i]}] (out: ${result.stdout})`,
100+
`Malformed line from header command: [${line}] (out: ${result.stdout})`,
104101
);
105102
}
106103
headers[key] = value;

src/inbox.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,21 @@ const TEMPLATE_WORKSPACE_OUT_OF_MEMORY = "a9d027b4-ac49-4fb1-9f6d-45af15f64e7a";
1616
const TEMPLATE_WORKSPACE_OUT_OF_DISK = "f047f6a3-5713-40f7-85aa-0394cce9fa3a";
1717

1818
export class Inbox implements vscode.Disposable {
19-
readonly #logger: Logger;
20-
#disposed = false;
21-
#socket: OneWayWebSocket<GetInboxNotificationResponse>;
19+
private socket: OneWayWebSocket<GetInboxNotificationResponse> | undefined;
20+
private disposed = false;
2221

23-
constructor(workspace: Workspace, client: CoderApi, logger: Logger) {
24-
this.#logger = logger;
22+
private constructor(private readonly logger: Logger) {}
23+
24+
/**
25+
* Factory method to create and initialize an Inbox.
26+
* Use this instead of the constructor to properly handle async websocket initialization.
27+
*/
28+
static async create(
29+
workspace: Workspace,
30+
client: CoderApi,
31+
logger: Logger,
32+
): Promise<Inbox> {
33+
const inbox = new Inbox(logger);
2534

2635
const watchTemplates = [
2736
TEMPLATE_WORKSPACE_OUT_OF_DISK,
@@ -30,33 +39,40 @@ export class Inbox implements vscode.Disposable {
3039

3140
const watchTargets = [workspace.id];
3241

33-
this.#socket = client.watchInboxNotifications(watchTemplates, watchTargets);
42+
const socket = await client.watchInboxNotifications(
43+
watchTemplates,
44+
watchTargets,
45+
);
3446

35-
this.#socket.addEventListener("open", () => {
36-
this.#logger.info("Listening to Coder Inbox");
47+
socket.addEventListener("open", () => {
48+
logger.info("Listening to Coder Inbox");
3749
});
3850

39-
this.#socket.addEventListener("error", () => {
51+
socket.addEventListener("error", () => {
4052
// Errors are already logged internally
41-
this.dispose();
53+
inbox.dispose();
4254
});
4355

44-
this.#socket.addEventListener("message", (data) => {
56+
socket.addEventListener("message", (data) => {
4557
if (data.parseError) {
46-
this.#logger.error("Failed to parse inbox message", data.parseError);
58+
logger.error("Failed to parse inbox message", data.parseError);
4759
} else {
4860
vscode.window.showInformationMessage(
4961
data.parsedMessage.notification.title,
5062
);
5163
}
5264
});
65+
66+
inbox.socket = socket;
67+
68+
return inbox;
5369
}
5470

5571
dispose() {
56-
if (!this.#disposed) {
57-
this.#logger.info("No longer listening to Coder Inbox");
58-
this.#socket.close();
59-
this.#disposed = true;
72+
if (!this.disposed) {
73+
this.logger.info("No longer listening to Coder Inbox");
74+
this.socket?.close();
75+
this.disposed = true;
6076
}
6177
}
6278
}

0 commit comments

Comments
 (0)