From 7b14be5c247fd8bf3d1277b8389143662ce36bb1 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 6 Oct 2026 08:57:19 +0000 Subject: [PATCH 1/3] feat: report actual IDE for session tracking --- src/featureSet.ts | 4 + src/remote/remote.ts | 40 +++--- test/unit/api/workspace.test.ts | 2 + test/unit/featureSet.test.ts | 24 ++++ test/unit/remote/remote.test.ts | 119 ++++++++++++++++++ .../unit/remote/workspaceStateMachine.test.ts | 2 +- 6 files changed, 176 insertions(+), 15 deletions(-) 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..26497b305b 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -310,16 +310,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. @@ -433,6 +432,7 @@ export class Remote { logDir, cliFeatures, cliAuth, + sessionApp, ), ); const remoteCommand = computedSshProperties.remotecommand; @@ -695,6 +695,7 @@ export class Remote { logDir: string, cliFeatures: CliFeatureSet, cliAuth: CliAuth, + sessionApp: string, ): Promise { try { this.logger.info("Updating SSH config..."); @@ -705,6 +706,7 @@ export class Remote { logDir, cliFeatures, cliAuth, + sessionApp, ); } catch (error) { this.logger.warn("Failed to configure SSH", error); @@ -779,19 +781,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 +811,9 @@ export class Remote { ); return { cliFeatures, - serverFeatures: serverFeatureSet(serverVersion), + serverFeatures, cliAuth, + sessionApp, }; } @@ -875,6 +886,7 @@ export class Remote { logDir: string, cliFeatures: CliFeatureSet, cliAuth: CliAuth, + sessionApp: string, ): Promise { // Taken from the authority, so a legacy host keeps working. const { hostPrefix, safeHostname, sshHost } = parts; @@ -936,6 +948,7 @@ export class Remote { binaryPath, cliAuth, logArgs: await this.getLogArgs(logDir), + sessionApp, }; const proxyCommand = cliFeatures.wildcardSSH ? buildSshProxyCommand({ ...proxyOptions, hostPrefix }) @@ -952,9 +965,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 +1106,7 @@ interface ProxyCommandOptions { binaryPath: string; cliAuth: CliAuth; logArgs: string[]; + sessionApp: string; } function coderCommand( @@ -1116,7 +1128,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..67fe4d4d6d 100644 --- a/test/unit/featureSet.test.ts +++ b/test/unit/featureSet.test.ts @@ -96,6 +96,30 @@ describe("check version support", () => { } }); + it.each([cliFeatureSet, serverFeatureSet])( + "gates custom session app names at 2.38", + (features) => { + for (const version of ["2.37.0", "2.37.99", "2.38.0-rc.1", "invalid"]) { + expect( + features(semver.parse(version)).customSessionAppNames, + version, + ).toBe(false); + } + for (const version of [ + "2.38.0", + "2.38.1", + "3.0.0", + "0.0.0-devel+abc123", + ]) { + expect( + features(semver.parse(version)).customSessionAppNames, + version, + ).toBe(true); + } + expect(features(null).customSessionAppNames).toBe(false); + }, + ); + 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..45a20b763a 100644 --- a/test/unit/remote/remote.test.ts +++ b/test/unit/remote/remote.test.ts @@ -1,7 +1,9 @@ +import { Api } from "coder/site/src/api/api"; import { vol } from "memfs"; 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"; @@ -10,6 +12,8 @@ import { Remote, workspaceLabelSuffix, } from "@/remote/remote"; +import { sshSupportsSetEnv } from "@/remote/sshSupport"; +import { parseRemoteAuthority } from "@/util/authority"; import { createTestTelemetryService } from "../../mocks/telemetry"; import { @@ -34,6 +38,14 @@ const mockWorkspace = vscode.workspace as typeof vscode.workspace & { }; vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); +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 +261,112 @@ describe("Remote", () => { const elideNetworkInfoDir = (command: string) => command.replace(/--network-info-dir \S+/, "--network-info-dir "); + interface SessionAppCase { + name: string; + scheme?: string; + cli?: string | Error; + server?: string; + app: string; + legacy?: boolean; + setEnv?: boolean; + } + + it.each([ + ...[ + "vscode", + "cursor", + "devin", + "windsurf", + "vscode-insiders", + "devin-next", + "vscodium-insiders", + "future-editor", + ].map((scheme) => ({ name: scheme, scheme, app: scheme })), + { name: "empty scheme", scheme: "", app: "vscode" }, + { name: "old CLI", cli: "2.37.99", app: "vscode" }, + { name: "old server", server: "2.37.99", app: "vscode" }, + { name: "both old", cli: "2.37.0", server: "2.37.0", app: "vscode" }, + { name: "invalid server", server: "invalid", app: "vscode" }, + { name: "unreadable CLI", cli: new Error("unavailable"), app: "vscode" }, + { name: "invalid CLI", cli: "invalid", legacy: true, app: "vscode" }, + { name: "legacy CLI", cli: "2.18.0", legacy: true, app: "vscode" }, + { name: "SSH without SetEnv", setEnv: false, app: "cursor" }, + ])( + "reports the session app for $name", + async ({ + scheme = "cursor", + cli = "2.38.0", + server = "2.38.0", + app, + legacy = false, + setEnv = true, + }) => { + const { remote } = createRemote(); + useEditor(scheme); + if (cli instanceof Error) { + vi.mocked(cliVersion).mockRejectedValue(cli); + } else { + vi.mocked(cliVersion).mockResolvedValue(cli); + } + vi.mocked(sshSupportsSetEnv).mockReturnValue(setEnv); + const client = new Api(); + vi.spyOn(client, "getBuildInfo").mockResolvedValue({ + version: server, + external_url: "", + dashboard_url: CLI_AUTH.url, + telemetry: false, + workspace_proxy: false, + agent_api_version: "2.11", + provisioner_api_version: "", + upgrade_message: "", + deployment_id: "", + }); + vi.spyOn(client, "getDeploymentSSHConfig").mockResolvedValue({ + hostname_prefix: "coder-", + hostname_suffix: "", + ssh_config_options: {}, + }); + const compatibilityMethod = "checkCompatibility"; + const { cliFeatures, cliAuth, sessionApp } = await remote[ + compatibilityMethod + ]({ + workspaceClient: client, + binaryPath: "/mock/coder", + baseUrl: CLI_AUTH.url, + safeHostname: SAFE_HOSTNAME, + }); + expect(sessionApp).toBe(app); + if (cli instanceof Error) { + expect(cliFeatures.customSessionAppNames).toBe(false); + expect(cliFeatures.allowRedirects).toBe(true); + } + // Host identity requires a non-empty scheme, independently of session reporting. + useEditor(scheme || "vscode"); + const configMethod = "updateSSHConfig"; + const properties = await remote[configMethod]( + client, + parseRemoteAuthority(REMOTE_AUTHORITY)!, + "/mock/coder", + "", + cliFeatures, + cliAuth, + sessionApp, + ); + expect(properties.setenv).toBe( + setEnv ? `CODER_SSH_SESSION_TYPE=${app}` : undefined, + ); + if (legacy) { + expect(properties.proxycommand).toContain(" vscodessh "); + expect(properties.proxycommand).not.toContain("--usage-app"); + } else { + expect(properties.proxycommand).toContain(`--usage-app=${app} `); + expect(properties.proxycommand).toContain( + "--ssh-host-prefix coder-vscode.", + ); + } + }, + ); + describe("ProxyCommand", () => { it.each([ { @@ -273,6 +391,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", From ce3f8b716062ea0a391f4bab65344103f8d1858c Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 6 Oct 2026 09:20:02 +0000 Subject: [PATCH 2/3] refactor: simplify session tracking setup and tests --- src/remote/remote.ts | 54 +++++----- test/unit/featureSet.test.ts | 34 +++---- test/unit/remote/remote.test.ts | 169 ++++++++++++++++---------------- 3 files changed, 118 insertions(+), 139 deletions(-) diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 26497b305b..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 @@ -425,15 +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,25 +699,11 @@ export class Remote { } private async writeRemoteSshConfig( - context: RemoteSetupContext, - workspaceClient: Api, - binaryPath: string, - logDir: string, - cliFeatures: CliFeatureSet, - cliAuth: CliAuth, - sessionApp: string, + options: SshConfigOptions, ): Promise { try { this.logger.info("Updating SSH config..."); - return await this.updateSSHConfig( - workspaceClient, - context.parts, - binaryPath, - logDir, - cliFeatures, - cliAuth, - sessionApp, - ); + return await this.updateSSHConfig(options); } catch (error) { this.logger.warn("Failed to configure SSH", error); throw error; @@ -879,15 +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, - sessionApp: string, - ): 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. @@ -913,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 }, diff --git a/test/unit/featureSet.test.ts b/test/unit/featureSet.test.ts index 67fe4d4d6d..c67bffe295 100644 --- a/test/unit/featureSet.test.ts +++ b/test/unit/featureSet.test.ts @@ -96,29 +96,17 @@ describe("check version support", () => { } }); - it.each([cliFeatureSet, serverFeatureSet])( - "gates custom session app names at 2.38", - (features) => { - for (const version of ["2.37.0", "2.37.99", "2.38.0-rc.1", "invalid"]) { - expect( - features(semver.parse(version)).customSessionAppNames, - version, - ).toBe(false); - } - for (const version of [ - "2.38.0", - "2.38.1", - "3.0.0", - "0.0.0-devel+abc123", - ]) { - expect( - features(semver.parse(version)).customSessionAppNames, - version, - ).toBe(true); - } - expect(features(null).customSessionAppNames).toBe(false); - }, - ); + 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 45a20b763a..46ff1ec26c 100644 --- a/test/unit/remote/remote.test.ts +++ b/test/unit/remote/remote.test.ts @@ -1,5 +1,6 @@ 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"; @@ -7,6 +8,7 @@ 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, @@ -261,111 +263,104 @@ describe("Remote", () => { const elideNetworkInfoDir = (command: string) => command.replace(/--network-info-dir \S+/, "--network-info-dir "); - interface SessionAppCase { - name: string; - scheme?: string; - cli?: string | Error; - server?: string; - app: string; - legacy?: boolean; - setEnv?: boolean; - } - - it.each([ - ...[ - "vscode", - "cursor", - "devin", - "windsurf", - "vscode-insiders", - "devin-next", - "vscodium-insiders", - "future-editor", - ].map((scheme) => ({ name: scheme, scheme, app: scheme })), - { name: "empty scheme", scheme: "", app: "vscode" }, - { name: "old CLI", cli: "2.37.99", app: "vscode" }, - { name: "old server", server: "2.37.99", app: "vscode" }, - { name: "both old", cli: "2.37.0", server: "2.37.0", app: "vscode" }, - { name: "invalid server", server: "invalid", app: "vscode" }, - { name: "unreadable CLI", cli: new Error("unavailable"), app: "vscode" }, - { name: "invalid CLI", cli: "invalid", legacy: true, app: "vscode" }, - { name: "legacy CLI", cli: "2.18.0", legacy: true, app: "vscode" }, - { name: "SSH without SetEnv", setEnv: false, app: "cursor" }, - ])( - "reports the session app for $name", - async ({ - scheme = "cursor", - cli = "2.38.0", - server = "2.38.0", - app, - legacy = false, - setEnv = true, - }) => { - const { remote } = createRemote(); - useEditor(scheme); - if (cli instanceof Error) { - vi.mocked(cliVersion).mockRejectedValue(cli); - } else { - vi.mocked(cliVersion).mockResolvedValue(cli); - } - vi.mocked(sshSupportsSetEnv).mockReturnValue(setEnv); + describe("session app", () => { + function mockClient(version = "2.38.0") { const client = new Api(); vi.spyOn(client, "getBuildInfo").mockResolvedValue({ - version: server, + version, external_url: "", - dashboard_url: CLI_AUTH.url, + dashboard_url: "", telemetry: false, workspace_proxy: false, - agent_api_version: "2.11", + agent_api_version: "", provisioner_api_version: "", upgrade_message: "", deployment_id: "", }); vi.spyOn(client, "getDeploymentSSHConfig").mockResolvedValue({ - hostname_prefix: "coder-", + hostname_prefix: "", hostname_suffix: "", ssh_config_options: {}, }); - const compatibilityMethod = "checkCompatibility"; - const { cliFeatures, cliAuth, sessionApp } = await remote[ - compatibilityMethod - ]({ - workspaceClient: client, + 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, }); - expect(sessionApp).toBe(app); - if (cli instanceof Error) { - expect(cliFeatures.customSessionAppNames).toBe(false); - expect(cliFeatures.allowRedirects).toBe(true); - } - // Host identity requires a non-empty scheme, independently of session reporting. - useEditor(scheme || "vscode"); - const configMethod = "updateSSHConfig"; - const properties = await remote[configMethod]( - client, - parseRemoteAuthority(REMOTE_AUTHORITY)!, - "/mock/coder", - "", - cliFeatures, - cliAuth, - sessionApp, - ); - expect(properties.setenv).toBe( - setEnv ? `CODER_SSH_SESSION_TYPE=${app}` : undefined, - ); - if (legacy) { - expect(properties.proxycommand).toContain(" vscodessh "); - expect(properties.proxycommand).not.toContain("--usage-app"); - } else { - expect(properties.proxycommand).toContain(`--usage-app=${app} `); - expect(properties.proxycommand).toContain( - "--ssh-host-prefix coder-vscode.", + } + + 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([ From 5b5045397fdca05c37f2107e46c0de2f433be67a Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 6 Oct 2026 09:27:32 +0000 Subject: [PATCH 3/3] test: isolate Windows ACL repair from in-memory SSH config --- test/unit/remote/remote.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit/remote/remote.test.ts b/test/unit/remote/remote.test.ts index 46ff1ec26c..fe4d7cf6a5 100644 --- a/test/unit/remote/remote.test.ts +++ b/test/unit/remote/remote.test.ts @@ -40,6 +40,9 @@ 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(),