Skip to content

Commit

Permalink
Add header command setting
Browse files Browse the repository at this point in the history
This will be called before requests and added to the SSH config.
  • Loading branch information
code-asher committed Aug 14, 2023
1 parent 697b30e commit 95bd8c2
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 4 deletions.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@
"markdownDescription": "The full path of the directory into which the Coder CLI will be downloaded. Defaults to the extension's global storage directory.",
"type": "string",
"default": ""
},
"coder.headerCommand": {
"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`.",
"type": "string",
"default": ""
}
}
},
Expand Down
4 changes: 3 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ export class Commands {
severity: vscode.InputBoxValidationSeverity.Error,
}
}
// This could be something like the header command erroring or an
// invalid session token.
return {
message: "Invalid session token! (" + message + ")",
message: "Failed to authenticate: " + message,
severity: vscode.InputBoxValidationSeverity.Error,
}
})
Expand Down
17 changes: 15 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
const storage = new Storage(output, ctx.globalState, ctx.secrets, ctx.globalStorageUri, ctx.logUri)
await storage.init()

// Add headers from the header command.
axios.interceptors.request.use(async (config) => {
return {
...config,
headers: {
...(await storage.getHeaders()),
...creds.headers,
},
}
})

const myWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.Mine, storage)
const allWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.All, storage)

Expand All @@ -74,8 +85,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
}
}
})
.catch(() => {
// Not authenticated!
.catch((error) => {
// This should be a failure to make the request, like the header command
// errored.
vscodeProposed.window.showErrorMessage("Failed to check user authentication: " + error.message)
})
.finally(() => {
vscode.commands.executeCommand("setContext", "coder.loaded", true)
Expand Down
50 changes: 50 additions & 0 deletions src/headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as os from "os"
import { it, expect } from "vitest"
import { getHeaders } from "./headers"

const logger = {
writeToCoderOutputChannel() {
// no-op
},
}

it("should return undefined", async () => {
await expect(getHeaders(undefined, undefined, logger)).resolves.toStrictEqual({})
await expect(getHeaders("foo", undefined, logger)).resolves.toStrictEqual({})
await expect(getHeaders(undefined, "foo", logger)).resolves.toStrictEqual({})
})

it("should return headers", async () => {
await expect(getHeaders("foo", "printf foo=bar'\n'baz=qux", logger)).resolves.toStrictEqual({
foo: "bar",
baz: "qux",
})
await expect(getHeaders("foo", "printf foo=bar'\r\n'baz=qux", logger)).resolves.toStrictEqual({
foo: "bar",
baz: "qux",
})
await expect(getHeaders("foo", "printf foo=bar'\r\n'", logger)).resolves.toStrictEqual({ foo: "bar" })
await expect(getHeaders("foo", "printf foo=bar", logger)).resolves.toStrictEqual({ foo: "bar" })
await expect(getHeaders("foo", "printf foo=bar=", logger)).resolves.toStrictEqual({ foo: "bar=" })
await expect(getHeaders("foo", "printf foo=bar=baz", logger)).resolves.toStrictEqual({ foo: "bar=baz" })
await expect(getHeaders("foo", "printf foo=", logger)).resolves.toStrictEqual({ foo: "" })
})

it("should error on malformed or empty lines", async () => {
await expect(getHeaders("foo", "printf foo=bar'\r\n\r\n'", logger)).rejects.toMatch(/Malformed/)
await expect(getHeaders("foo", "printf '\r\n'foo=bar", logger)).rejects.toMatch(/Malformed/)
await expect(getHeaders("foo", "printf =foo", logger)).rejects.toMatch(/Malformed/)
await expect(getHeaders("foo", "printf foo", logger)).rejects.toMatch(/Malformed/)
await expect(getHeaders("foo", "printf ''", logger)).rejects.toMatch(/Malformed/)
})

it("should have access to environment variables", async () => {
const coderUrl = "dev.coder.com"
await expect(
getHeaders(coderUrl, os.platform() === "win32" ? "printf url=%CODER_URL" : "printf url=$CODER_URL", logger),
).resolves.toStrictEqual({ url: coderUrl })
})

it("should error on non-zero exit", async () => {
await expect(getHeaders("foo", "exit 10", logger)).rejects.toMatch(/exited unexpectedly with code 10/)
})
63 changes: 63 additions & 0 deletions src/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as cp from "child_process"
import * as util from "util"

export interface Logger {
writeToCoderOutputChannel(message: string): void
}

interface ExecException {
code?: number
stderr?: string
stdout?: string
}

function isExecException(err: unknown): err is ExecException {
return typeof (err as ExecException).code !== "undefined"
}

// 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.
export async function getHeaders(
url: string | undefined,
command: string | undefined,
logger: Logger,
): Promise<Record<string, string>> {
const headers: Record<string, string> = {}
if (typeof url === "string" && url.trim().length > 0 && typeof command === "string" && command.trim().length > 0) {
let result: { stdout: string; stderr: string }
try {
result = await util.promisify(cp.exec)(command, {
env: {
...process.env,
CODER_URL: url,
},
})
} catch (error) {
if (isExecException(error)) {
logger.writeToCoderOutputChannel(`Header command exited unexpectedly with code ${error.code}`)
logger.writeToCoderOutputChannel(`stdout: ${error.stdout}`)
logger.writeToCoderOutputChannel(`stderr: ${error.stderr}`)
throw new Error(`Header command exited unexpectedly with code ${error.code}`)
}
throw new Error(`Header command exited unexpectedly: ${error}`)
}
// This should imitate or be a subset of the Coder CLI's behavior.
const lines = result.stdout.replace(/\r?\n$/, "").split(/\r?\n/)
for (let i = 0; i < lines.length; ++i) {
const [key, value] = lines[i].split(/=(.*)/)
if (key.length === 0 || typeof value === "undefined") {
throw new Error(`Malformed line from header command: [${lines[i]}] (out: ${result.stdout})`)
}
headers[key] = value
}
}
return headers
}
9 changes: 8 additions & 1 deletion src/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,16 @@ export class Remote {
}

const escape = (str: string): string => `"${str.replace(/"/g, '\\"')}"`

// Add headers from the header command.
let headerCommand = vscode.workspace.getConfiguration().get("coder.headerCommand")
if (headerCommand) {
headerCommand = ` --header-command ${headerCommand}`
}

const sshValues: SSHValues = {
Host: `${Remote.Prefix}*`,
ProxyCommand: `${escape(binaryPath)} vscodessh --network-info-dir ${escape(
ProxyCommand: `${escape(binaryPath)}${headerCommand} vscodessh --network-info-dir ${escape(
this.storage.getNetworkInfoPath(),
)} --session-token-file ${escape(this.storage.getSessionTokenPath())} --url-file ${escape(
this.storage.getURLPath(),
Expand Down
5 changes: 5 additions & 0 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import os from "os"
import path from "path"
import prettyBytes from "pretty-bytes"
import * as vscode from "vscode"
import { getHeaders } from "./headers"

export class Storage {
public workspace?: Workspace
Expand Down Expand Up @@ -391,6 +392,10 @@ export class Storage {
await fs.rm(this.getSessionTokenPath(), { force: true })
}
}

public async getHeaders(url = this.getURL()): Promise<Record<string, string> | undefined> {
return getHeaders(url, vscode.workspace.getConfiguration().get("coder.headerCommand"), this)
}
}

// goos returns the Go format for the current platform.
Expand Down

0 comments on commit 95bd8c2

Please sign in to comment.