From 01254f28976ffea459c52ecef21dad406aa0716e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Jul 2026 16:19:31 +0300 Subject: [PATCH 1/2] 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 f0fce77ceae0f3d045bdec9bd0f1aa257a5a5ad8 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 22 Jul 2026 16:19:31 +0300 Subject: [PATCH 2/2] 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`, ]); });