Skip to content

Commit 36c842b

Browse files
committed
Add header command setting
This will be called before requests and added to the SSH config.
1 parent 697b30e commit 36c842b

File tree

7 files changed

+157
-4
lines changed

7 files changed

+157
-4
lines changed

package.json

+5
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
"markdownDescription": "The full path of the directory into which the Coder CLI will be downloaded. Defaults to the extension's global storage directory.",
5858
"type": "string",
5959
"default": ""
60+
},
61+
"coder.headerCommand": {
62+
"markdownDescription": "An external command that outputs additional HTTP headers added to all requests. The command must output each header as `key=value` on its own line. The following environment variables will be available to the process: `CODER_URL`.",
63+
"type": "string",
64+
"default": ""
6065
}
6166
}
6267
},

src/commands.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@ export class Commands {
7070
severity: vscode.InputBoxValidationSeverity.Error,
7171
}
7272
}
73+
// This could be something like the header command erroring or an
74+
// invalid session token.
7375
return {
74-
message: "Invalid session token! (" + message + ")",
76+
message: "Failed to authenticate: " + message,
7577
severity: vscode.InputBoxValidationSeverity.Error,
7678
}
7779
})

src/extension.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
5959
const storage = new Storage(output, ctx.globalState, ctx.secrets, ctx.globalStorageUri, ctx.logUri)
6060
await storage.init()
6161

62+
// Add headers from the header command.
63+
axios.interceptors.request.use(async (config) => {
64+
return {
65+
...config,
66+
headers: {
67+
...(await storage.getHeaders()),
68+
...creds.headers,
69+
},
70+
}
71+
})
72+
6273
const myWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.Mine, storage)
6374
const allWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.All, storage)
6475

