Skip to content
Merged
13 changes: 12 additions & 1 deletion src/api/coderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
CONFIG_CHANGE_DEBOUNCE_MS,
watchConfigurationChanges,
} from "../configWatcher";
import { sessionId } from "../core/sessionId";
import { ClientCertificateError } from "../error/clientCertificateError";
import { toError } from "../error/errorUtils";
import { ServerCertificateError } from "../error/serverCertificateError";
Expand Down Expand Up @@ -77,6 +78,11 @@ import type {

const coderSessionTokenHeader = "Coder-Session-Token";

/** W3C baggage header used to propagate the session ID to the server. */
const BAGGAGE_HEADER = "baggage";
const SESSION_ID_BAGGAGE_KEY = "client_session_id";
const SESSION_ID_BAGGAGE = `${SESSION_ID_BAGGAGE_KEY}=${sessionId}`;

/**
* Default timeout for REST requests, so requests hung on half-open TCP
* connections (e.g. after system sleep) don't stall pollers forever.
Expand Down Expand Up @@ -130,7 +136,9 @@ export class CoderApi extends Api implements vscode.Disposable {
* Automatically sets up logging interceptors, certificate handling,
* HTTP request telemetry, and WebSocket connection telemetry. All
* telemetry routes through the single reporter passed in (defaults to
* NOOP_TELEMETRY_REPORTER for throwaway clients).
* NOOP_TELEMETRY_REPORTER for throwaway clients). The session ID is
* attached to every request via the `baggage` header so the server can
* correlate requests with the session's logs and telemetry.
*/
static create(
baseUrl: string,
Expand All @@ -147,6 +155,8 @@ export class CoderApi extends Api implements vscode.Disposable {
authConfigTracker,
);
client.getAxiosInstance().defaults.timeout = DEFAULT_REQUEST_TIMEOUT_MS;
client.getAxiosInstance().defaults.headers.common[BAGGAGE_HEADER] =
SESSION_ID_BAGGAGE;
client.setCredentials(baseUrl, token);

setupInterceptors(client, output, httpRequestsTelemetry, authConfigTracker);
Expand Down Expand Up @@ -381,6 +391,7 @@ export class CoderApi extends Api implements vscode.Disposable {
...(token ? { [coderSessionTokenHeader]: token } : {}),
...configs.options?.headers,
...headersFromCommand,
[BAGGAGE_HEADER]: SESSION_ID_BAGGAGE,
};

const baseUrl = new URL(baseUrlRaw);
Expand Down
10 changes: 3 additions & 7 deletions src/core/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { shortId } from "../logging/utils";
import { LoginCoordinator } from "../login/loginCoordinator";
import { OAuthCallback } from "../oauth/oauthCallback";
import { buildSession, extractExtensionVersion } from "../telemetry/event";
import { newSessionId } from "../telemetry/ids";
import { TelemetryService } from "../telemetry/service";
import { LocalJsonlSink } from "../telemetry/sinks/localJsonlSink";
import { NetcheckPanelFactory } from "../webviews/netcheck/netcheckPanelFactory";
Expand All @@ -20,6 +19,7 @@ import { ContextManager } from "./contextManager";
import { MementoManager } from "./mementoManager";
import { PathResolver } from "./pathResolver";
import { SecretsManager } from "./secretsManager";
import { sessionId } from "./sessionId";

import type { Logger } from "../logging/logger";

Expand All @@ -29,7 +29,6 @@ import type { Logger } from "../logging/logger";
*/
export class ServiceContainer implements vscode.Disposable {
private readonly outputChannel: vscode.LogOutputChannel;
private readonly sessionId: string;
private readonly logger: Logger;
private readonly pathResolver: PathResolver;
private readonly mementoManager: MementoManager;
Expand All @@ -49,12 +48,9 @@ export class ServiceContainer implements vscode.Disposable {
this.outputChannel = vscode.window.createOutputChannel("Coder", {
log: true,
});
// One session ID per activation, shared by logs, API requests,
// telemetry, and the CLI so all data for a session correlates.
this.sessionId = newSessionId();
this.logger = prefixLogger(
this.outputChannel,
`[session ${shortId(this.sessionId)}]`,
`[session ${shortId(sessionId)}]`,
);
this.pathResolver = new PathResolver(
context.globalStorageUri.fsPath,
Expand All @@ -69,7 +65,7 @@ export class ServiceContainer implements vscode.Disposable {

const session = buildSession(
extractExtensionVersion(context.extension.packageJSON),
this.sessionId,
sessionId,
);
const localJsonlSink = LocalJsonlSink.start(
{
Expand Down
16 changes: 16 additions & 0 deletions src/core/sessionId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { randomBytes } from "node:crypto";

/**
* One session ID per extension host process, shared across logs, API
* requests, telemetry, and the CLI so all data for a session can be correlated
* by a single ID.
*
* In rare cases when the extension is deactivated then activated within the
* same window, it should reuse the same ID. However, reloading or closing-
* then-reopening a window creates a new process with a new ID.
*
* 16 bytes / 32 lowercase hex, matching the OTel id format so a future OTel
* exporter maps 1:1. Avoids `vscode.env.sessionId`, which is a UUID
* concatenated with a timestamp.
*/
export const sessionId = randomBytes(16).toString("hex");
27 changes: 16 additions & 11 deletions src/remote/environment.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { joinNoProxy } from "../api/proxy";
import { sessionId } from "../core/sessionId";

import type {
GlobalEnvironmentVariableCollection,
WorkspaceConfiguration,
} from "vscode";

type Environment = Record<string, string | undefined>;
type SshEnvironment = Partial<
type SshProxyEnvironment = Partial<
Record<"HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY", string>
>;

Expand All @@ -26,13 +27,14 @@ export const SSH_PROXY_SETTINGS: ReadonlyArray<{

/**
* Apply the SSH environment that the spawned `coder ssh` ProxyCommand inherits.
* Currently just the proxy config (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), read by the
* coder CLI like any Go HTTP client. Applied via both process.env (ssh spawned as
* a child, `remote.SSH.useLocalServer=true`) and the terminal env collection (ssh
* spawned in a terminal, `useLocalServer=false`, which can't see process.env),
* since the mode isn't knowable up front. Mutating env rather than the SSH config
* keeps credentialed URLs off disk and windows independent. Disposable restores
* both.
* Includes the proxy config (HTTP_PROXY/HTTPS_PROXY/NO_PROXY), read by the coder
* CLI like any Go HTTP client, and the session ID via CODER_TRACE_SESSION_ID so
* the CLI reuses the plugin's session ID instead of generating its own. Applied
* via both process.env (ssh spawned as a child, `remote.SSH.useLocalServer=true`)
* and the terminal env collection (ssh spawned in a terminal,
* `useLocalServer=false`, which can't see process.env), since the mode isn't
* knowable up front. Mutating env rather than the SSH config keeps credentialed
* URLs off disk and windows independent. Disposable restores both.
*/
export function applySshEnvironment(
cfg: Pick<WorkspaceConfiguration, "get">,
Expand All @@ -42,7 +44,10 @@ export function applySshEnvironment(
>,
env: Environment = process.env,
): { dispose(): void } {
const values = getSshProxyEnvironment(cfg);
const values: Environment = {
...getSshProxyEnvironment(cfg),
CODER_TRACE_SESSION_ID: sessionId,
};
const restoreEnv = applyEnvironment(values, env);

collection.persistent = false;
Expand All @@ -65,7 +70,7 @@ export function applySshEnvironment(
/** The proxy portion of the SSH environment, derived from VS Code's settings. */
export function getSshProxyEnvironment(
cfg: Pick<WorkspaceConfiguration, "get">,
): SshEnvironment {
): SshProxyEnvironment {
if (cfg.get<string>("http.proxySupport") === "off") {
return {};
}
Expand All @@ -83,7 +88,7 @@ export function getSshProxyEnvironment(
}

function applyEnvironment(
values: SshEnvironment,
values: Environment,
env: Environment,
): { dispose(): void } {
// Stored `undefined` means the key was absent and should be deleted on cleanup.
Expand Down
6 changes: 0 additions & 6 deletions src/telemetry/ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,3 @@ export function newTraceId(): string {
export function newSpanId(): string {
return randomBytes(8).toString("hex");
}

/** Our own session id (16 bytes / 32 hex). Avoids `vscode.env.sessionId`,
* which is a UUID concatenated with a timestamp. */
export function newSessionId(): string {
return randomBytes(16).toString("hex");
}
15 changes: 15 additions & 0 deletions test/unit/api/coderApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "@/api/responseValidation";
import { createHttpAgent } from "@/api/utils";
import { CONFIG_CHANGE_DEBOUNCE_MS } from "@/configWatcher";
import { sessionId } from "@/core/sessionId";
import { ClientCertificateError } from "@/error/clientCertificateError";
import { ServerCertificateError } from "@/error/serverCertificateError";
import { getHeaders } from "@/headers";
Expand Down Expand Up @@ -148,6 +149,16 @@ describe("CoderApi", () => {
);
});

it("attaches the session ID to every request as a baggage header", async () => {
api = createApi();

const response = await api.getAxiosInstance().get("/api/v2/users/me");

expect(response.config.headers["baggage"]).toBe(
`client_session_id=${sessionId}`,
);
});

it("applies the default timeout to requests", async () => {
api = createApi();
const response = await api.getAxiosInstance().get("/api/v2/users/me");
Expand Down Expand Up @@ -473,6 +484,7 @@ describe("CoderApi", () => {
headers: {
"X-Custom-Header": "custom-value",
"Coder-Session-Token": AXIOS_TOKEN,
baggage: `client_session_id=${sessionId}`,
},
});
});
Expand All @@ -486,6 +498,7 @@ describe("CoderApi", () => {
followRedirects: true,
headers: {
"Coder-Session-Token": AXIOS_TOKEN,
baggage: `client_session_id=${sessionId}`,
},
});

Expand All @@ -503,6 +516,7 @@ describe("CoderApi", () => {
headers: {
"Coder-Session-Token": "from-config",
"X-Config-Header": "config-value",
baggage: `client_session_id=${sessionId}`,
},
});

Expand All @@ -522,6 +536,7 @@ describe("CoderApi", () => {
followRedirects: true,
headers: {
"Coder-Session-Token": "from-header-command",
baggage: `client_session_id=${sessionId}`,
},
});
});
Expand Down
20 changes: 13 additions & 7 deletions test/unit/remote/environment.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { sessionId } from "@/core/sessionId";
import {
applySshEnvironment,
getSshProxyEnvironment,
Expand All @@ -14,6 +15,7 @@ import {
} from "../../mocks/testHelpers";

const proxyEnv = { HTTP_PROXY: proxy, HTTPS_PROXY: proxy };
const sessionEnv = { CODER_TRACE_SESSION_ID: sessionId };
type Environment = Record<string, string | undefined>;

beforeEach(() => {
Expand Down Expand Up @@ -108,7 +110,11 @@ describe("applySshEnvironment", () => {
it("applies proxy variables to process.env and the collection, and restores on dispose", () => {
const env: Environment = {};
const collection = fakeEnvCollection();
const expected = { ...proxyEnv, NO_PROXY: "internal.example.com" };
const expected = {
...proxyEnv,
NO_PROXY: "internal.example.com",
...sessionEnv,
};

const applied = applySshEnvironment(
config(withProxy({ "coder.proxyBypass": "internal.example.com" })),
Expand All @@ -125,14 +131,14 @@ describe("applySshEnvironment", () => {
expect(collection.vars).toEqual({});
});

it("sets nothing when no proxy is configured", () => {
it("sets the session ID even when no proxy is configured", () => {
const env: Environment = {};
const collection = fakeEnvCollection();

applySshEnvironment(config(), collection, env);

expect(env).toEqual({});
expect(collection.vars).toEqual({});
expect(env).toEqual(sessionEnv);
expect(collection.vars).toEqual(sessionEnv);
});

it("does not clear existing env proxy variables when proxy support is off", () => {
Expand All @@ -149,8 +155,8 @@ describe("applySshEnvironment", () => {
env,
);

expect(env).toEqual(original);
expect(collection.vars).toEqual({});
expect(env).toEqual({ ...original, ...sessionEnv });
expect(collection.vars).toEqual(sessionEnv);
});

it("does not overwrite existing lowercase variables", () => {
Expand All @@ -166,7 +172,7 @@ describe("applySshEnvironment", () => {
env,
);

expect(env).toEqual({ ...original, ...proxyEnv });
expect(env).toEqual({ ...original, ...proxyEnv, ...sessionEnv });

applied.dispose();
expect(env).toEqual(original);
Expand Down