From 5900c60627b2c98f47b9741f6c7e227fe02db5b0 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Jul 2026 16:19:31 +0300 Subject: [PATCH 1/4] feat: collect remote editor logs in support bundles Resolve the active remote editor's server data directory and pass its log glob to the CLI as --workspace-file so support bundles include the remote editor logs. The path resolves from Remote-SSH settings (mirroring each fork's serverInstallPath semantics) with the exec server's VSCODE_AGENT_FOLDER as fallback, matching the server's own precedence, and validates every source against globs, variables, and traversal. Gated on CLI support for workspace files. --- src/commands.ts | 43 ++- src/core/cliExec.ts | 11 +- src/error/errorUtils.ts | 29 +- src/featureSet.ts | 3 + src/supportBundle/remoteServerDataPath.ts | 230 +++++++++++++ src/supportBundle/workspaceFiles.ts | 86 +++++ src/typings/vscode.proposed.resolvers.d.ts | 13 + test/mocks/vscode.runtime.ts | 1 + test/unit/api/workspace.test.ts | 1 + test/unit/commands.supportBundle.test.ts | 193 +++++++++++ test/unit/core/cliExec.test.ts | 41 ++- test/unit/error/errorUtils.test.ts | 33 +- test/unit/featureSet.test.ts | 19 +- .../remoteServerDataPath.test.ts | 306 ++++++++++++++++++ .../unit/supportBundle/workspaceFiles.test.ts | 142 ++++++++ 15 files changed, 1137 insertions(+), 14 deletions(-) create mode 100644 src/supportBundle/remoteServerDataPath.ts create mode 100644 src/supportBundle/workspaceFiles.ts create mode 100644 test/unit/commands.supportBundle.test.ts create mode 100644 test/unit/supportBundle/remoteServerDataPath.test.ts create mode 100644 test/unit/supportBundle/workspaceFiles.test.ts diff --git a/src/commands.ts b/src/commands.ts index d85f59df03..981b255f4e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -12,7 +12,7 @@ import { import { runDiagnosticCli } from "./command/diagnosticFlow"; import * as cliExec from "./core/cliExec"; import { CertificateError } from "./error/certificateError"; -import { toError } from "./error/errorUtils"; +import { raceWithAbort, toError } from "./error/errorUtils"; import { type FeatureSet, featureSetForVersion } from "./featureSet"; import { AuthTelemetry, @@ -45,8 +45,9 @@ import { } from "./remote/sshOverrides"; import { resolveCliAuth } from "./settings/cli"; import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs"; +import { getRemoteEditorLogGlobs } from "./supportBundle/workspaceFiles"; import { runExportTelemetryCommand } from "./telemetry/export/command"; -import { toRemoteAuthority } from "./util/authority"; +import { parseRemoteAuthority, toRemoteAuthority } from "./util/authority"; import { openInBrowser, toSafeHost } from "./util/uri"; import { vscodeProposed } from "./vscodeProposed"; import { parseNetcheckReport } from "./webviews/netcheck/types"; @@ -99,8 +100,11 @@ interface LoginArgs { type WorkspaceResolution = | { readonly status: "selected"; + readonly agentName?: string; readonly client: CoderApi; readonly workspaceId: string; + /** Active authority, only when it identifies this workspace. */ + readonly remoteAuthority?: string; } | { readonly status: "cancelled" } | { @@ -424,7 +428,7 @@ export class Commands { return; } - const { client, workspaceId } = resolved; + const { agentName, client, workspaceId, remoteAuthority } = resolved; const outputUri = await this.promptSupportBundlePath(); if (!outputUri) { @@ -435,13 +439,30 @@ export class Commands { const result = await withCancellableProgress( async ({ signal, progress }) => { progress.report({ message: "Resolving CLI..." }); + // Independent of the CLI and never rejects, so resolve the globs + // concurrently and discard them when the CLI lacks support. + const remoteLogGlobs = getRemoteEditorLogGlobs({ + appRoot: vscode.env.appRoot, + remoteAuthority, + logger: this.logger, + }); const env = await this.resolveCliEnv(client); if (!env.featureSet.supportBundle) { throw new SupportBundleUnsupportedCliError(); } + // The resolution APIs take no signal, so race to keep cancel honest. + const workspaceFiles = env.featureSet.supportBundleWorkspaceFiles + ? await raceWithAbort(remoteLogGlobs, signal) + : []; + progress.report({ message: "Collecting diagnostics..." }); - await cliExec.supportBundle(env, workspaceId, outputUri.fsPath, signal); + await cliExec.supportBundle(env, workspaceId, { + outputPath: outputUri.fsPath, + agentName, + workspaceFiles, + signal, + }); progress.report({ message: "Adding VS Code logs..." }); await appendVsCodeLogs( @@ -1133,15 +1154,29 @@ export class Commands { if (item) { return { status: "selected", + agentName: item instanceof AgentTreeItem ? item.agent.name : undefined, client: this.extensionClient, workspaceId: createWorkspaceIdentifier(item.workspace), }; } if (this.workspace && this.remoteWorkspaceClient) { + const remoteAuthority = vscodeProposed.env.remoteAuthority; + // Agent resolution is best-effort; a malformed authority must not + // block diagnostics for the whole workspace. + let agentName: string | undefined; + try { + agentName = remoteAuthority + ? parseRemoteAuthority(remoteAuthority)?.agent || undefined + : undefined; + } catch (error) { + this.logger.warn("Could not resolve the connected agent", error); + } return { status: "selected", + agentName, client: this.remoteWorkspaceClient, workspaceId: createWorkspaceIdentifier(this.workspace), + remoteAuthority, }; } const pick = await this.pickWorkspace("diagnostic", { diff --git a/src/core/cliExec.ts b/src/core/cliExec.ts index d25980c1e6..04df34ec13 100644 --- a/src/core/cliExec.ts +++ b/src/core/cliExec.ts @@ -105,18 +105,25 @@ export async function netcheck( export async function supportBundle( env: CliEnv, workspaceName: string, - outputPath: string, - signal?: AbortSignal, + options: { + outputPath: string; + agentName?: string; + workspaceFiles?: readonly string[]; + signal?: AbortSignal; + }, ): Promise { + const { outputPath, agentName, workspaceFiles = [], signal } = options; const globalFlags = getGlobalFlags(env.configs, env.auth); const args = [ ...globalFlags, "support", "bundle", workspaceName, + ...(agentName ? [agentName] : []), "--output-file", outputPath, "--yes", + ...workspaceFiles.flatMap((file) => ["--workspace-file", file]), ]; try { await execFileAsync(env.binary, args, { signal }); diff --git a/src/error/errorUtils.ts b/src/error/errorUtils.ts index b4f3b3c152..d8145080be 100644 --- a/src/error/errorUtils.ts +++ b/src/error/errorUtils.ts @@ -14,12 +14,37 @@ export function isAbortError(error: unknown): error is Error { */ export function throwIfAborted(signal: AbortSignal | undefined): void { if (!signal?.aborted) return; - const reason: unknown = signal.reason; - throw reason instanceof Error + throw toAbortError(signal.reason); +} + +function toAbortError(reason: unknown): Error { + return reason instanceof Error ? reason : Object.assign(new Error("Aborted"), { name: "AbortError" }); } +/** + * Await `promise`, rejecting with an AbortError as soon as `signal` aborts. + * The underlying work is abandoned, not stopped; use for operations that + * cannot take a signal themselves. + */ +export async function raceWithAbort( + promise: Promise, + signal: AbortSignal, +): Promise { + throwIfAborted(signal); + let onAbort!: () => void; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(toAbortError(signal.reason)); + }); + signal.addEventListener("abort", onAbort, { once: true }); + try { + return await Promise.race([promise, aborted]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + // getErrorDetail is copied from coder/site, but changes the default return. export const getErrorDetail = (error: unknown): string | undefined | null => { if (isApiError(error)) { diff --git a/src/featureSet.ts b/src/featureSet.ts index 57a3b1d684..2a209076eb 100644 --- a/src/featureSet.ts +++ b/src/featureSet.ts @@ -9,6 +9,7 @@ export interface FeatureSet { keyringAuth: boolean; tokenRead: boolean; supportBundle: boolean; + supportBundleWorkspaceFiles: boolean; } /** @@ -49,5 +50,7 @@ export function featureSetForVersion( tokenRead: versionAtLeast(version, "2.31.0"), // `coder support bundle` (officially released/unhidden in 2.10.0) supportBundle: versionAtLeast(version, "2.10.0"), + // --workspace-file flag for `coder support bundle` + supportBundleWorkspaceFiles: versionAtLeast(version, "2.36.0"), }; } diff --git a/src/supportBundle/remoteServerDataPath.ts b/src/supportBundle/remoteServerDataPath.ts new file mode 100644 index 0000000000..f2654a0bde --- /dev/null +++ b/src/supportBundle/remoteServerDataPath.ts @@ -0,0 +1,230 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { + getRemoteSshExtension, + type RemoteSshExtensionId, +} from "../remote/sshExtension"; +import { parseRemoteAuthority } from "../util/authority"; +import { vscodeProposed } from "../vscodeProposed"; + +import type { Logger } from "../logging/logger"; + +interface RemoteServerDataPathOptions { + readonly remoteAuthority: string; + /** Product-specific server directory name, such as `.vscode-server`. */ + readonly serverDataFolderName?: string; + readonly logger: Logger; +} + +export interface RemoteServerDataPath { + readonly value: string; + readonly style: "posix" | "win32"; +} + +/** Remote-SSH implementations where `serverInstallPath` names a parent directory. */ +const parentInstallPathExtensions: readonly RemoteSshExtensionId[] = [ + "anysphere.remote-ssh", + "ms-vscode-remote.remote-ssh", +]; + +/** + * Resolve the active remote server's data directory when possible. + * + * Precedence mirrors the server's own resolution, the `--server-data-dir` + * flag over `VSCODE_AGENT_FOLDER` over the home default: supported + * implementations pass `serverInstallPath` as that flag, so its + * interpretation outranks the environment variable. + * @see https://github.com/microsoft/vscode/blob/085b6a1465387d070516ba8a640ccfed66417796/src/vs/server/node/server.main.ts#L39 + */ +export async function getRemoteServerDataPath({ + remoteAuthority, + serverDataFolderName, + logger, +}: RemoteServerDataPathOptions): Promise { + const configured = serverDataFolderName + ? getConfiguredServerDataPath(remoteAuthority, serverDataFolderName, logger) + : undefined; + return configured ?? getActiveServerDataPath(remoteAuthority, logger); +} + +/** + * Append known editor log locations. Globs always use forward slashes: + * doublestar only matches on `/`, and the agent normalizes Windows paths + * to forward slashes before matching. + */ +export function toRemoteLogGlobs({ + value, + style, +}: RemoteServerDataPath): readonly string[] { + const base = style === "win32" ? value.replaceAll("\\", "/") : value; + return [path.posix.join(base, "data", "logs", "**", "*.log")]; +} + +async function getActiveServerDataPath( + remoteAuthority: string, + logger: Logger, +): Promise { + try { + if (!vscodeProposed.workspace.getRemoteExecServer) { + return undefined; + } + const execServer = + await vscodeProposed.workspace.getRemoteExecServer(remoteAuthority); + if (!execServer) { + return undefined; + } + const { env, osPlatform } = await execServer.env(); + const value = env.VSCODE_AGENT_FOLDER; + if (value === undefined) { + return undefined; + } + const style = pathStyleForPlatform(osPlatform); + if (!isSafeAbsolutePath(value, style)) { + logger.warn(`Ignoring unsafe VSCODE_AGENT_FOLDER value: ${value}`); + return undefined; + } + return { value, style }; + } catch (error) { + logger.warn( + "Could not resolve the remote server data path from the active environment", + error, + ); + return undefined; + } +} + +function getConfiguredServerDataPath( + remoteAuthority: string, + serverDataFolderName: string, + logger: Logger, +): RemoteServerDataPath | undefined { + try { + const parts = parseRemoteAuthority(remoteAuthority); + const extensionId = getRemoteSshExtension()?.id; + if (!parts || !extensionId) { + return undefined; + } + + const config = vscode.workspace.getConfiguration("remote.SSH"); + const installPaths = config.get>( + "serverInstallPath", + {}, + ); + let installPath: string | undefined; + if (extensionId === "jeanp413.open-remote-ssh") { + installPath = findOpenRemoteSshInstallPath(parts.sshHost, installPaths); + } else if (parentInstallPathExtensions.includes(extensionId)) { + installPath = installPaths[parts.sshHost]; + } + if (!installPath) { + return undefined; + } + + const remotePlatforms = config.get>( + "remotePlatform", + {}, + ); + const style = configuredPathStyle( + installPath, + remotePlatforms[parts.sshHost], + ); + if (!isSafeAbsolutePath(installPath, style)) { + logger.warn( + `Ignoring unsafe remote.SSH.serverInstallPath value: ${installPath}`, + ); + return undefined; + } + + if (extensionId === "jeanp413.open-remote-ssh") { + return { value: installPath, style }; + } + + const remotePath = path[style]; + // Cursor accepts the product folder itself despite documenting a parent. + // Its installer strips this suffix before consistently re-appending it. + const parentPath = + extensionId === "anysphere.remote-ssh" && + remotePath.basename(installPath) === serverDataFolderName + ? remotePath.dirname(installPath) + : installPath; + return { + value: remotePath.join(parentPath, serverDataFolderName), + style, + }; + } catch (error) { + logger.warn( + "Could not resolve the remote server data path from Remote-SSH settings", + error, + ); + return undefined; + } +} + +/** + * Match Open Remote SSH's exact > specific wildcard > `*` precedence. + * @see https://github.com/jeanp413/open-remote-ssh/blob/3ba888b808bcbf224f71f142072dde0617f55c28/src/serverSetup.ts#L22-L74 + */ +function findOpenRemoteSshInstallPath( + hostname: string, + pathMap: Readonly>, +): string | undefined { + let bestMatch: { readonly path: string; readonly score: number } | undefined; + for (const [pattern, installPath] of Object.entries(pathMap)) { + const score = hostnamePatternScore(hostname, pattern); + if (score > 0 && (!bestMatch || score > bestMatch.score)) { + bestMatch = { path: installPath, score }; + } + } + return bestMatch?.path; +} + +function hostnamePatternScore(hostname: string, pattern: string): number { + if (hostname === pattern) { + return 1000; + } + if (pattern === "*") { + return 1; + } + const expression = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + return new RegExp(`^${expression}$`).test(hostname) + ? 10 + pattern.replace(/\*/g, "").length + : -1; +} + +function pathStyleForPlatform(platform: string): RemoteServerDataPath["style"] { + return platform === "win32" || platform === "windows" ? "win32" : "posix"; +} + +function configuredPathStyle( + value: string, + platform: string | undefined, +): RemoteServerDataPath["style"] { + if (platform) { + return pathStyleForPlatform(platform); + } + // remotePlatform can be absent with RemoteCommand. Only infer Windows for + // unambiguous drive-letter or UNC paths; every other absolute path is POSIX. + return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("\\\\") + ? "win32" + : "posix"; +} + +/** Reject variables and glob syntax that could broaden workspace collection. */ +export function hasUnsafePathChars(value: string): boolean { + return /[\0$*?[\]{}]/.test(value); +} + +/** Absolute, no unsafe characters, and no `..` segments to traverse out. */ +function isSafeAbsolutePath( + value: string, + style: RemoteServerDataPath["style"], +): boolean { + return ( + path[style].isAbsolute(value) && + !hasUnsafePathChars(value) && + !value.split(/[\\/]/).includes("..") + ); +} diff --git a/src/supportBundle/workspaceFiles.ts b/src/supportBundle/workspaceFiles.ts new file mode 100644 index 0000000000..e81d732ece --- /dev/null +++ b/src/supportBundle/workspaceFiles.ts @@ -0,0 +1,86 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { + getRemoteServerDataPath, + hasUnsafePathChars, + toRemoteLogGlobs, +} from "./remoteServerDataPath"; + +import type { Logger } from "../logging/logger"; + +interface ProductConfiguration { + /** + * Default remote server data directory beneath the user's home directory. + * @see https://github.com/microsoft/vscode/blob/085b6a1465387d070516ba8a640ccfed66417796/src/vs/server/node/server.main.ts#L39 + */ + serverDataFolderName?: unknown; +} + +interface RemoteEditorLogOptions { + /** The local editor's install root, `vscode.env.appRoot`. */ + readonly appRoot: string; + /** The active authority, only when it targets the support bundle workspace. */ + readonly remoteAuthority?: string; + readonly logger: Logger; +} + +/** Return known remote server log globs for the target workspace. */ +export async function getRemoteEditorLogGlobs({ + appRoot, + remoteAuthority, + logger, +}: RemoteEditorLogOptions): Promise { + const serverDataFolderName = await readServerDataFolderName(appRoot, logger); + const serverDataPath = remoteAuthority + ? await getRemoteServerDataPath({ + remoteAuthority, + serverDataFolderName, + logger, + }) + : undefined; + if (serverDataPath) { + return toRemoteLogGlobs(serverDataPath); + } + // The agent expands `~/` against the remote home directory and normalizes + // separators before glob matching, so the posix style is portable here. + if (serverDataFolderName) { + return toRemoteLogGlobs({ + value: `~/${serverDataFolderName}`, + style: "posix", + }); + } + return []; +} + +async function readServerDataFolderName( + appRoot: string, + logger: Logger, +): Promise { + try { + const productJson = await fs.readFile( + path.join(appRoot, "product.json"), + "utf-8", + ); + const product = JSON.parse(productJson) as ProductConfiguration; + return isSafeServerDataFolderName(product.serverDataFolderName) + ? product.serverDataFolderName + : undefined; + } catch (error) { + logger.warn("Could not read the editor's product metadata", error); + return undefined; + } +} + +/** Return whether the value is a single portable path segment. */ +function isSafeServerDataFolderName(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value !== "." && + value !== ".." && + !hasUnsafePathChars(value) && + path.posix.basename(value) === value && + path.win32.basename(value) === value + ); +} diff --git a/src/typings/vscode.proposed.resolvers.d.ts b/src/typings/vscode.proposed.resolvers.d.ts index 2634fb01c6..1c36d086ba 100644 --- a/src/typings/vscode.proposed.resolvers.d.ts +++ b/src/typings/vscode.proposed.resolvers.d.ts @@ -201,6 +201,16 @@ declare module "vscode" { stripPathStartingSeparator?: boolean; } + export interface ExecEnvironment { + readonly env: Record; + readonly osPlatform: string; + readonly osRelease?: string; + } + + export interface ExecServer { + env(): Thenable; + } + export namespace workspace { export function registerRemoteAuthorityResolver( authorityPrefix: string, @@ -209,6 +219,9 @@ declare module "vscode" { export function registerResourceLabelFormatter( formatter: ResourceLabelFormatter, ): Disposable; + export function getRemoteExecServer( + authority: string, + ): Thenable; } export namespace env { diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 55cd482e7d..90d430dea8 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -180,6 +180,7 @@ export const commands = { export const workspace = { getConfiguration: vi.fn(), // your helpers override this + getRemoteExecServer: vi.fn(), workspaceFolders: [] as unknown[], fs: { readFile: vi.fn(), diff --git a/test/unit/api/workspace.test.ts b/test/unit/api/workspace.test.ts index fea30aeef3..df55fb48ee 100644 --- a/test/unit/api/workspace.test.ts +++ b/test/unit/api/workspace.test.ts @@ -30,6 +30,7 @@ const featureSet: FeatureSet = { keyringAuth: true, tokenRead: true, supportBundle: true, + supportBundleWorkspaceFiles: true, }; function mockStream(): UnidirectionalStream { diff --git a/test/unit/commands.supportBundle.test.ts b/test/unit/commands.supportBundle.test.ts new file mode 100644 index 0000000000..b04d5fc34d --- /dev/null +++ b/test/unit/commands.supportBundle.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { Commands } from "@/commands"; +import * as cliExec from "@/core/cliExec"; +import { appendVsCodeLogs } from "@/supportBundle/appendVsCodeLogs"; +import { getRemoteEditorLogGlobs } from "@/supportBundle/workspaceFiles"; +import { AgentTreeItem } from "@/workspace/workspacesProvider"; + +import { createTelemetryHarness } from "../mocks/telemetry"; +import { + config, + createMockLogger, + MockProgressReporter, +} from "../mocks/testHelpers"; + +import type { + Workspace, + WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; + +import type { CoderApi } from "@/api/coderApi"; +import type { ServiceContainer } from "@/core/container"; +import type { DeploymentManager } from "@/deployment/deploymentManager"; + +vi.mock("@/core/cliExec", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, version: vi.fn(), supportBundle: vi.fn() }; +}); + +vi.mock("@/supportBundle/appendVsCodeLogs", () => ({ + appendVsCodeLogs: vi.fn(), +})); + +vi.mock("@/supportBundle/workspaceFiles", () => ({ + getRemoteEditorLogGlobs: vi.fn(), +})); + +const OUTPUT_PATH = "/tmp/bundle.zip"; +const REMOTE_LOG_GLOBS = ["~/.vscode-server/data/logs/**/*.log"]; +const workspace = { + owner_name: "owner", + name: "ws", + latest_build: { status: "running" }, +} as Workspace; + +function setup(options: { cliVersion?: string } = {}) { + vi.clearAllMocks(); + new MockProgressReporter(); + config({}); + setRemoteAuthority(undefined); + const { service } = createTelemetryHarness(); + + vi.mocked(vscode.window.showSaveDialog).mockResolvedValue( + vscode.Uri.file(OUTPUT_PATH), + ); + vi.mocked(cliExec.version).mockResolvedValue(options.cliVersion ?? "v2.36.0"); + vi.mocked(cliExec.supportBundle).mockResolvedValue(undefined); + vi.mocked(getRemoteEditorLogGlobs).mockResolvedValue(REMOTE_LOG_GLOBS); + vi.mocked(appendVsCodeLogs).mockResolvedValue(undefined); + + const logger = createMockLogger(); + const serviceContainer = { + getTelemetryService: () => service, + getLogger: () => logger, + getPathResolver: () => ({ + getGlobalConfigDir: () => "/cfg", + getProxyLogPath: () => "/logs/proxy", + getCodeLogDir: () => "/logs/code", + getTelemetryPath: () => "/logs/telemetry", + }), + getMementoManager: () => ({}), + getSecretsManager: () => ({}), + getCliManager: () => ({ + locateBinary: vi.fn(() => Promise.resolve("/bin/coder")), + configure: vi.fn(() => Promise.resolve()), + }), + getLoginCoordinator: () => ({}), + getDuplicateWorkspaceIpc: () => ({}), + getSpeedtestPanelFactory: () => ({}), + getNetcheckPanelFactory: () => ({}), + } as unknown as ServiceContainer; + + const client = { + getAxiosInstance: () => ({ defaults: { baseURL: "https://coder.test" } }), + getSessionToken: () => "token", + } as unknown as CoderApi; + + const commands = new Commands( + serviceContainer, + client, + {} as DeploymentManager, + ); + + return { commands, client, logger }; +} + +function setRemoteAuthority(value: string | undefined): void { + (vscode.env as { remoteAuthority?: string }).remoteAuthority = value; +} + +function agentItem(agentName: string): AgentTreeItem { + const agent = { id: "agent-id", name: agentName } as WorkspaceAgent; + return new AgentTreeItem(agent, workspace); +} + +function connectToWorkspace( + commands: Commands, + client: CoderApi, + remoteAuthority: string, +): void { + commands.workspace = workspace; + commands.remoteWorkspaceClient = client; + setRemoteAuthority(remoteAuthority); +} + +describe("Commands.supportBundle", () => { + it("collects the selected agent's bundle with remote log globs", async () => { + const { commands } = setup(); + + await commands.supportBundle(agentItem("dev")); + + expect(cliExec.supportBundle).toHaveBeenCalledWith( + expect.anything(), + "owner/ws", + expect.objectContaining({ + outputPath: OUTPUT_PATH, + agentName: "dev", + workspaceFiles: REMOTE_LOG_GLOBS, + }), + ); + // No item authority: remote logs cannot target the workspace. + expect(getRemoteEditorLogGlobs).toHaveBeenCalledWith( + expect.objectContaining({ remoteAuthority: undefined }), + ); + }); + + it("derives the agent and remote authority from the active connection", async () => { + const { commands, client } = setup(); + const remoteAuthority = "ssh-remote+coder-vscode.example--owner--ws.main"; + connectToWorkspace(commands, client, remoteAuthority); + + await commands.supportBundle(); + + expect(cliExec.supportBundle).toHaveBeenCalledWith( + expect.anything(), + "owner/ws", + expect.objectContaining({ agentName: "main" }), + ); + expect(getRemoteEditorLogGlobs).toHaveBeenCalledWith( + expect.objectContaining({ remoteAuthority }), + ); + }); + + it("degrades the agent to undefined for a malformed Coder authority", async () => { + const { commands, client } = setup(); + connectToWorkspace(commands, client, "ssh-remote+coder-vscode.malformed"); + + await commands.supportBundle(); + + expect(cliExec.supportBundle).toHaveBeenCalledWith( + expect.anything(), + "owner/ws", + expect.objectContaining({ agentName: undefined }), + ); + }); + + it("skips remote log collection when the CLI lacks workspace file support", async () => { + const { commands } = setup({ cliVersion: "v2.35.0" }); + + await commands.supportBundle(agentItem("dev")); + + expect(cliExec.supportBundle).toHaveBeenCalledWith( + expect.anything(), + "owner/ws", + expect.objectContaining({ workspaceFiles: [] }), + ); + }); + + describe("logging", () => { + it("warns when the connected agent cannot be resolved", async () => { + const { commands, client, logger } = setup(); + connectToWorkspace(commands, client, "ssh-remote+coder-vscode.malformed"); + + await commands.supportBundle(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.any(String), + expect.any(Error), + ); + }); + }); +}); diff --git a/test/unit/core/cliExec.test.ts b/test/unit/core/cliExec.test.ts index 27548c4172..7fb81a5014 100644 --- a/test/unit/core/cliExec.test.ts +++ b/test/unit/core/cliExec.test.ts @@ -271,7 +271,14 @@ describe("cliExec", () => { bin, ); configs.set("coder.headerCommand", "my-header-cmd"); - await cliExec.supportBundle(env, "owner/workspace", outputPath); + await cliExec.supportBundle(env, "owner/workspace", { + outputPath, + agentName: "dev", + workspaceFiles: [ + "$HOME/.vscode-server/data/logs/**/*.log", + "$HOME/.cursor-server/data/logs/**/*.log", + ], + }); const args = (await fs.readFile(outputPath, "utf-8")).trim().split("\n"); expect(args).toEqual([ "--url", @@ -281,6 +288,34 @@ describe("cliExec", () => { "support", "bundle", "owner/workspace", + "dev", + "--output-file", + outputPath, + "--yes", + "--workspace-file", + "$HOME/.vscode-server/data/logs/**/*.log", + "--workspace-file", + "$HOME/.cursor-server/data/logs/**/*.log", + ]); + }); + + it("omits the agent and workspace files when not provided", async () => { + const code = [ + `const args = process.argv.slice(2);`, + `const idx = args.indexOf("--output-file");`, + `if (idx !== -1) { require("fs").writeFileSync(args[idx+1], args.join("\\n")); }`, + ].join("\n"); + const bin = await writeExecutable(tmp, "sb-echo-defaults", code); + const outputPath = path.join(tmp, "sb-defaults-output.zip"); + const { env } = setup({ mode: "url", url: "http://localhost:3000" }, bin); + await cliExec.supportBundle(env, "owner/workspace", { outputPath }); + const args = (await fs.readFile(outputPath, "utf-8")).trim().split("\n"); + expect(args).toEqual([ + "--url", + "http://localhost:3000", + "support", + "bundle", + "owner/workspace", "--output-file", outputPath, "--yes", @@ -298,7 +333,9 @@ describe("cliExec", () => { bin, ); await expect( - cliExec.supportBundle(env, "owner/workspace", "/tmp/bundle.zip"), + cliExec.supportBundle(env, "owner/workspace", { + outputPath: "/tmp/bundle.zip", + }), ).rejects.toThrow("workspace not found"); }); }); diff --git a/test/unit/error/errorUtils.test.ts b/test/unit/error/errorUtils.test.ts index 1aa14617e6..b6691f06fa 100644 --- a/test/unit/error/errorUtils.test.ts +++ b/test/unit/error/errorUtils.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; -import { getErrorDetail, isAbortError, toError } from "@/error/errorUtils"; +import { + getErrorDetail, + isAbortError, + raceWithAbort, + toError, +} from "@/error/errorUtils"; describe("isAbortError", () => { it("returns true for an Error named AbortError", () => { @@ -43,6 +48,32 @@ describe("isAbortError", () => { }); }); +describe("raceWithAbort", () => { + it("resolves with the promise result when not aborted", async () => { + const ac = new AbortController(); + await expect( + raceWithAbort(Promise.resolve("done"), ac.signal), + ).resolves.toBe("done"); + }); + + it("rejects with an AbortError when the signal aborts first", async () => { + const ac = new AbortController(); + const hanging = new Promise(() => {}); + const raced = raceWithAbort(hanging, ac.signal); + ac.abort(); + await expect(raced).rejects.toSatisfy(isAbortError); + }); + + it("rejects immediately when the signal is already aborted", async () => { + const ac = new AbortController(); + ac.abort(); + const hanging = new Promise(() => {}); + await expect(raceWithAbort(hanging, ac.signal)).rejects.toSatisfy( + isAbortError, + ); + }); +}); + describe("getErrorDetail", () => { it("returns detail from API error", () => { const error = { diff --git a/test/unit/featureSet.test.ts b/test/unit/featureSet.test.ts index 4b6bf6b09a..d8d91637f6 100644 --- a/test/unit/featureSet.test.ts +++ b/test/unit/featureSet.test.ts @@ -14,9 +14,6 @@ function expectFlag( for (const v of atOrAbove) { expect(featureSetForVersion(semver.parse(v))[flag]).toBeTruthy(); } - expect( - featureSetForVersion(semver.parse("0.0.0-devel+abc123"))[flag], - ).toBeTruthy(); } describe("check version support", () => { @@ -62,4 +59,20 @@ describe("check version support", () => { ["v2.10.0", "v2.10.1", "v2.11.0", "v3.0.0"], ); }); + it("support bundle workspace files", () => { + expectFlag( + "supportBundleWorkspaceFiles", + ["v2.35.0", "v2.35.2", "v2.35.99"], + ["v2.36.0", "v2.36.1", "v2.37.0", "v3.0.0"], + ); + }); + it("enables all features for development builds", () => { + const featureSet = featureSetForVersion( + semver.parse("v0.0.0-devel+abc123"), + ); + + for (const [feature, enabled] of Object.entries(featureSet)) { + expect(enabled, feature).toBe(true); + } + }); }); diff --git a/test/unit/supportBundle/remoteServerDataPath.test.ts b/test/unit/supportBundle/remoteServerDataPath.test.ts new file mode 100644 index 0000000000..853070d9f3 --- /dev/null +++ b/test/unit/supportBundle/remoteServerDataPath.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { + getRemoteServerDataPath, + toRemoteLogGlobs, +} from "@/supportBundle/remoteServerDataPath"; + +import { config, createMockLogger } from "../../mocks/testHelpers"; + +const sshHost = "coder-vscode.example--owner--workspace.agent"; +const remoteAuthority = `ssh-remote+${sshHost}`; +const serverDataFolderName = ".vscode-server"; + +type ResolveOptions = Parameters[0]; + +function setup() { + vi.mocked(vscode.workspace.getRemoteExecServer).mockReset(); + vi.mocked(vscode.extensions.getExtension).mockReset(); + vi.mocked(vscode.workspace.getRemoteExecServer).mockResolvedValue(undefined); + setRemoteSshConfiguration({}); + const logger = createMockLogger(); + const resolve = (overrides: Partial = {}) => + getRemoteServerDataPath({ + remoteAuthority, + serverDataFolderName, + logger, + ...overrides, + }); + return { logger, resolve }; +} + +function useRemoteSshExtension(id: string): void { + vi.mocked(vscode.extensions.getExtension).mockImplementation( + (extensionId) => + (extensionId === id ? { id: extensionId } : undefined) as + vscode.Extension | undefined, + ); +} + +function setRemoteSshConfiguration(options: { + readonly installPaths?: Record; + readonly remotePlatforms?: Record; +}): void { + config({ + "remote.SSH.serverInstallPath": options.installPaths ?? {}, + "remote.SSH.remotePlatform": options.remotePlatforms ?? {}, + }); +} + +function useActiveServerDataPath(value: string, osPlatform = "linux"): void { + vi.mocked(vscode.workspace.getRemoteExecServer).mockResolvedValue({ + env: vi.fn().mockResolvedValue({ + env: { VSCODE_AGENT_FOLDER: value }, + osPlatform, + }), + }); +} + +describe("getRemoteServerDataPath", () => { + it("uses the active exec server environment", async () => { + const { resolve } = setup(); + useActiveServerDataPath("/srv/vscode"); + + await expect(resolve()).resolves.toEqual({ + value: "/srv/vscode", + style: "posix", + }); + }); + + it("uses the active environment without product metadata", async () => { + const { resolve } = setup(); + useActiveServerDataPath("/srv/vscode"); + + await expect(resolve({ serverDataFolderName: undefined })).resolves.toEqual( + { value: "/srv/vscode", style: "posix" }, + ); + }); + + it("uses the active environment platform for Windows paths", async () => { + const { resolve } = setup(); + useActiveServerDataPath("C:\\Users\\coder\\.vscode-server", "win32"); + + await expect(resolve()).resolves.toEqual({ + value: "C:\\Users\\coder\\.vscode-server", + style: "win32", + }); + }); + + it("rejects an unsafe active environment path", async () => { + const { resolve } = setup(); + useActiveServerDataPath("$HOME/.vscode-server"); + + await expect(resolve()).resolves.toBeUndefined(); + }); + + it.each(["ms-vscode-remote.remote-ssh", "anysphere.remote-ssh"])( + "appends the product folder for %s", + async (extensionId) => { + const { resolve } = setup(); + useRemoteSshExtension(extensionId); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "/srv/editor" }, + remotePlatforms: { [sshHost]: "linux" }, + }); + + await expect(resolve()).resolves.toEqual({ + value: "/srv/editor/.vscode-server", + style: "posix", + }); + }, + ); + + it("prefers the configured path over the active environment", async () => { + const { resolve } = setup(); + useRemoteSshExtension("ms-vscode-remote.remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "/srv/editor" }, + remotePlatforms: { [sshHost]: "linux" }, + }); + useActiveServerDataPath("/srv/active"); + + await expect(resolve()).resolves.toEqual({ + value: "/srv/editor/.vscode-server", + style: "posix", + }); + }); + + it("returns undefined when resolving the active environment throws", async () => { + const { resolve } = setup(); + vi.mocked(vscode.workspace.getRemoteExecServer).mockRejectedValue( + new Error("resolver unavailable"), + ); + + await expect(resolve()).resolves.toBeUndefined(); + }); + + it("does not duplicate Cursor's product folder", async () => { + const { resolve } = setup(); + useRemoteSshExtension("anysphere.remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "/srv/editor/.cursor-server" }, + remotePlatforms: { [sshHost]: "linux" }, + }); + + await expect( + resolve({ serverDataFolderName: ".cursor-server" }), + ).resolves.toEqual({ value: "/srv/editor/.cursor-server", style: "posix" }); + }); + + it("uses the configured remote platform for Windows paths", async () => { + const { resolve } = setup(); + useRemoteSshExtension("ms-vscode-remote.remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "C:\\Users\\coder\\editor" }, + remotePlatforms: { [sshHost]: "windows" }, + }); + + await expect(resolve()).resolves.toEqual({ + value: "C:\\Users\\coder\\editor\\.vscode-server", + style: "win32", + }); + }); + + it("infers Windows only from an unambiguous configured path", async () => { + const { resolve } = setup(); + useRemoteSshExtension("ms-vscode-remote.remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "C:\\Users\\coder\\editor" }, + }); + + await expect(resolve()).resolves.toEqual({ + value: "C:\\Users\\coder\\editor\\.vscode-server", + style: "win32", + }); + }); + + it("uses Open Remote SSH's most specific matching path as the final folder", async () => { + const { resolve } = setup(); + useRemoteSshExtension("jeanp413.open-remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { + "*": "/srv/default", + "coder-vscode.*": "/srv/coder", + [sshHost]: "/srv/exact", + }, + remotePlatforms: { [sshHost]: "linux" }, + }); + + await expect(resolve()).resolves.toEqual({ + value: "/srv/exact", + style: "posix", + }); + }); + + it("prefers a specific wildcard over the catch-all for Open Remote SSH", async () => { + const { resolve } = setup(); + useRemoteSshExtension("jeanp413.open-remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { + "*": "/srv/default", + "coder-vscode.*": "/srv/coder", + }, + remotePlatforms: { [sshHost]: "linux" }, + }); + + await expect(resolve()).resolves.toEqual({ + value: "/srv/coder", + style: "posix", + }); + }); + + it("treats ? as a literal character in Open Remote SSH patterns", async () => { + const { resolve } = setup(); + useRemoteSshExtension("jeanp413.open-remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { + "*": "/srv/default", + "coder-vscode.?xample--owner--workspace.agent": "/srv/question", + }, + remotePlatforms: { [sshHost]: "linux" }, + }); + + await expect(resolve()).resolves.toEqual({ + value: "/srv/default", + style: "posix", + }); + }); + + it.each([ + "codeium.windsurf-remote-openssh", + "google.antigravity-remote-openssh", + ])("ignores serverInstallPath for %s", async (extensionId) => { + const { resolve } = setup(); + useRemoteSshExtension(extensionId); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "/srv/editor" }, + remotePlatforms: { [sshHost]: "linux" }, + }); + + await expect(resolve()).resolves.toBeUndefined(); + }); + + it.each([ + "relative/editor", + "$HOME/editor", + "/srv/*/editor", + "/srv/../editor", + ])("rejects an unsafe configured path: %s", async (installPath) => { + const { resolve } = setup(); + useRemoteSshExtension("ms-vscode-remote.remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: installPath }, + remotePlatforms: { [sshHost]: "linux" }, + }); + + await expect(resolve()).resolves.toBeUndefined(); + }); + + describe("logging", () => { + it("warns when resolving the active environment throws", async () => { + const { logger, resolve } = setup(); + vi.mocked(vscode.workspace.getRemoteExecServer).mockRejectedValue( + new Error("resolver unavailable"), + ); + + await resolve(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.any(String), + expect.any(Error), + ); + }); + + it("warns when a configured path is rejected as unsafe", async () => { + const { logger, resolve } = setup(); + useRemoteSshExtension("ms-vscode-remote.remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "$HOME/editor" }, + remotePlatforms: { [sshHost]: "linux" }, + }); + + await resolve(); + + expect(logger.warn).toHaveBeenCalledWith(expect.any(String)); + }); + }); +}); + +describe("toRemoteLogGlobs", () => { + it.each([ + [ + { value: "/srv/vscode", style: "posix" as const }, + ["/srv/vscode/data/logs/**/*.log"], + ], + [ + { + value: "C:\\Users\\coder\\.vscode-server", + style: "win32" as const, + }, + ["C:/Users/coder/.vscode-server/data/logs/**/*.log"], + ], + ])("appends the log globs to $value", (serverDataPath, expected) => { + expect(toRemoteLogGlobs(serverDataPath)).toEqual(expected); + }); +}); diff --git a/test/unit/supportBundle/workspaceFiles.test.ts b/test/unit/supportBundle/workspaceFiles.test.ts new file mode 100644 index 0000000000..760b8d0e91 --- /dev/null +++ b/test/unit/supportBundle/workspaceFiles.test.ts @@ -0,0 +1,142 @@ +import { vol } from "memfs"; +import { describe, expect, it, vi } from "vitest"; + +import { getRemoteServerDataPath } from "@/supportBundle/remoteServerDataPath"; +import { getRemoteEditorLogGlobs } from "@/supportBundle/workspaceFiles"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); +vi.mock("@/supportBundle/remoteServerDataPath", async (importOriginal) => { + const original = + await importOriginal< + typeof import("@/supportBundle/remoteServerDataPath") + >(); + return { + ...original, + getRemoteServerDataPath: vi.fn(), + }; +}); + +const appRoot = "/app"; +const productPath = `${appRoot}/product.json`; +const remoteAuthority = + "ssh-remote+coder-vscode.example--owner--workspace.agent"; +const resolvedLogFiles = ["/srv/vscode/data/logs/**/*.log"]; + +function setup() { + vol.reset(); + vi.mocked(getRemoteServerDataPath).mockReset(); + vi.mocked(getRemoteServerDataPath).mockResolvedValue(undefined); + const logger = createMockLogger(); + const collect = (overrides: { remoteAuthority?: string } = {}) => + getRemoteEditorLogGlobs({ appRoot, logger, ...overrides }); + return { logger, collect }; +} + +function writeProduct(serverDataFolderName: unknown): void { + vol.fromJSON({ + [productPath]: JSON.stringify({ serverDataFolderName }), + }); +} + +describe("getRemoteEditorLogGlobs", () => { + it.each([ + ".vscode-server", + ".vscode-server-insiders", + ".cursor-server", + ".windsurf-server", + ".antigravity-server", + ])("derives the product fallback for %s", async (serverDataFolderName) => { + const { collect } = setup(); + writeProduct(serverDataFolderName); + + await expect(collect()).resolves.toEqual([ + `~/${serverDataFolderName}/data/logs/**/*.log`, + ]); + }); + + it("uses the resolved remote server path", async () => { + const { logger, collect } = setup(); + writeProduct(".vscode-server"); + vi.mocked(getRemoteServerDataPath).mockResolvedValue({ + value: "/srv/vscode", + style: "posix", + }); + + await expect(collect({ remoteAuthority })).resolves.toEqual( + resolvedLogFiles, + ); + expect(getRemoteServerDataPath).toHaveBeenCalledWith({ + remoteAuthority, + serverDataFolderName: ".vscode-server", + logger, + }); + }); + + it.each([ + undefined, + null, + 42, + "", + ".", + "..", + "../.vscode-server", + "nested/.vscode-server", + "nested\\.vscode-server", + "/home/coder/.vscode-server", + "*", + "wild*card", + "?server", + "[ab]server", + "$HOME", + ])("rejects unsafe server data folder names: %j", async (value) => { + const { collect } = setup(); + writeProduct(value); + + await expect(collect()).resolves.toEqual([]); + }); + + it("uses the active server path without product metadata", async () => { + const { logger, collect } = setup(); + vi.mocked(getRemoteServerDataPath).mockResolvedValue({ + value: "/srv/vscode", + style: "posix", + }); + + await expect(collect({ remoteAuthority })).resolves.toEqual( + resolvedLogFiles, + ); + expect(getRemoteServerDataPath).toHaveBeenCalledWith({ + remoteAuthority, + serverDataFolderName: undefined, + logger, + }); + }); + + it("returns no paths when product metadata is unavailable", async () => { + const { collect } = setup(); + + await expect(collect()).resolves.toEqual([]); + }); + + it("uses the active server path when product metadata is invalid", async () => { + const { collect } = setup(); + vol.fromJSON({ [productPath]: "not-json" }); + vi.mocked(getRemoteServerDataPath).mockResolvedValue({ + value: "/srv/vscode", + style: "posix", + }); + + await expect(collect({ remoteAuthority })).resolves.toEqual( + resolvedLogFiles, + ); + }); + + it("returns no paths when product metadata is invalid JSON", async () => { + const { collect } = setup(); + vol.fromJSON({ [productPath]: "not-json" }); + + await expect(collect()).resolves.toEqual([]); + }); +}); From ed4231e39055fd8d30fe121fb67ef2160ed94579 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Jul 2026 16:19:31 +0300 Subject: [PATCH 2/4] feat: collect remote server startup logs Also collect the server's root dotfile logs and CLI launcher logs (cli/servers/*/log.txt) alongside the editor session logs. --- src/supportBundle/remoteServerDataPath.ts | 6 +++++- test/unit/supportBundle/remoteServerDataPath.test.ts | 12 ++++++++++-- test/unit/supportBundle/workspaceFiles.test.ts | 8 +++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/supportBundle/remoteServerDataPath.ts b/src/supportBundle/remoteServerDataPath.ts index f2654a0bde..df08a4c384 100644 --- a/src/supportBundle/remoteServerDataPath.ts +++ b/src/supportBundle/remoteServerDataPath.ts @@ -58,7 +58,11 @@ export function toRemoteLogGlobs({ style, }: RemoteServerDataPath): readonly string[] { const base = style === "win32" ? value.replaceAll("\\", "/") : value; - return [path.posix.join(base, "data", "logs", "**", "*.log")]; + return [ + path.posix.join(base, "data", "logs", "**", "*.log"), + path.posix.join(base, ".*.log"), + path.posix.join(base, "cli", "servers", "*", "log.txt"), + ]; } async function getActiveServerDataPath( diff --git a/test/unit/supportBundle/remoteServerDataPath.test.ts b/test/unit/supportBundle/remoteServerDataPath.test.ts index 853070d9f3..ef22a195de 100644 --- a/test/unit/supportBundle/remoteServerDataPath.test.ts +++ b/test/unit/supportBundle/remoteServerDataPath.test.ts @@ -291,14 +291,22 @@ describe("toRemoteLogGlobs", () => { it.each([ [ { value: "/srv/vscode", style: "posix" as const }, - ["/srv/vscode/data/logs/**/*.log"], + [ + "/srv/vscode/data/logs/**/*.log", + "/srv/vscode/.*.log", + "/srv/vscode/cli/servers/*/log.txt", + ], ], [ { value: "C:\\Users\\coder\\.vscode-server", style: "win32" as const, }, - ["C:/Users/coder/.vscode-server/data/logs/**/*.log"], + [ + "C:/Users/coder/.vscode-server/data/logs/**/*.log", + "C:/Users/coder/.vscode-server/.*.log", + "C:/Users/coder/.vscode-server/cli/servers/*/log.txt", + ], ], ])("appends the log globs to $value", (serverDataPath, expected) => { expect(toRemoteLogGlobs(serverDataPath)).toEqual(expected); diff --git a/test/unit/supportBundle/workspaceFiles.test.ts b/test/unit/supportBundle/workspaceFiles.test.ts index 760b8d0e91..9cd01b311a 100644 --- a/test/unit/supportBundle/workspaceFiles.test.ts +++ b/test/unit/supportBundle/workspaceFiles.test.ts @@ -22,7 +22,11 @@ const appRoot = "/app"; const productPath = `${appRoot}/product.json`; const remoteAuthority = "ssh-remote+coder-vscode.example--owner--workspace.agent"; -const resolvedLogFiles = ["/srv/vscode/data/logs/**/*.log"]; +const resolvedLogFiles = [ + "/srv/vscode/data/logs/**/*.log", + "/srv/vscode/.*.log", + "/srv/vscode/cli/servers/*/log.txt", +]; function setup() { vol.reset(); @@ -53,6 +57,8 @@ describe("getRemoteEditorLogGlobs", () => { await expect(collect()).resolves.toEqual([ `~/${serverDataFolderName}/data/logs/**/*.log`, + `~/${serverDataFolderName}/.*.log`, + `~/${serverDataFolderName}/cli/servers/*/log.txt`, ]); }); From 287e3642213f064ac7a3b793950f193d2f096dfd Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 28 Jul 2026 16:37:08 +0300 Subject: [PATCH 3/4] fix: address support bundle review feedback Pass server data paths through verbatim instead of rejecting unsafe-looking values; the agent canonicalizes patterns and records per-pattern failures in the bundle manifest. Glob metacharacters are escaped with character classes, which survive the agent's separator normalization on Windows where backslash escapes would not. Fold the home-directory fallback into getRemoteServerDataPath and default the folder name to .vscode-remote, matching the server's own resolution. Track the connected agent on Commands instead of re-parsing the remote authority. --- src/commands.ts | 18 +-- src/remote/remote.ts | 1 + src/supportBundle/remoteServerDataPath.ts | 71 ++++++------ src/supportBundle/workspaceFiles.ts | 50 +++----- test/unit/commands.supportBundle.test.ts | 24 ++-- .../remoteServerDataPath.test.ts | 75 +++++++----- .../unit/supportBundle/workspaceFiles.test.ts | 107 ++++++------------ 7 files changed, 143 insertions(+), 203 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 981b255f4e..eebfe272fe 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -47,7 +47,7 @@ import { resolveCliAuth } from "./settings/cli"; import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs"; import { getRemoteEditorLogGlobs } from "./supportBundle/workspaceFiles"; import { runExportTelemetryCommand } from "./telemetry/export/command"; -import { parseRemoteAuthority, toRemoteAuthority } from "./util/authority"; +import { toRemoteAuthority } from "./util/authority"; import { openInBrowser, toSafeHost } from "./util/uri"; import { vscodeProposed } from "./vscodeProposed"; import { parseNetcheckReport } from "./webviews/netcheck/types"; @@ -154,6 +154,7 @@ export class Commands { // are logged into (for convenience; otherwise the recents menu can be a pain // if you use multiple deployments). public workspace?: Workspace; + public agent?: WorkspaceAgent; public workspaceLogPath?: string; public remoteWorkspaceClient?: CoderApi; @@ -1160,23 +1161,12 @@ export class Commands { }; } if (this.workspace && this.remoteWorkspaceClient) { - const remoteAuthority = vscodeProposed.env.remoteAuthority; - // Agent resolution is best-effort; a malformed authority must not - // block diagnostics for the whole workspace. - let agentName: string | undefined; - try { - agentName = remoteAuthority - ? parseRemoteAuthority(remoteAuthority)?.agent || undefined - : undefined; - } catch (error) { - this.logger.warn("Could not resolve the connected agent", error); - } return { status: "selected", - agentName, + agentName: this.agent?.name, client: this.remoteWorkspaceClient, workspaceId: createWorkspaceIdentifier(this.workspace), - remoteAuthority, + remoteAuthority: vscodeProposed.env.remoteAuthority, }; } const pick = await this.pickWorkspace("diagnostic", { diff --git a/src/remote/remote.ts b/src/remote/remote.ts index b08bc8d9ff..36d8687dcb 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -638,6 +638,7 @@ export class Remote { }); this.commands.workspace = workspace; + this.commands.agent = agent; return agent; } diff --git a/src/supportBundle/remoteServerDataPath.ts b/src/supportBundle/remoteServerDataPath.ts index df08a4c384..13c25257a5 100644 --- a/src/supportBundle/remoteServerDataPath.ts +++ b/src/supportBundle/remoteServerDataPath.ts @@ -11,7 +11,7 @@ import { vscodeProposed } from "../vscodeProposed"; import type { Logger } from "../logging/logger"; interface RemoteServerDataPathOptions { - readonly remoteAuthority: string; + readonly remoteAuthority?: string; /** Product-specific server directory name, such as `.vscode-server`. */ readonly serverDataFolderName?: string; readonly logger: Logger; @@ -29,7 +29,7 @@ const parentInstallPathExtensions: readonly RemoteSshExtensionId[] = [ ]; /** - * Resolve the active remote server's data directory when possible. + * Resolve the remote server's data directory. * * Precedence mirrors the server's own resolution, the `--server-data-dir` * flag over `VSCODE_AGENT_FOLDER` over the home default: supported @@ -41,11 +41,28 @@ export async function getRemoteServerDataPath({ remoteAuthority, serverDataFolderName, logger, -}: RemoteServerDataPathOptions): Promise { - const configured = serverDataFolderName - ? getConfiguredServerDataPath(remoteAuthority, serverDataFolderName, logger) - : undefined; - return configured ?? getActiveServerDataPath(remoteAuthority, logger); +}: RemoteServerDataPathOptions): Promise { + const configured = + remoteAuthority && serverDataFolderName + ? getConfiguredServerDataPath( + remoteAuthority, + serverDataFolderName, + logger, + ) + : undefined; + const active = + configured ?? + (remoteAuthority + ? await getActiveServerDataPath(remoteAuthority, logger) + : undefined); + // The agent expands `~/` against the remote home directory, matching the + // server's default when neither override is present. + return ( + active ?? { + value: `~/${serverDataFolderName ?? ".vscode-remote"}`, + style: "posix", + } + ); } /** @@ -57,7 +74,9 @@ export function toRemoteLogGlobs({ value, style, }: RemoteServerDataPath): readonly string[] { - const base = style === "win32" ? value.replaceAll("\\", "/") : value; + const base = escapeGlobChars( + style === "win32" ? value.replaceAll("\\", "/") : value, + ); return [ path.posix.join(base, "data", "logs", "**", "*.log"), path.posix.join(base, ".*.log"), @@ -83,12 +102,7 @@ async function getActiveServerDataPath( if (value === undefined) { return undefined; } - const style = pathStyleForPlatform(osPlatform); - if (!isSafeAbsolutePath(value, style)) { - logger.warn(`Ignoring unsafe VSCODE_AGENT_FOLDER value: ${value}`); - return undefined; - } - return { value, style }; + return { value, style: pathStyleForPlatform(osPlatform) }; } catch (error) { logger.warn( "Could not resolve the remote server data path from the active environment", @@ -133,13 +147,6 @@ function getConfiguredServerDataPath( installPath, remotePlatforms[parts.sshHost], ); - if (!isSafeAbsolutePath(installPath, style)) { - logger.warn( - `Ignoring unsafe remote.SSH.serverInstallPath value: ${installPath}`, - ); - return undefined; - } - if (extensionId === "jeanp413.open-remote-ssh") { return { value: installPath, style }; } @@ -216,19 +223,11 @@ function configuredPathStyle( : "posix"; } -/** Reject variables and glob syntax that could broaden workspace collection. */ -export function hasUnsafePathChars(value: string): boolean { - return /[\0$*?[\]{}]/.test(value); -} - -/** Absolute, no unsafe characters, and no `..` segments to traverse out. */ -function isSafeAbsolutePath( - value: string, - style: RemoteServerDataPath["style"], -): boolean { - return ( - path[style].isAbsolute(value) && - !hasUnsafePathChars(value) && - !value.split(/[\\/]/).includes("..") - ); +/** + * Escape glob metacharacters so the base path matches literally. Character + * classes work on every platform, while backslash escapes would be + * normalized into path separators for Windows agents. + */ +function escapeGlobChars(value: string): string { + return value.replace(/[*?[{]/g, (char) => `[${char}]`); } diff --git a/src/supportBundle/workspaceFiles.ts b/src/supportBundle/workspaceFiles.ts index e81d732ece..2f0067a36b 100644 --- a/src/supportBundle/workspaceFiles.ts +++ b/src/supportBundle/workspaceFiles.ts @@ -3,7 +3,6 @@ import * as path from "node:path"; import { getRemoteServerDataPath, - hasUnsafePathChars, toRemoteLogGlobs, } from "./remoteServerDataPath"; @@ -11,7 +10,8 @@ import type { Logger } from "../logging/logger"; interface ProductConfiguration { /** - * Default remote server data directory beneath the user's home directory. + * Name of the server's data directory under the remote user's home + * directory, such as `.vscode-server`. * @see https://github.com/microsoft/vscode/blob/085b6a1465387d070516ba8a640ccfed66417796/src/vs/server/node/server.main.ts#L39 */ serverDataFolderName?: unknown; @@ -32,25 +32,12 @@ export async function getRemoteEditorLogGlobs({ logger, }: RemoteEditorLogOptions): Promise { const serverDataFolderName = await readServerDataFolderName(appRoot, logger); - const serverDataPath = remoteAuthority - ? await getRemoteServerDataPath({ - remoteAuthority, - serverDataFolderName, - logger, - }) - : undefined; - if (serverDataPath) { - return toRemoteLogGlobs(serverDataPath); - } - // The agent expands `~/` against the remote home directory and normalizes - // separators before glob matching, so the posix style is portable here. - if (serverDataFolderName) { - return toRemoteLogGlobs({ - value: `~/${serverDataFolderName}`, - style: "posix", - }); - } - return []; + const serverDataPath = await getRemoteServerDataPath({ + remoteAuthority, + serverDataFolderName, + logger, + }); + return toRemoteLogGlobs(serverDataPath); } async function readServerDataFolderName( @@ -62,25 +49,14 @@ async function readServerDataFolderName( path.join(appRoot, "product.json"), "utf-8", ); - const product = JSON.parse(productJson) as ProductConfiguration; - return isSafeServerDataFolderName(product.serverDataFolderName) - ? product.serverDataFolderName + const { serverDataFolderName } = JSON.parse( + productJson, + ) as ProductConfiguration; + return typeof serverDataFolderName === "string" && serverDataFolderName + ? serverDataFolderName : undefined; } catch (error) { logger.warn("Could not read the editor's product metadata", error); return undefined; } } - -/** Return whether the value is a single portable path segment. */ -function isSafeServerDataFolderName(value: unknown): value is string { - return ( - typeof value === "string" && - value.length > 0 && - value !== "." && - value !== ".." && - !hasUnsafePathChars(value) && - path.posix.basename(value) === value && - path.win32.basename(value) === value - ); -} diff --git a/test/unit/commands.supportBundle.test.ts b/test/unit/commands.supportBundle.test.ts index b04d5fc34d..a39a78f8f9 100644 --- a/test/unit/commands.supportBundle.test.ts +++ b/test/unit/commands.supportBundle.test.ts @@ -108,8 +108,12 @@ function connectToWorkspace( commands: Commands, client: CoderApi, remoteAuthority: string, + agentName?: string, ): void { commands.workspace = workspace; + commands.agent = agentName + ? ({ id: "agent-id", name: agentName } as WorkspaceAgent) + : undefined; commands.remoteWorkspaceClient = client; setRemoteAuthority(remoteAuthority); } @@ -138,7 +142,7 @@ describe("Commands.supportBundle", () => { it("derives the agent and remote authority from the active connection", async () => { const { commands, client } = setup(); const remoteAuthority = "ssh-remote+coder-vscode.example--owner--ws.main"; - connectToWorkspace(commands, client, remoteAuthority); + connectToWorkspace(commands, client, remoteAuthority, "main"); await commands.supportBundle(); @@ -152,9 +156,9 @@ describe("Commands.supportBundle", () => { ); }); - it("degrades the agent to undefined for a malformed Coder authority", async () => { + it("omits the agent when the connection has not resolved one", async () => { const { commands, client } = setup(); - connectToWorkspace(commands, client, "ssh-remote+coder-vscode.malformed"); + connectToWorkspace(commands, client, "ssh-remote+coder-vscode.example"); await commands.supportBundle(); @@ -176,18 +180,4 @@ describe("Commands.supportBundle", () => { expect.objectContaining({ workspaceFiles: [] }), ); }); - - describe("logging", () => { - it("warns when the connected agent cannot be resolved", async () => { - const { commands, client, logger } = setup(); - connectToWorkspace(commands, client, "ssh-remote+coder-vscode.malformed"); - - await commands.supportBundle(); - - expect(logger.warn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Error), - ); - }); - }); }); diff --git a/test/unit/supportBundle/remoteServerDataPath.test.ts b/test/unit/supportBundle/remoteServerDataPath.test.ts index ef22a195de..3be31dc32d 100644 --- a/test/unit/supportBundle/remoteServerDataPath.test.ts +++ b/test/unit/supportBundle/remoteServerDataPath.test.ts @@ -87,11 +87,14 @@ describe("getRemoteServerDataPath", () => { }); }); - it("rejects an unsafe active environment path", async () => { + it("uses the active environment value verbatim", async () => { const { resolve } = setup(); - useActiveServerDataPath("$HOME/.vscode-server"); + useActiveServerDataPath("$HOME/relative/.vscode-server"); - await expect(resolve()).resolves.toBeUndefined(); + await expect(resolve()).resolves.toEqual({ + value: "$HOME/relative/.vscode-server", + style: "posix", + }); }); it.each(["ms-vscode-remote.remote-ssh", "anysphere.remote-ssh"])( @@ -126,13 +129,33 @@ describe("getRemoteServerDataPath", () => { }); }); - it("returns undefined when resolving the active environment throws", async () => { + it("falls back to the home default when the active environment throws", async () => { const { resolve } = setup(); vi.mocked(vscode.workspace.getRemoteExecServer).mockRejectedValue( new Error("resolver unavailable"), ); - await expect(resolve()).resolves.toBeUndefined(); + await expect(resolve()).resolves.toEqual({ + value: "~/.vscode-server", + style: "posix", + }); + }); + + it("falls back to the home default without a remote authority", async () => { + const { resolve } = setup(); + + await expect(resolve({ remoteAuthority: undefined })).resolves.toEqual({ + value: "~/.vscode-server", + style: "posix", + }); + }); + + it("defaults the home fallback folder to .vscode-remote", async () => { + const { resolve } = setup(); + + await expect( + resolve({ remoteAuthority: undefined, serverDataFolderName: undefined }), + ).resolves.toEqual({ value: "~/.vscode-remote", style: "posix" }); }); it("does not duplicate Cursor's product folder", async () => { @@ -238,23 +261,24 @@ describe("getRemoteServerDataPath", () => { remotePlatforms: { [sshHost]: "linux" }, }); - await expect(resolve()).resolves.toBeUndefined(); + await expect(resolve()).resolves.toEqual({ + value: "~/.vscode-server", + style: "posix", + }); }); - it.each([ - "relative/editor", - "$HOME/editor", - "/srv/*/editor", - "/srv/../editor", - ])("rejects an unsafe configured path: %s", async (installPath) => { + it("uses a configured path verbatim", async () => { const { resolve } = setup(); useRemoteSshExtension("ms-vscode-remote.remote-ssh"); setRemoteSshConfiguration({ - installPaths: { [sshHost]: installPath }, + installPaths: { [sshHost]: "$HOME/relative/editor" }, remotePlatforms: { [sshHost]: "linux" }, }); - await expect(resolve()).resolves.toBeUndefined(); + await expect(resolve()).resolves.toEqual({ + value: "$HOME/relative/editor/.vscode-server", + style: "posix", + }); }); describe("logging", () => { @@ -271,19 +295,6 @@ describe("getRemoteServerDataPath", () => { expect.any(Error), ); }); - - it("warns when a configured path is rejected as unsafe", async () => { - const { logger, resolve } = setup(); - useRemoteSshExtension("ms-vscode-remote.remote-ssh"); - setRemoteSshConfiguration({ - installPaths: { [sshHost]: "$HOME/editor" }, - remotePlatforms: { [sshHost]: "linux" }, - }); - - await resolve(); - - expect(logger.warn).toHaveBeenCalledWith(expect.any(String)); - }); }); }); @@ -311,4 +322,14 @@ describe("toRemoteLogGlobs", () => { ])("appends the log globs to $value", (serverDataPath, expected) => { expect(toRemoteLogGlobs(serverDataPath)).toEqual(expected); }); + + it("escapes glob metacharacters in the base path", () => { + expect( + toRemoteLogGlobs({ value: "/srv/{v}[1]/vs*co?de", style: "posix" }), + ).toEqual([ + "/srv/[{]v}[[]1]/vs[*]co[?]de/data/logs/**/*.log", + "/srv/[{]v}[[]1]/vs[*]co[?]de/.*.log", + "/srv/[{]v}[[]1]/vs[*]co[?]de/cli/servers/*/log.txt", + ]); + }); }); diff --git a/test/unit/supportBundle/workspaceFiles.test.ts b/test/unit/supportBundle/workspaceFiles.test.ts index 9cd01b311a..6de9b92d0d 100644 --- a/test/unit/supportBundle/workspaceFiles.test.ts +++ b/test/unit/supportBundle/workspaceFiles.test.ts @@ -31,7 +31,10 @@ const resolvedLogFiles = [ function setup() { vol.reset(); vi.mocked(getRemoteServerDataPath).mockReset(); - vi.mocked(getRemoteServerDataPath).mockResolvedValue(undefined); + vi.mocked(getRemoteServerDataPath).mockResolvedValue({ + value: "/srv/vscode", + style: "posix", + }); const logger = createMockLogger(); const collect = (overrides: { remoteAuthority?: string } = {}) => getRemoteEditorLogGlobs({ appRoot, logger, ...overrides }); @@ -45,30 +48,9 @@ function writeProduct(serverDataFolderName: unknown): void { } describe("getRemoteEditorLogGlobs", () => { - it.each([ - ".vscode-server", - ".vscode-server-insiders", - ".cursor-server", - ".windsurf-server", - ".antigravity-server", - ])("derives the product fallback for %s", async (serverDataFolderName) => { - const { collect } = setup(); - writeProduct(serverDataFolderName); - - await expect(collect()).resolves.toEqual([ - `~/${serverDataFolderName}/data/logs/**/*.log`, - `~/${serverDataFolderName}/.*.log`, - `~/${serverDataFolderName}/cli/servers/*/log.txt`, - ]); - }); - - it("uses the resolved remote server path", async () => { + it("returns log globs for the resolved server data path", async () => { const { logger, collect } = setup(); writeProduct(".vscode-server"); - vi.mocked(getRemoteServerDataPath).mockResolvedValue({ - value: "/srv/vscode", - style: "posix", - }); await expect(collect({ remoteAuthority })).resolves.toEqual( resolvedLogFiles, @@ -80,69 +62,50 @@ describe("getRemoteEditorLogGlobs", () => { }); }); - it.each([ - undefined, - null, - 42, - "", - ".", - "..", - "../.vscode-server", - "nested/.vscode-server", - "nested\\.vscode-server", - "/home/coder/.vscode-server", - "*", - "wild*card", - "?server", - "[ab]server", - "$HOME", - ])("rejects unsafe server data folder names: %j", async (value) => { + it("passes any non-empty folder name through", async () => { const { collect } = setup(); - writeProduct(value); + writeProduct("nested/$HOME/*server"); - await expect(collect()).resolves.toEqual([]); - }); + await collect(); - it("uses the active server path without product metadata", async () => { - const { logger, collect } = setup(); - vi.mocked(getRemoteServerDataPath).mockResolvedValue({ - value: "/srv/vscode", - style: "posix", - }); - - await expect(collect({ remoteAuthority })).resolves.toEqual( - resolvedLogFiles, + expect(getRemoteServerDataPath).toHaveBeenCalledWith( + expect.objectContaining({ serverDataFolderName: "nested/$HOME/*server" }), ); - expect(getRemoteServerDataPath).toHaveBeenCalledWith({ - remoteAuthority, - serverDataFolderName: undefined, - logger, - }); }); - it("returns no paths when product metadata is unavailable", async () => { - const { collect } = setup(); + it.each([undefined, null, 42, ""])( + "resolves without a folder name for invalid product values: %j", + async (value) => { + const { collect } = setup(); + writeProduct(value); - await expect(collect()).resolves.toEqual([]); - }); + await expect(collect()).resolves.toEqual(resolvedLogFiles); + expect(getRemoteServerDataPath).toHaveBeenCalledWith( + expect.objectContaining({ serverDataFolderName: undefined }), + ); + }, + ); - it("uses the active server path when product metadata is invalid", async () => { - const { collect } = setup(); - vol.fromJSON({ [productPath]: "not-json" }); - vi.mocked(getRemoteServerDataPath).mockResolvedValue({ - value: "/srv/vscode", - style: "posix", - }); + it("resolves without a folder name when product metadata is unavailable", async () => { + const { logger, collect } = setup(); - await expect(collect({ remoteAuthority })).resolves.toEqual( - resolvedLogFiles, + await expect(collect()).resolves.toEqual(resolvedLogFiles); + expect(getRemoteServerDataPath).toHaveBeenCalledWith( + expect.objectContaining({ serverDataFolderName: undefined }), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), ); }); - it("returns no paths when product metadata is invalid JSON", async () => { + it("resolves without a folder name when product metadata is invalid JSON", async () => { const { collect } = setup(); vol.fromJSON({ [productPath]: "not-json" }); - await expect(collect()).resolves.toEqual([]); + await expect(collect()).resolves.toEqual(resolvedLogFiles); + expect(getRemoteServerDataPath).toHaveBeenCalledWith( + expect.objectContaining({ serverDataFolderName: undefined }), + ); }); }); From 12d195e5d74ca3b37d8cdfd5ee0c8dd56bf16d15 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 29 Jul 2026 13:41:43 +0300 Subject: [PATCH 4/4] fix: resolve remote server data paths from settings alone Drop the exec-server VSCODE_AGENT_FOLDER read: every supported Remote-SSH implementation derives the server data dir from its install path setting (or a fixed home default) and hands it to the server at launch, so local settings resolve the same path deterministically. Reconstruct the authority for sidebar and picker flows so the lookup works without an active connection, anchor relative install paths to the remote home, and fold workspaceFiles.ts into remoteServerDataPath.ts. --- src/commands.ts | 45 +++- src/supportBundle/remoteServerDataPath.ts | 113 ++++----- src/supportBundle/workspaceFiles.ts | 62 ----- src/typings/vscode.proposed.resolvers.d.ts | 13 - test/mocks/vscode.runtime.ts | 1 - test/unit/commands.supportBundle.test.ts | 104 ++++++-- .../remoteServerDataPath.test.ts | 225 +++++++++++------- .../unit/supportBundle/workspaceFiles.test.ts | 111 --------- 8 files changed, 320 insertions(+), 354 deletions(-) delete mode 100644 src/supportBundle/workspaceFiles.ts delete mode 100644 test/unit/supportBundle/workspaceFiles.test.ts diff --git a/src/commands.ts b/src/commands.ts index eebfe272fe..e058baf2ab 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -45,7 +45,10 @@ import { } from "./remote/sshOverrides"; import { resolveCliAuth } from "./settings/cli"; import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs"; -import { getRemoteEditorLogGlobs } from "./supportBundle/workspaceFiles"; +import { + getRemoteServerDataPath, + toRemoteLogGlobs, +} from "./supportBundle/remoteServerDataPath"; import { runExportTelemetryCommand } from "./telemetry/export/command"; import { toRemoteAuthority } from "./util/authority"; import { openInBrowser, toSafeHost } from "./util/uri"; @@ -103,7 +106,7 @@ type WorkspaceResolution = readonly agentName?: string; readonly client: CoderApi; readonly workspaceId: string; - /** Active authority, only when it identifies this workspace. */ + /** Authority for the workspace, live or reconstructed from metadata. */ readonly remoteAuthority?: string; } | { readonly status: "cancelled" } @@ -442,11 +445,11 @@ export class Commands { progress.report({ message: "Resolving CLI..." }); // Independent of the CLI and never rejects, so resolve the globs // concurrently and discard them when the CLI lacks support. - const remoteLogGlobs = getRemoteEditorLogGlobs({ + const remoteLogGlobs = getRemoteServerDataPath({ appRoot: vscode.env.appRoot, remoteAuthority, logger: this.logger, - }); + }).then(toRemoteLogGlobs); const env = await this.resolveCliEnv(client); if (!env.featureSet.supportBundle) { throw new SupportBundleUnsupportedCliError(); @@ -1153,11 +1156,18 @@ export class Commands { item?: OpenableTreeItem, ): Promise { if (item) { + const agentName = + item instanceof AgentTreeItem ? item.agent.name : undefined; return { status: "selected", - agentName: item instanceof AgentTreeItem ? item.agent.name : undefined, + agentName, client: this.extensionClient, workspaceId: createWorkspaceIdentifier(item.workspace), + remoteAuthority: this.toWorkspaceAuthority( + this.extensionClient, + item.workspace, + agentName, + ), }; } if (this.workspace && this.remoteWorkspaceClient) { @@ -1180,11 +1190,36 @@ export class Commands { status: "selected", client: this.extensionClient, workspaceId: createWorkspaceIdentifier(pick.workspace), + remoteAuthority: this.toWorkspaceAuthority( + this.extensionClient, + pick.workspace, + ), }; } return pick; } + /** + * Reconstruct the authority Remote-SSH would use, defaulting to the + * first agent like the CLI does. + */ + private toWorkspaceAuthority( + client: CoderApi, + workspace: Workspace, + agentName?: string, + ): string | undefined { + const baseUrl = client.getAxiosInstance().defaults.baseURL; + if (!baseUrl) { + return undefined; + } + return toRemoteAuthority( + baseUrl, + workspace.owner_name, + workspace.name, + agentName ?? extractAgents(workspace.latest_build.resources)[0]?.name, + ); + } + /** Resolve a CliEnv, preferring a locally cached binary over a network fetch. */ private async resolveCliEnv( client: CoderApi, diff --git a/src/supportBundle/remoteServerDataPath.ts b/src/supportBundle/remoteServerDataPath.ts index 13c25257a5..8cb6b6a029 100644 --- a/src/supportBundle/remoteServerDataPath.ts +++ b/src/supportBundle/remoteServerDataPath.ts @@ -1,3 +1,4 @@ +import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as vscode from "vscode"; @@ -6,17 +7,26 @@ import { type RemoteSshExtensionId, } from "../remote/sshExtension"; import { parseRemoteAuthority } from "../util/authority"; -import { vscodeProposed } from "../vscodeProposed"; import type { Logger } from "../logging/logger"; interface RemoteServerDataPathOptions { + /** The local editor's install root, `vscode.env.appRoot`. */ + readonly appRoot: string; + /** Authority identifying the target workspace's SSH host. */ readonly remoteAuthority?: string; - /** Product-specific server directory name, such as `.vscode-server`. */ - readonly serverDataFolderName?: string; readonly logger: Logger; } +interface ProductConfiguration { + /** + * Name of the server's data directory under the remote user's home + * directory, such as `.vscode-server`. + * @see https://github.com/microsoft/vscode/blob/085b6a1465387d070516ba8a640ccfed66417796/src/vs/server/node/server.main.ts#L39 + */ + serverDataFolderName?: unknown; +} + export interface RemoteServerDataPath { readonly value: string; readonly style: "posix" | "win32"; @@ -31,35 +41,30 @@ const parentInstallPathExtensions: readonly RemoteSshExtensionId[] = [ /** * Resolve the remote server's data directory. * - * Precedence mirrors the server's own resolution, the `--server-data-dir` - * flag over `VSCODE_AGENT_FOLDER` over the home default: supported - * implementations pass `serverInstallPath` as that flag, so its - * interpretation outranks the environment variable. + * Remote-SSH implementations derive this path from `serverInstallPath` and + * hand it to the server at launch (as `--server-data-dir` or + * `VSCODE_AGENT_FOLDER`), so the setting alone resolves it without an + * active connection. * @see https://github.com/microsoft/vscode/blob/085b6a1465387d070516ba8a640ccfed66417796/src/vs/server/node/server.main.ts#L39 */ export async function getRemoteServerDataPath({ + appRoot, remoteAuthority, - serverDataFolderName, logger, }: RemoteServerDataPathOptions): Promise { - const configured = - remoteAuthority && serverDataFolderName - ? getConfiguredServerDataPath( - remoteAuthority, - serverDataFolderName, - logger, - ) - : undefined; - const active = - configured ?? - (remoteAuthority - ? await getActiveServerDataPath(remoteAuthority, logger) - : undefined); - // The agent expands `~/` against the remote home directory, matching the - // server's default when neither override is present. + const serverDataFolderName = await readServerDataFolderName(appRoot, logger); + let dataPath: RemoteServerDataPath | undefined; + if (remoteAuthority && serverDataFolderName) { + dataPath = getConfiguredServerDataPath( + remoteAuthority, + serverDataFolderName, + logger, + ); + } + // The agent expands `~/` against the remote home, the server's default. return ( - active ?? { - value: `~/${serverDataFolderName ?? ".vscode-remote"}`, + dataPath ?? { + value: `~/${serverDataFolderName || ".vscode-remote"}`, style: "posix", } ); @@ -84,34 +89,6 @@ export function toRemoteLogGlobs({ ]; } -async function getActiveServerDataPath( - remoteAuthority: string, - logger: Logger, -): Promise { - try { - if (!vscodeProposed.workspace.getRemoteExecServer) { - return undefined; - } - const execServer = - await vscodeProposed.workspace.getRemoteExecServer(remoteAuthority); - if (!execServer) { - return undefined; - } - const { env, osPlatform } = await execServer.env(); - const value = env.VSCODE_AGENT_FOLDER; - if (value === undefined) { - return undefined; - } - return { value, style: pathStyleForPlatform(osPlatform) }; - } catch (error) { - logger.warn( - "Could not resolve the remote server data path from the active environment", - error, - ); - return undefined; - } -} - function getConfiguredServerDataPath( remoteAuthority: string, serverDataFolderName: string, @@ -129,6 +106,8 @@ function getConfiguredServerDataPath( "serverInstallPath", {}, ); + // Windsurf and Antigravity have no install path setting; their + // servers always live in the home default. let installPath: string | undefined; if (extensionId === "jeanp413.open-remote-ssh") { installPath = findOpenRemoteSshInstallPath(parts.sshHost, installPaths); @@ -147,11 +126,16 @@ function getConfiguredServerDataPath( installPath, remotePlatforms[parts.sshHost], ); + const remotePath = path[style]; + // SSH resolves relative paths against home; the agent only expands + // absolute, `~/`, and environment-variable paths. + if (!remotePath.isAbsolute(installPath) && !/^[~$%]/.test(installPath)) { + installPath = `~/${installPath}`; + } if (extensionId === "jeanp413.open-remote-ssh") { return { value: installPath, style }; } - const remotePath = path[style]; // Cursor accepts the product folder itself despite documenting a parent. // Its installer strips this suffix before consistently re-appending it. const parentPath = @@ -231,3 +215,24 @@ function configuredPathStyle( function escapeGlobChars(value: string): string { return value.replace(/[*?[{]/g, (char) => `[${char}]`); } + +async function readServerDataFolderName( + appRoot: string, + logger: Logger, +): Promise { + try { + const productJson = await fs.readFile( + path.join(appRoot, "product.json"), + "utf-8", + ); + const { serverDataFolderName } = JSON.parse( + productJson, + ) as ProductConfiguration; + return typeof serverDataFolderName === "string" && serverDataFolderName + ? serverDataFolderName + : undefined; + } catch (error) { + logger.warn("Could not read the editor's product metadata", error); + return undefined; + } +} diff --git a/src/supportBundle/workspaceFiles.ts b/src/supportBundle/workspaceFiles.ts deleted file mode 100644 index 2f0067a36b..0000000000 --- a/src/supportBundle/workspaceFiles.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; - -import { - getRemoteServerDataPath, - toRemoteLogGlobs, -} from "./remoteServerDataPath"; - -import type { Logger } from "../logging/logger"; - -interface ProductConfiguration { - /** - * Name of the server's data directory under the remote user's home - * directory, such as `.vscode-server`. - * @see https://github.com/microsoft/vscode/blob/085b6a1465387d070516ba8a640ccfed66417796/src/vs/server/node/server.main.ts#L39 - */ - serverDataFolderName?: unknown; -} - -interface RemoteEditorLogOptions { - /** The local editor's install root, `vscode.env.appRoot`. */ - readonly appRoot: string; - /** The active authority, only when it targets the support bundle workspace. */ - readonly remoteAuthority?: string; - readonly logger: Logger; -} - -/** Return known remote server log globs for the target workspace. */ -export async function getRemoteEditorLogGlobs({ - appRoot, - remoteAuthority, - logger, -}: RemoteEditorLogOptions): Promise { - const serverDataFolderName = await readServerDataFolderName(appRoot, logger); - const serverDataPath = await getRemoteServerDataPath({ - remoteAuthority, - serverDataFolderName, - logger, - }); - return toRemoteLogGlobs(serverDataPath); -} - -async function readServerDataFolderName( - appRoot: string, - logger: Logger, -): Promise { - try { - const productJson = await fs.readFile( - path.join(appRoot, "product.json"), - "utf-8", - ); - const { serverDataFolderName } = JSON.parse( - productJson, - ) as ProductConfiguration; - return typeof serverDataFolderName === "string" && serverDataFolderName - ? serverDataFolderName - : undefined; - } catch (error) { - logger.warn("Could not read the editor's product metadata", error); - return undefined; - } -} diff --git a/src/typings/vscode.proposed.resolvers.d.ts b/src/typings/vscode.proposed.resolvers.d.ts index 1c36d086ba..2634fb01c6 100644 --- a/src/typings/vscode.proposed.resolvers.d.ts +++ b/src/typings/vscode.proposed.resolvers.d.ts @@ -201,16 +201,6 @@ declare module "vscode" { stripPathStartingSeparator?: boolean; } - export interface ExecEnvironment { - readonly env: Record; - readonly osPlatform: string; - readonly osRelease?: string; - } - - export interface ExecServer { - env(): Thenable; - } - export namespace workspace { export function registerRemoteAuthorityResolver( authorityPrefix: string, @@ -219,9 +209,6 @@ declare module "vscode" { export function registerResourceLabelFormatter( formatter: ResourceLabelFormatter, ): Disposable; - export function getRemoteExecServer( - authority: string, - ): Thenable; } export namespace env { diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 90d430dea8..55cd482e7d 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -180,7 +180,6 @@ export const commands = { export const workspace = { getConfiguration: vi.fn(), // your helpers override this - getRemoteExecServer: vi.fn(), workspaceFolders: [] as unknown[], fs: { readFile: vi.fn(), diff --git a/test/unit/commands.supportBundle.test.ts b/test/unit/commands.supportBundle.test.ts index a39a78f8f9..1cee6dadd6 100644 --- a/test/unit/commands.supportBundle.test.ts +++ b/test/unit/commands.supportBundle.test.ts @@ -4,8 +4,13 @@ import * as vscode from "vscode"; import { Commands } from "@/commands"; import * as cliExec from "@/core/cliExec"; import { appendVsCodeLogs } from "@/supportBundle/appendVsCodeLogs"; -import { getRemoteEditorLogGlobs } from "@/supportBundle/workspaceFiles"; -import { AgentTreeItem } from "@/workspace/workspacesProvider"; +import { getRemoteServerDataPath } from "@/supportBundle/remoteServerDataPath"; +import { + AgentTreeItem, + WorkspaceTreeItem, +} from "@/workspace/workspacesProvider"; + +import { agent, resource, workspace } from "@repo/mocks"; import { createTelemetryHarness } from "../mocks/telemetry"; import { @@ -14,11 +19,6 @@ import { MockProgressReporter, } from "../mocks/testHelpers"; -import type { - Workspace, - WorkspaceAgent, -} from "coder/site/src/api/typesGenerated"; - import type { CoderApi } from "@/api/coderApi"; import type { ServiceContainer } from "@/core/container"; import type { DeploymentManager } from "@/deployment/deploymentManager"; @@ -32,17 +32,29 @@ vi.mock("@/supportBundle/appendVsCodeLogs", () => ({ appendVsCodeLogs: vi.fn(), })); -vi.mock("@/supportBundle/workspaceFiles", () => ({ - getRemoteEditorLogGlobs: vi.fn(), -})); +vi.mock("@/supportBundle/remoteServerDataPath", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/supportBundle/remoteServerDataPath") + >(); + return { ...actual, getRemoteServerDataPath: vi.fn() }; +}); const OUTPUT_PATH = "/tmp/bundle.zip"; -const REMOTE_LOG_GLOBS = ["~/.vscode-server/data/logs/**/*.log"]; -const workspace = { +// Derived from the mocked data path by the real toRemoteLogGlobs. +const REMOTE_LOG_GLOBS = [ + "~/.vscode-server/data/logs/**/*.log", + "~/.vscode-server/.*.log", + "~/.vscode-server/cli/servers/*/log.txt", +]; +const TEST_WORKSPACE = workspace({ owner_name: "owner", name: "ws", - latest_build: { status: "running" }, -} as Workspace; + latest_build: { + status: "running", + resources: [resource({ agents: [agent({ name: "main" })] })], + }, +}); function setup(options: { cliVersion?: string } = {}) { vi.clearAllMocks(); @@ -56,7 +68,10 @@ function setup(options: { cliVersion?: string } = {}) { ); vi.mocked(cliExec.version).mockResolvedValue(options.cliVersion ?? "v2.36.0"); vi.mocked(cliExec.supportBundle).mockResolvedValue(undefined); - vi.mocked(getRemoteEditorLogGlobs).mockResolvedValue(REMOTE_LOG_GLOBS); + vi.mocked(getRemoteServerDataPath).mockResolvedValue({ + value: "~/.vscode-server", + style: "posix", + }); vi.mocked(appendVsCodeLogs).mockResolvedValue(undefined); const logger = createMockLogger(); @@ -100,8 +115,7 @@ function setRemoteAuthority(value: string | undefined): void { } function agentItem(agentName: string): AgentTreeItem { - const agent = { id: "agent-id", name: agentName } as WorkspaceAgent; - return new AgentTreeItem(agent, workspace); + return new AgentTreeItem(agent({ name: agentName }), TEST_WORKSPACE); } function connectToWorkspace( @@ -110,10 +124,8 @@ function connectToWorkspace( remoteAuthority: string, agentName?: string, ): void { - commands.workspace = workspace; - commands.agent = agentName - ? ({ id: "agent-id", name: agentName } as WorkspaceAgent) - : undefined; + commands.workspace = TEST_WORKSPACE; + commands.agent = agentName ? agent({ name: agentName }) : undefined; commands.remoteWorkspaceClient = client; setRemoteAuthority(remoteAuthority); } @@ -133,9 +145,51 @@ describe("Commands.supportBundle", () => { workspaceFiles: REMOTE_LOG_GLOBS, }), ); - // No item authority: remote logs cannot target the workspace. - expect(getRemoteEditorLogGlobs).toHaveBeenCalledWith( - expect.objectContaining({ remoteAuthority: undefined }), + // The authority is reconstructed from the item's workspace and agent. + expect(getRemoteServerDataPath).toHaveBeenCalledWith( + expect.objectContaining({ + remoteAuthority: "ssh-remote+coder-vscode.coder.test--owner--ws.dev", + }), + ); + }); + + it("prefers the selected item over the active connection", async () => { + const { commands, client } = setup(); + connectToWorkspace( + commands, + client, + "ssh-remote+coder-vscode.example--owner--ws.main", + "main", + ); + + await commands.supportBundle(agentItem("dev")); + + expect(cliExec.supportBundle).toHaveBeenCalledWith( + expect.anything(), + "owner/ws", + expect.objectContaining({ agentName: "dev" }), + ); + expect(getRemoteServerDataPath).toHaveBeenCalledWith( + expect.objectContaining({ + remoteAuthority: "ssh-remote+coder-vscode.coder.test--owner--ws.dev", + }), + ); + }); + + it("reconstructs the authority with the first agent for workspace items", async () => { + const { commands } = setup(); + + await commands.supportBundle(new WorkspaceTreeItem(TEST_WORKSPACE, false)); + + expect(cliExec.supportBundle).toHaveBeenCalledWith( + expect.anything(), + "owner/ws", + expect.objectContaining({ agentName: undefined }), + ); + expect(getRemoteServerDataPath).toHaveBeenCalledWith( + expect.objectContaining({ + remoteAuthority: "ssh-remote+coder-vscode.coder.test--owner--ws.main", + }), ); }); @@ -151,7 +205,7 @@ describe("Commands.supportBundle", () => { "owner/ws", expect.objectContaining({ agentName: "main" }), ); - expect(getRemoteEditorLogGlobs).toHaveBeenCalledWith( + expect(getRemoteServerDataPath).toHaveBeenCalledWith( expect.objectContaining({ remoteAuthority }), ); }); diff --git a/test/unit/supportBundle/remoteServerDataPath.test.ts b/test/unit/supportBundle/remoteServerDataPath.test.ts index 3be31dc32d..639987309f 100644 --- a/test/unit/supportBundle/remoteServerDataPath.test.ts +++ b/test/unit/supportBundle/remoteServerDataPath.test.ts @@ -1,3 +1,4 @@ +import { vol } from "memfs"; import { describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; @@ -8,28 +9,37 @@ import { import { config, createMockLogger } from "../../mocks/testHelpers"; +vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); + const sshHost = "coder-vscode.example--owner--workspace.agent"; const remoteAuthority = `ssh-remote+${sshHost}`; -const serverDataFolderName = ".vscode-server"; +const appRoot = "/app"; +const productPath = `${appRoot}/product.json`; type ResolveOptions = Parameters[0]; function setup() { - vi.mocked(vscode.workspace.getRemoteExecServer).mockReset(); vi.mocked(vscode.extensions.getExtension).mockReset(); - vi.mocked(vscode.workspace.getRemoteExecServer).mockResolvedValue(undefined); setRemoteSshConfiguration({}); + vol.reset(); + writeProduct(".vscode-server"); const logger = createMockLogger(); const resolve = (overrides: Partial = {}) => getRemoteServerDataPath({ + appRoot, remoteAuthority, - serverDataFolderName, logger, ...overrides, }); return { logger, resolve }; } +function writeProduct(serverDataFolderName: unknown): void { + vol.fromJSON({ + [productPath]: JSON.stringify({ serverDataFolderName }), + }); +} + function useRemoteSshExtension(id: string): void { vi.mocked(vscode.extensions.getExtension).mockImplementation( (extensionId) => @@ -48,127 +58,159 @@ function setRemoteSshConfiguration(options: { }); } -function useActiveServerDataPath(value: string, osPlatform = "linux"): void { - vi.mocked(vscode.workspace.getRemoteExecServer).mockResolvedValue({ - env: vi.fn().mockResolvedValue({ - env: { VSCODE_AGENT_FOLDER: value }, - osPlatform, - }), - }); -} - describe("getRemoteServerDataPath", () => { - it("uses the active exec server environment", async () => { + it.each(["ms-vscode-remote.remote-ssh", "anysphere.remote-ssh"])( + "appends the product folder for %s", + async (extensionId) => { + const { resolve } = setup(); + useRemoteSshExtension(extensionId); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "/srv/editor" }, + remotePlatforms: { [sshHost]: "linux" }, + }); + + await expect(resolve()).resolves.toEqual({ + value: "/srv/editor/.vscode-server", + style: "posix", + }); + }, + ); + + it("falls back to the home default when nothing is configured", async () => { const { resolve } = setup(); - useActiveServerDataPath("/srv/vscode"); await expect(resolve()).resolves.toEqual({ - value: "/srv/vscode", + value: "~/.vscode-server", style: "posix", }); }); - it("uses the active environment without product metadata", async () => { + it("falls back to the home default without a remote authority", async () => { const { resolve } = setup(); - useActiveServerDataPath("/srv/vscode"); - await expect(resolve({ serverDataFolderName: undefined })).resolves.toEqual( - { value: "/srv/vscode", style: "posix" }, - ); + await expect(resolve({ remoteAuthority: undefined })).resolves.toEqual({ + value: "~/.vscode-server", + style: "posix", + }); }); - it("uses the active environment platform for Windows paths", async () => { + it.each([undefined, null, 42, ""])( + "defaults the folder to .vscode-remote for invalid product values: %j", + async (value) => { + const { resolve } = setup(); + writeProduct(value); + + await expect(resolve()).resolves.toEqual({ + value: "~/.vscode-remote", + style: "posix", + }); + }, + ); + + it("uses the default folder when product metadata is unavailable", async () => { const { resolve } = setup(); - useActiveServerDataPath("C:\\Users\\coder\\.vscode-server", "win32"); + vol.reset(); await expect(resolve()).resolves.toEqual({ - value: "C:\\Users\\coder\\.vscode-server", - style: "win32", + value: "~/.vscode-remote", + style: "posix", }); }); - it("uses the active environment value verbatim", async () => { + it("uses the default folder when product metadata is invalid JSON", async () => { const { resolve } = setup(); - useActiveServerDataPath("$HOME/relative/.vscode-server"); + vol.fromJSON({ [productPath]: "not-json" }); await expect(resolve()).resolves.toEqual({ - value: "$HOME/relative/.vscode-server", + value: "~/.vscode-remote", style: "posix", }); }); - it.each(["ms-vscode-remote.remote-ssh", "anysphere.remote-ssh"])( - "appends the product folder for %s", - async (extensionId) => { - const { resolve } = setup(); - useRemoteSshExtension(extensionId); - setRemoteSshConfiguration({ - installPaths: { [sshHost]: "/srv/editor" }, - remotePlatforms: { [sshHost]: "linux" }, - }); + it("does not duplicate Cursor's product folder", async () => { + const { resolve } = setup(); + useRemoteSshExtension("anysphere.remote-ssh"); + writeProduct(".cursor-server"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "/srv/editor/.cursor-server" }, + remotePlatforms: { [sshHost]: "linux" }, + }); - await expect(resolve()).resolves.toEqual({ - value: "/srv/editor/.vscode-server", - style: "posix", - }); - }, - ); + await expect(resolve()).resolves.toEqual({ + value: "/srv/editor/.cursor-server", + style: "posix", + }); + }); - it("prefers the configured path over the active environment", async () => { + it("anchors a relative configured path to the home directory", async () => { const { resolve } = setup(); useRemoteSshExtension("ms-vscode-remote.remote-ssh"); setRemoteSshConfiguration({ - installPaths: { [sshHost]: "/srv/editor" }, + installPaths: { [sshHost]: "editor/base" }, remotePlatforms: { [sshHost]: "linux" }, }); - useActiveServerDataPath("/srv/active"); await expect(resolve()).resolves.toEqual({ - value: "/srv/editor/.vscode-server", + value: "~/editor/base/.vscode-server", style: "posix", }); }); - it("falls back to the home default when the active environment throws", async () => { + it("anchors a relative path before stripping Cursor's product folder", async () => { const { resolve } = setup(); - vi.mocked(vscode.workspace.getRemoteExecServer).mockRejectedValue( - new Error("resolver unavailable"), - ); + useRemoteSshExtension("anysphere.remote-ssh"); + writeProduct(".cursor-server"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "editor/.cursor-server" }, + remotePlatforms: { [sshHost]: "linux" }, + }); await expect(resolve()).resolves.toEqual({ - value: "~/.vscode-server", + value: "~/editor/.cursor-server", style: "posix", }); }); - it("falls back to the home default without a remote authority", async () => { + it("anchors a relative Windows path to the home directory", async () => { const { resolve } = setup(); + useRemoteSshExtension("ms-vscode-remote.remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "editor\\base" }, + remotePlatforms: { [sshHost]: "windows" }, + }); - await expect(resolve({ remoteAuthority: undefined })).resolves.toEqual({ - value: "~/.vscode-server", - style: "posix", + await expect(resolve()).resolves.toEqual({ + value: "~\\editor\\base\\.vscode-server", + style: "win32", }); }); - it("defaults the home fallback folder to .vscode-remote", async () => { + it("anchors Open Remote SSH's relative path to the home directory", async () => { const { resolve } = setup(); + useRemoteSshExtension("jeanp413.open-remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "data" }, + remotePlatforms: { [sshHost]: "linux" }, + }); - await expect( - resolve({ remoteAuthority: undefined, serverDataFolderName: undefined }), - ).resolves.toEqual({ value: "~/.vscode-remote", style: "posix" }); + await expect(resolve()).resolves.toEqual({ + value: "~/data", + style: "posix", + }); }); - it("does not duplicate Cursor's product folder", async () => { + it("uses an environment-variable path verbatim", async () => { const { resolve } = setup(); - useRemoteSshExtension("anysphere.remote-ssh"); + useRemoteSshExtension("ms-vscode-remote.remote-ssh"); setRemoteSshConfiguration({ - installPaths: { [sshHost]: "/srv/editor/.cursor-server" }, + installPaths: { [sshHost]: "$HOME/relative/editor" }, remotePlatforms: { [sshHost]: "linux" }, }); - await expect( - resolve({ serverDataFolderName: ".cursor-server" }), - ).resolves.toEqual({ value: "/srv/editor/.cursor-server", style: "posix" }); + await expect(resolve()).resolves.toEqual({ + value: "$HOME/relative/editor/.vscode-server", + style: "posix", + }); }); it("uses the configured remote platform for Windows paths", async () => { @@ -198,6 +240,28 @@ describe("getRemoteServerDataPath", () => { }); }); + it("prefers the configured platform over path inference", async () => { + const { resolve } = setup(); + useRemoteSshExtension("ms-vscode-remote.remote-ssh"); + setRemoteSshConfiguration({ + installPaths: { [sshHost]: "/srv/editor" }, + remotePlatforms: { [sshHost]: "windows" }, + }); + + await expect(resolve()).resolves.toEqual({ + value: "\\srv\\editor\\.vscode-server", + style: "win32", + }); + }); + + it("falls back to the home default for an invalid authority", async () => { + const { resolve } = setup(); + + await expect( + resolve({ remoteAuthority: "ssh-remote+coder-vscode.broken" }), + ).resolves.toEqual({ value: "~/.vscode-server", style: "posix" }); + }); + it("uses Open Remote SSH's most specific matching path as the final folder", async () => { const { resolve } = setup(); useRemoteSshExtension("jeanp413.open-remote-ssh"); @@ -267,32 +331,27 @@ describe("getRemoteServerDataPath", () => { }); }); - it("uses a configured path verbatim", async () => { - const { resolve } = setup(); - useRemoteSshExtension("ms-vscode-remote.remote-ssh"); - setRemoteSshConfiguration({ - installPaths: { [sshHost]: "$HOME/relative/editor" }, - remotePlatforms: { [sshHost]: "linux" }, - }); + describe("logging", () => { + it("warns when the authority is invalid", async () => { + const { logger, resolve } = setup(); - await expect(resolve()).resolves.toEqual({ - value: "$HOME/relative/editor/.vscode-server", - style: "posix", + await resolve({ remoteAuthority: "ssh-remote+coder-vscode.broken" }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.any(String), + expect.any(Error), + ); }); - }); - describe("logging", () => { - it("warns when resolving the active environment throws", async () => { + it("warns when product metadata is unavailable", async () => { const { logger, resolve } = setup(); - vi.mocked(vscode.workspace.getRemoteExecServer).mockRejectedValue( - new Error("resolver unavailable"), - ); + vol.reset(); await resolve(); expect(logger.warn).toHaveBeenCalledWith( expect.any(String), - expect.any(Error), + expect.anything(), ); }); }); diff --git a/test/unit/supportBundle/workspaceFiles.test.ts b/test/unit/supportBundle/workspaceFiles.test.ts deleted file mode 100644 index 6de9b92d0d..0000000000 --- a/test/unit/supportBundle/workspaceFiles.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { vol } from "memfs"; -import { describe, expect, it, vi } from "vitest"; - -import { getRemoteServerDataPath } from "@/supportBundle/remoteServerDataPath"; -import { getRemoteEditorLogGlobs } from "@/supportBundle/workspaceFiles"; - -import { createMockLogger } from "../../mocks/testHelpers"; - -vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); -vi.mock("@/supportBundle/remoteServerDataPath", async (importOriginal) => { - const original = - await importOriginal< - typeof import("@/supportBundle/remoteServerDataPath") - >(); - return { - ...original, - getRemoteServerDataPath: vi.fn(), - }; -}); - -const appRoot = "/app"; -const productPath = `${appRoot}/product.json`; -const remoteAuthority = - "ssh-remote+coder-vscode.example--owner--workspace.agent"; -const resolvedLogFiles = [ - "/srv/vscode/data/logs/**/*.log", - "/srv/vscode/.*.log", - "/srv/vscode/cli/servers/*/log.txt", -]; - -function setup() { - vol.reset(); - vi.mocked(getRemoteServerDataPath).mockReset(); - vi.mocked(getRemoteServerDataPath).mockResolvedValue({ - value: "/srv/vscode", - style: "posix", - }); - const logger = createMockLogger(); - const collect = (overrides: { remoteAuthority?: string } = {}) => - getRemoteEditorLogGlobs({ appRoot, logger, ...overrides }); - return { logger, collect }; -} - -function writeProduct(serverDataFolderName: unknown): void { - vol.fromJSON({ - [productPath]: JSON.stringify({ serverDataFolderName }), - }); -} - -describe("getRemoteEditorLogGlobs", () => { - it("returns log globs for the resolved server data path", async () => { - const { logger, collect } = setup(); - writeProduct(".vscode-server"); - - await expect(collect({ remoteAuthority })).resolves.toEqual( - resolvedLogFiles, - ); - expect(getRemoteServerDataPath).toHaveBeenCalledWith({ - remoteAuthority, - serverDataFolderName: ".vscode-server", - logger, - }); - }); - - it("passes any non-empty folder name through", async () => { - const { collect } = setup(); - writeProduct("nested/$HOME/*server"); - - await collect(); - - expect(getRemoteServerDataPath).toHaveBeenCalledWith( - expect.objectContaining({ serverDataFolderName: "nested/$HOME/*server" }), - ); - }); - - it.each([undefined, null, 42, ""])( - "resolves without a folder name for invalid product values: %j", - async (value) => { - const { collect } = setup(); - writeProduct(value); - - await expect(collect()).resolves.toEqual(resolvedLogFiles); - expect(getRemoteServerDataPath).toHaveBeenCalledWith( - expect.objectContaining({ serverDataFolderName: undefined }), - ); - }, - ); - - it("resolves without a folder name when product metadata is unavailable", async () => { - const { logger, collect } = setup(); - - await expect(collect()).resolves.toEqual(resolvedLogFiles); - expect(getRemoteServerDataPath).toHaveBeenCalledWith( - expect.objectContaining({ serverDataFolderName: undefined }), - ); - expect(logger.warn).toHaveBeenCalledWith( - expect.any(String), - expect.anything(), - ); - }); - - it("resolves without a folder name when product metadata is invalid JSON", async () => { - const { collect } = setup(); - vol.fromJSON({ [productPath]: "not-json" }); - - await expect(collect()).resolves.toEqual(resolvedLogFiles); - expect(getRemoteServerDataPath).toHaveBeenCalledWith( - expect.objectContaining({ serverDataFolderName: undefined }), - ); - }); -});