@@ -74,8 +85,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
7485
}
7586
}
7687
})
77-
.catch(() => {
78-
// Not authenticated!
88+
.catch((error) => {
89+
// This should be a failure to make the request, like the header command
90+
// errored.
91+
vscodeProposed.window.showErrorMessage("Failed to check user authentication: " + error.message)
7992
})
8093
.finally(() => {
8194
vscode.commands.executeCommand("setContext", "coder.loaded", true)

src/headers.test.ts

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as os from "os"
2+
import { it, expect } from "vitest"
3+
import { getHeaders } from "./headers"
4+
5+
const logger = {
6+
writeToCoderOutputChannel() {
7+
// no-op
8+
},
9+
}
10+
11+
it("should return no headers", async () => {
12+
await expect(getHeaders(undefined, undefined, logger)).resolves.toStrictEqual({})
13+
await expect(getHeaders("localhost", undefined, logger)).resolves.toStrictEqual({})
14+
await expect(getHeaders(undefined, "command", logger)).resolves.toStrictEqual({})
15+
await expect(getHeaders("localhost", "", logger)).resolves.toStrictEqual({})
16+
await expect(getHeaders("", "command", logger)).resolves.toStrictEqual({})
17+
await expect(getHeaders("localhost", " ", logger)).resolves.toStrictEqual({})
18+
await expect(getHeaders(" ", "command", logger)).resolves.toStrictEqual({})
19+
})
20+
21+
it("should return headers", async () => {
22+
await expect(getHeaders("localhost", "printf foo=bar'\n'baz=qux", logger)).resolves.toStrictEqual({
23+
foo: "bar",
24+
baz: "qux",
25+
})
26+
await expect(getHeaders("localhost", "printf foo=bar'\r\n'baz=qux", logger)).resolves.toStrictEqual({
27+
foo: "bar",
28+
baz: "qux",
29+
})
30+
await expect(getHeaders("localhost", "printf foo=bar'\r\n'", logger)).resolves.toStrictEqual({ foo: "bar" })
31+
await expect(getHeaders("localhost", "printf foo=bar", logger)).resolves.toStrictEqual({ foo: "bar" })
32+
await expect(getHeaders("localhost", "printf foo=bar=", logger)).resolves.toStrictEqual({ foo: "bar=" })
33+
await expect(getHeaders("localhost", "printf foo=bar=baz", logger)).resolves.toStrictEqual({ foo: "bar=baz" })
34+
await expect(getHeaders("localhost", "printf foo=", logger)).resolves.toStrictEqual({ foo: "" })
35+
})
36+
37+
it("should error on malformed or empty lines", async () => {
38+
await expect(getHeaders("localhost", "printf foo=bar'\r\n\r\n'", logger)).rejects.toMatch(/Malformed/)
39+
await expect(getHeaders("localhost", "printf '\r\n'foo=bar", logger)).rejects.toMatch(/Malformed/)
40+
await expect(getHeaders("localhost", "printf =foo", logger)).rejects.toMatch(/Malformed/)
41+
await expect(getHeaders("localhost", "printf foo", logger)).rejects.toMatch(/Malformed/)
42+
await expect(getHeaders("localhost", "printf ' =foo'", logger)).rejects.toMatch(/Malformed/)
43+
await expect(getHeaders("localhost", "printf 'foo =bar'", logger)).rejects.toMatch(/Malformed/)
44+
await expect(getHeaders("localhost", "printf 'foo foo=bar'", logger)).rejects.toMatch(/Malformed/)
45+
await expect(getHeaders("localhost", "printf ''", logger)).rejects.toMatch(/Malformed/)
46+
})
47+
48+
it("should have access to environment variables", async () => {
49+
const coderUrl = "dev.coder.com"
50+
await expect(
51+
getHeaders(coderUrl, os.platform() === "win32" ? "printf url=%CODER_URL" : "printf url=$CODER_URL", logger),
52+
).resolves.toStrictEqual({ url: coderUrl })
53+
})
54+
55+
it("should error on non-zero exit", async () => {
56+
await expect(getHeaders("localhost", "exit 10", logger)).rejects.toMatch(/exited unexpectedly with code 10/)
57+
})

src/headers.ts

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as cp from "child_process"
2+
import * as util from "util"
3+
4+
export interface Logger {
5+
writeToCoderOutputChannel(message: string): void
6+
}
7+
8+
interface ExecException {
9+
code?: number
10+
stderr?: string
11+
stdout?: string
12+
}
13+
14+
function isExecException(err: unknown): err is ExecException {
15+
return typeof (err as ExecException).code !== "undefined"
16+
}
17+
18+
// TODO: getHeaders might make more sense to directly implement on Storage
19+
// but it is difficult to test Storage right now since we use vitest instead of
20+
// the standard extension testing framework which would give us access to vscode
21+
// APIs. We should revert the testing framework then consider moving this.
22+
23+
// getHeaders executes the header command and parses the headers from stdout.
24+
// Both stdout and stderr are logged on error but stderr is otherwise ignored.
25+
// Throws an error if the process exits with non-zero or the JSON is invalid.
26+
// Returns undefined if there is no header command set. No effort is made to
27+
// validate the JSON other than making sure it can be parsed.
28+
export async function getHeaders(
29+
url: string | undefined,
30+
command: string | undefined,
31+
logger: Logger,
32+
): Promise<Record<string, string>> {
33+
const headers: Record<string, string> = {}
34+
if (typeof url === "string" && url.trim().length > 0 && typeof command === "string" && command.trim().length > 0) {
35+
let result: { stdout: string; stderr: string }
36+
try {
37+
result = await util.promisify(cp.exec)(command, {
38+
env: {
39+
...process.env,
40+
CODER_URL: url,
41+
},
42+
})
43+
} catch (error) {
44+
if (isExecException(error)) {
45+
logger.writeToCoderOutputChannel(`Header command exited unexpectedly with code ${error.code}`)
46+
logger.writeToCoderOutputChannel(`stdout: ${error.stdout}`)
47+
logger.writeToCoderOutputChannel(`stderr: ${error.stderr}`)
48+
throw new Error(`Header command exited unexpectedly with code ${error.code}`)
49+
}
50+
throw new Error(`Header command exited unexpectedly: ${error}`)
51+
}
52+
const lines = result.stdout.replace(/\r?\n$/, "").split(/\r?\n/)
53+
for (let i = 0; i < lines.length; ++i) {
54+
const [key, value] = lines[i].split(/=(.*)/)
55+
// Header names cannot be blank or contain whitespace and the Coder CLI
56+
// requires that there be an equals sign (the value can be blank though).
57+
if (key.length === 0 || key.indexOf(" ") !== -1 || typeof value === "undefined") {
58+
throw new Error(`Malformed line from header command: [${lines[i]}] (out: ${result.stdout})`)
59+
}
60+
headers[key] = value
61+
}
62+
}
63+
return headers
64+
}

src/remote.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -508,9 +508,16 @@ export class Remote {
508508
}
509509

510510
const escape = (str: string): string => `"${str.replace(/"/g, '\\"')}"`
511+
512+
// Add headers from the header command.
513+
let headerCommand = vscode.workspace.getConfiguration().get("coder.headerCommand")
514+
if (headerCommand) {
515+
headerCommand = ` --header-command ${headerCommand}`
516+
}
517+
511518
const sshValues: SSHValues = {
512519
Host: `${Remote.Prefix}*`,
513-
ProxyCommand: `${escape(binaryPath)} vscodessh --network-info-dir ${escape(
520+
ProxyCommand: `${escape(binaryPath)}${headerCommand} vscodessh --network-info-dir ${escape(
514521
this.storage.getNetworkInfoPath(),
515522
)} --session-token-file ${escape(this.storage.getSessionTokenPath())} --url-file ${escape(
516523
this.storage.getURLPath(),

src/storage.ts

+5
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import os from "os"
1111
import path from "path"
1212
import prettyBytes from "pretty-bytes"
1313
import * as vscode from "vscode"
14+
import { getHeaders } from "./headers"
1415

1516
export class Storage {
1617
public workspace?: Workspace
@@ -391,6 +392,10 @@ export class Storage {
391392
await fs.rm(this.getSessionTokenPath(), { force: true })
392393
}
393394
}
395+
396+
public async getHeaders(url = this.getURL()): Promise<Record<string, string> | undefined> {
397+
return getHeaders(url, vscode.workspace.getConfiguration().get("coder.headerCommand"), this)
398+
}
394399
}
395400

396401
// goos returns the Go format for the current platform.

0 commit comments

Comments
 (0)