diff --git a/src/featureSet.ts b/src/featureSet.ts index 29433e2fd0..c50072958d 100644 --- a/src/featureSet.ts +++ b/src/featureSet.ts @@ -12,12 +12,14 @@ export interface CliFeatureSet { supportBundle: boolean; supportBundleWorkspaceFiles: boolean; allowRedirects: boolean; + customSessionAppNames: boolean; } /** Capabilities keyed to the Coder server (REST API) version. */ export interface ServerFeatureSet { tasks: boolean; onSuccessBuild: boolean; + customSessionAppNames: boolean; } /** @@ -58,6 +60,7 @@ export function cliFeatureSet(version: semver.SemVer | null): CliFeatureSet { supportBundleWorkspaceFiles: versionAtLeast(version, "2.36.0"), // --allow-redirects; from 2.38 the CLI otherwise errors on a redirected URL. allowRedirects: versionAtLeast(version, "2.38.0"), + customSessionAppNames: versionAtLeast(version, "2.38.0"), }; } @@ -71,5 +74,6 @@ export function serverFeatureSet( versionAtLeast(version, "2.29.0") && !versionAtLeast(version, "2.35.0"), // `on_success` on a stop build, which queues the start in one request onSuccessBuild: versionAtLeast(version, "2.36.0"), + customSessionAppNames: versionAtLeast(version, "2.38.0"), }; } diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 956d686ee3..002f452d4e 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -115,6 +115,16 @@ interface RemoteSetupContext { disposables: vscode.Disposable[]; } +interface SshConfigOptions { + workspaceClient: Api; + parts: AuthorityParts; + binaryPath: string; + logDir: string; + cliFeatures: CliFeatureSet; + cliAuth: CliAuth; + sessionApp: string; +} + /** * What Open Recent shows after the path. VS Code splits the label on the * separator, so "/" would display "/home/kyle [Coder: kyle/workspace]" as @@ -310,16 +320,15 @@ export class Remote { this.resolveRemoteBinary(workspaceClient), ); - const { cliFeatures, serverFeatures, cliAuth } = await tracer.phase( - "compatibility_check", - () => + const { cliFeatures, serverFeatures, cliAuth, sessionApp } = + await tracer.phase("compatibility_check", () => this.checkCompatibility({ workspaceClient, binaryPath, baseUrl, safeHostname: parts.safeHostname, }), - ); + ); // Reject deployments below our minimum supported version (v0.25.0) // before configuring credentials, so they get a clear message. @@ -426,14 +435,15 @@ export class Remote { const logDir = this.getLogDir(cliFeatures); const computedSshProperties = await tracer.phase("ssh_config_write", () => - this.writeRemoteSshConfig( - context, + this.writeRemoteSshConfig({ + parts: context.parts, workspaceClient, binaryPath, logDir, cliFeatures, cliAuth, - ), + sessionApp, + }), ); const remoteCommand = computedSshProperties.remotecommand; @@ -689,23 +699,11 @@ export class Remote { } private async writeRemoteSshConfig( - context: RemoteSetupContext, - workspaceClient: Api, - binaryPath: string, - logDir: string, - cliFeatures: CliFeatureSet, - cliAuth: CliAuth, + options: SshConfigOptions, ): Promise { try { this.logger.info("Updating SSH config..."); - return await this.updateSSHConfig( - workspaceClient, - context.parts, - binaryPath, - logDir, - cliFeatures, - cliAuth, - ); + return await this.updateSSHConfig(options); } catch (error) { this.logger.warn("Failed to configure SSH", error); throw error; @@ -779,19 +777,27 @@ export class Remote { cliFeatures: CliFeatureSet; serverFeatures: ServerFeatureSet; cliAuth: CliAuth; + sessionApp: string; }> { const { workspaceClient, binaryPath, baseUrl, safeHostname } = options; const buildInfo = await workspaceClient.getBuildInfo(); const serverVersion = semver.parse(buildInfo.version); - let version: semver.SemVer | null; + let cliFeatures: CliFeatureSet; try { - version = semver.parse(await cliVersion(binaryPath)); + cliFeatures = cliFeatureSet(semver.parse(await cliVersion(binaryPath))); } catch { - version = serverVersion; + cliFeatures = { + ...cliFeatureSet(serverVersion), + customSessionAppNames: false, + }; } - - const cliFeatures = cliFeatureSet(version); + const serverFeatures = serverFeatureSet(serverVersion); + // Report schemes verbatim; normalization and editor families belong to the server. + const sessionApp = + cliFeatures.customSessionAppNames && serverFeatures.customSessionAppNames + ? vscode.env.uriScheme || "vscode" + : "vscode"; const configDir = this.pathResolver.getGlobalConfigDir(safeHostname); const cliAuth = resolveCliAuth( vscode.workspace.getConfiguration(), @@ -801,8 +807,9 @@ export class Remote { ); return { cliFeatures, - serverFeatures: serverFeatureSet(serverVersion), + serverFeatures, cliAuth, + sessionApp, }; } @@ -868,14 +875,15 @@ export class Remote { // updateSSHConfig updates the SSH configuration with a wildcard that handles // all Coder entries. - private async updateSSHConfig( - restClient: Api, - parts: AuthorityParts, - binaryPath: string, - logDir: string, - cliFeatures: CliFeatureSet, - cliAuth: CliAuth, - ): Promise { + private async updateSSHConfig({ + workspaceClient, + parts, + binaryPath, + logDir, + cliFeatures, + cliAuth, + sessionApp, + }: SshConfigOptions): Promise { // Taken from the authority, so a legacy host keeps working. const { hostPrefix, safeHostname, sshHost } = parts; // One file per (host prefix, deployment); the user's config gains one shared include. @@ -901,7 +909,7 @@ export class Remote { let deploymentSshConfig = {}; try { - const deploymentConfig = await restClient.getDeploymentSSHConfig(); + const deploymentConfig = await workspaceClient.getDeploymentSSHConfig(); deploymentSshConfig = validateDeploymentSshOptions( deploymentConfig.ssh_config_options, { ...configSshOptions, ...userConfig }, @@ -936,6 +944,7 @@ export class Remote { binaryPath, cliAuth, logArgs: await this.getLogArgs(logDir), + sessionApp, }; const proxyCommand = cliFeatures.wildcardSSH ? buildSshProxyCommand({ ...proxyOptions, hostPrefix }) @@ -952,9 +961,7 @@ export class Remote { ServerAliveCountMax: "3", }; if (sshSupportsSetEnv()) { - // This allows for tracking the number of extension - // users connected to workspaces! - sshValues.SetEnv = "CODER_SSH_SESSION_TYPE=vscode"; + sshValues.SetEnv = `CODER_SSH_SESSION_TYPE=${sessionApp}`; } // Write our file before including it, so the include never dangles. @@ -1095,6 +1102,7 @@ interface ProxyCommandOptions { binaryPath: string; cliAuth: CliAuth; logArgs: string[]; + sessionApp: string; } function coderCommand( @@ -1116,7 +1124,7 @@ export function buildSshProxyCommand( // Make sure to update the `coder.sshFlags` description if we add more internal flags here! const internalFlags = [ "--stdio", - "--usage-app=vscode", + `--usage-app=${escapeCommandArg(options.sessionApp)}`, "--network-info-dir", escapeCommandArg(options.pathResolver.getNetworkInfoPath()), ...options.logArgs, diff --git a/test/unit/api/workspace.test.ts b/test/unit/api/workspace.test.ts index f7c7ac0bcd..ed1b857a16 100644 --- a/test/unit/api/workspace.test.ts +++ b/test/unit/api/workspace.test.ts @@ -32,6 +32,7 @@ const CLI_FEATURES: CliFeatureSet = { supportBundle: true, supportBundleWorkspaceFiles: true, allowRedirects: true, + customSessionAppNames: true, }; function mockStream(): UnidirectionalStream { @@ -110,6 +111,7 @@ function createUpdateCtx( serverFeatures: { tasks: false, onSuccessBuild: false, + customSessionAppNames: false, ...overrides.serverFeatures, }, }; diff --git a/test/unit/featureSet.test.ts b/test/unit/featureSet.test.ts index c17cd36dff..c67bffe295 100644 --- a/test/unit/featureSet.test.ts +++ b/test/unit/featureSet.test.ts @@ -96,6 +96,18 @@ describe("check version support", () => { } }); + it.each([ + ["2.37.99", false], + ["2.38.0-rc.1", false], + ["2.38.0", true], + ["0.0.0-devel+abc123", true], + ["invalid", false], + ] as const)("custom session app names for %s: %s", (version, expected) => { + const parsed = semver.parse(version); + expect(cliFeatureSet(parsed).customSessionAppNames).toBe(expected); + expect(serverFeatureSet(parsed).customSessionAppNames).toBe(expected); + }); + it("enables all features for development builds", () => { const featureSet = cliFeatureSet(semver.parse("v0.0.0-devel+abc123")); diff --git a/test/unit/remote/remote.test.ts b/test/unit/remote/remote.test.ts index 8139c57266..fe4d7cf6a5 100644 --- a/test/unit/remote/remote.test.ts +++ b/test/unit/remote/remote.test.ts @@ -1,15 +1,21 @@ +import { Api } from "coder/site/src/api/api"; import { vol } from "memfs"; +import * as semver from "semver"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; +import { version as cliVersion } from "@/core/cliExec"; import { MementoManager } from "@/core/mementoManager"; import { PathResolver } from "@/core/pathResolver"; import { SecretsManager } from "@/core/secretsManager"; +import { cliFeatureSet } from "@/featureSet"; import { buildSshProxyCommand, Remote, workspaceLabelSuffix, } from "@/remote/remote"; +import { sshSupportsSetEnv } from "@/remote/sshSupport"; +import { parseRemoteAuthority } from "@/util/authority"; import { createTestTelemetryService } from "../../mocks/telemetry"; import { @@ -34,6 +40,17 @@ const mockWorkspace = vscode.workspace as typeof vscode.workspace & { }; vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); +vi.mock("@/remote/windowsAcl", () => ({ + createManagedPermissions: () => undefined, +})); +vi.mock(import("@/core/cliExec"), async (importOriginal) => ({ + ...(await importOriginal()), + version: vi.fn(), +})); +vi.mock(import("@/remote/sshSupport"), async (importOriginal) => ({ + ...(await importOriginal()), + sshSupportsSetEnv: vi.fn(), +})); const SAFE_HOSTNAME = "coder.example.com"; const REMOTE_AUTHORITY = @@ -249,6 +266,105 @@ describe("Remote", () => { const elideNetworkInfoDir = (command: string) => command.replace(/--network-info-dir \S+/, "--network-info-dir "); + describe("session app", () => { + function mockClient(version = "2.38.0") { + const client = new Api(); + vi.spyOn(client, "getBuildInfo").mockResolvedValue({ + version, + external_url: "", + dashboard_url: "", + telemetry: false, + workspace_proxy: false, + agent_api_version: "", + provisioner_api_version: "", + upgrade_message: "", + deployment_id: "", + }); + vi.spyOn(client, "getDeploymentSSHConfig").mockResolvedValue({ + hostname_prefix: "", + hostname_suffix: "", + ssh_config_options: {}, + }); + return client; + } + + async function checkCompatibility(cli: string | Error, server: string) { + const { remote } = createRemote(); + if (cli instanceof Error) { + vi.mocked(cliVersion).mockRejectedValue(cli); + } else { + vi.mocked(cliVersion).mockResolvedValue(cli); + } + const method = "checkCompatibility"; + return remote[method]({ + workspaceClient: mockClient(server), + binaryPath: "/mock/coder", + baseUrl: CLI_AUTH.url, + safeHostname: SAFE_HOSTNAME, + }); + } + + it.each([ + ["cursor", "2.38.0", "2.38.0", "cursor"], + ["future-editor-next", "2.38.0", "2.38.0", "future-editor-next"], + ["", "2.38.0", "2.38.0", "vscode"], + ["cursor", "2.37.99", "2.38.0", "vscode"], + ["cursor", "2.38.0", "2.37.99", "vscode"], + ["cursor", "invalid", "2.38.0", "vscode"], + ["cursor", "2.38.0", "invalid", "vscode"], + ])( + "selects %s with CLI %s and server %s", + async (scheme, cli, server, expected) => { + useEditor(scheme); + expect((await checkCompatibility(cli, server)).sessionApp).toBe( + expected, + ); + }, + ); + + it("uses vscode when CLI probing fails without disabling unrelated features", async () => { + useEditor("cursor"); + const { sessionApp, cliFeatures } = await checkCompatibility( + new Error("unavailable"), + "2.38.0", + ); + expect(sessionApp).toBe("vscode"); + expect(cliFeatures.allowRedirects).toBe(true); + }); + + async function writeConfig(cli = "2.38.0", setEnv = true) { + const { remote } = createRemote(); + useEditor("cursor"); + vi.mocked(sshSupportsSetEnv).mockReturnValue(setEnv); + const method = "writeRemoteSshConfig"; + return remote[method]({ + workspaceClient: mockClient(), + parts: parseRemoteAuthority(REMOTE_AUTHORITY)!, + binaryPath: "/mock/coder", + logDir: "", + cliFeatures: cliFeatureSet(semver.parse(cli)), + cliAuth: CLI_AUTH, + sessionApp: "future-editor-next", + }); + } + + it("writes the same verbatim app name to both SSH mechanisms", async () => { + const config = await writeConfig(); + expect(config.proxycommand).toContain("--usage-app=future-editor-next "); + expect(config.setenv).toBe("CODER_SSH_SESSION_TYPE=future-editor-next"); + }); + + it("keeps the legacy proxy command without adding unsupported flags", async () => { + const config = await writeConfig("2.18.0"); + expect(config.proxycommand).toContain(" vscodessh "); + expect(config.proxycommand).not.toContain("--usage-app"); + }); + + it("omits SetEnv when unsupported by OpenSSH", async () => { + expect((await writeConfig("2.38.0", false)).setenv).toBeUndefined(); + }); + }); + describe("ProxyCommand", () => { it.each([ { @@ -273,6 +389,7 @@ describe("Remote", () => { binaryPath: "/mock/coder", cliAuth: CLI_AUTH, logArgs: [], + sessionApp: "vscode", hostPrefix: "coder-vscode.coder.example.com--", }); diff --git a/test/unit/remote/workspaceStateMachine.test.ts b/test/unit/remote/workspaceStateMachine.test.ts index f8453087ed..f6249c8022 100644 --- a/test/unit/remote/workspaceStateMachine.test.ts +++ b/test/unit/remote/workspaceStateMachine.test.ts @@ -118,7 +118,7 @@ function setup( startupMode, "/usr/bin/coder", {} as CliFeatureSet, - { tasks: false, onSuccessBuild: true }, + { tasks: false, onSuccessBuild: true, customSessionAppNames: true }, { store: "cli", url: "https://test.coder.com",