Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 baggageHeader = "baggage";

const SESSION_ID_BAGGAGE_KEY = "client_session_id";

/**
* 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[baggageHeader] =
`${SESSION_ID_BAGGAGE_KEY}=${sessionId}`;
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,
[baggageHeader]: `${SESSION_ID_BAGGAGE_KEY}=${sessionId}`,
};

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 @@ -5,7 +5,6 @@ import { prefixLogger } from "../logging/prefixLogger";
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 @@ -19,6 +18,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 @@ -28,7 +28,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 @@ -48,10 +47,7 @@ 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, `[${this.sessionId}]`);
this.logger = prefixLogger(this.outputChannel, `[${sessionId}]`);
this.pathResolver = new PathResolver(
context.globalStorageUri.fsPath,
context.logUri.fsPath,
Expand All @@ -65,7 +61,7 @@ export class ServiceContainer implements vscode.Disposable {

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

/**
* One session ID per activation, shared by logs, API requests, telemetry, and
* the CLI so all data for a session can be correlated by a single 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
Loading