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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Added

- Proxy log directory now defaults to the extension's global storage when `coder.proxyLogDirectory`
is not set, so SSH connection logs are always captured without manual configuration. Also respects
the `CODER_SSH_LOG_DIR` environment variable as a fallback.

## [v1.14.0-pre](https://github.com/coder/vscode-coder/releases/tag/v1.14.0-pre) 2026-03-06

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"default": ""
},
"coder.proxyLogDirectory": {
"markdownDescription": "If set, the Coder CLI will output extra SSH information into this directory, which can be helpful for debugging connectivity issues.",
"markdownDescription": "Directory where the Coder CLI outputs SSH connection logs for debugging. Defaults to the value of `CODER_SSH_LOG_DIR` if not set, otherwise the extension's global storage directory.",
"type": "string",
"default": ""
},
Expand Down
92 changes: 44 additions & 48 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,62 +148,39 @@ export class Commands {
public async viewLogs(): Promise<void> {
if (this.workspaceLogPath) {
// Return the connected deployment's log file.
return this.openFile(this.workspaceLogPath);
return openFile(this.workspaceLogPath);
}

const logDir = vscode.workspace
.getConfiguration()
.get<string>("coder.proxyLogDirectory");
if (logDir) {
try {
const files = await fs.readdir(logDir);
// Sort explicitly since fs.readdir order is platform-dependent
const logFiles = files
.filter((f) => f.endsWith(".log"))
.sort((a, b) => a.localeCompare(b))
.reverse();

if (logFiles.length === 0) {
vscode.window.showInformationMessage(
"No log files found in the configured log directory.",
);
return;
}
const logDir = this.pathResolver.getProxyLogPath();
try {
const files = await readdirOrEmpty(logDir);
// Sort explicitly since fs.readdir order is platform-dependent
const logFiles = files
.filter((f) => f.endsWith(".log"))
.sort((a, b) => a.localeCompare(b))
.reverse();

if (logFiles.length === 0) {
vscode.window.showInformationMessage(
"No log files found in the log directory.",
);
return;
}

const selected = await vscode.window.showQuickPick(logFiles, {
title: "Select a log file to view",
});
const selected = await vscode.window.showQuickPick(logFiles, {
title: "Select a log file to view",
});

if (selected) {
await this.openFile(path.join(logDir, selected));
}
} catch (error) {
vscode.window.showErrorMessage(
`Failed to read log directory: ${error instanceof Error ? error.message : String(error)}`,
);
if (selected) {
await openFile(path.join(logDir, selected));
}
} else {
vscode.window
.showInformationMessage(
"No logs available. Make sure to set coder.proxyLogDirectory to get logs.",
"Open Settings",
)
.then((action) => {
if (action === "Open Settings") {
vscode.commands.executeCommand(
"workbench.action.openSettings",
"coder.proxyLogDirectory",
);
}
});
} catch (error) {
vscode.window.showErrorMessage(
`Failed to read log directory: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

private async openFile(filePath: string): Promise<void> {
const uri = vscode.Uri.file(filePath);
await vscode.window.showTextDocument(uri);
}

/**
* Log out and clear stored credentials, requiring re-authentication on next login.
*/
Expand Down Expand Up @@ -786,3 +763,22 @@ export class Commands {
});
}
}

async function openFile(filePath: string): Promise<void> {
const uri = vscode.Uri.file(filePath);
await vscode.window.showTextDocument(uri);
}

/**
* Read a directory's entries, returning an empty array if it does not exist.
*/
async function readdirOrEmpty(dirPath: string): Promise<string[]> {
try {
return await fs.readdir(dirPath);
} catch (err) {
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
return [];
}
throw err;
}
}
46 changes: 32 additions & 14 deletions src/core/pathResolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as path from "node:path";
import * as vscode from "vscode";

import { expandPath } from "../util";

export class PathResolver {
constructor(
private readonly basePath: string,
Expand Down Expand Up @@ -30,15 +32,12 @@ export class PathResolver {
* The caller must ensure this directory exists before use.
*/
public getBinaryCachePath(safeHostname: string): string {
const settingPath = vscode.workspace
.getConfiguration()
.get<string>("coder.binaryDestination")
?.trim();
const binaryPath =
settingPath || process.env.CODER_BINARY_DESTINATION?.trim();
return binaryPath
? path.normalize(binaryPath)
: path.join(this.getGlobalConfigDir(safeHostname), "bin");
return (
PathResolver.resolveOverride(
"coder.binaryDestination",
"CODER_BINARY_DESTINATION",
) || path.join(this.getGlobalConfigDir(safeHostname), "bin")
);
}

/**
Expand All @@ -51,14 +50,19 @@ export class PathResolver {
}

/**
* Return the path where log data from the connection is stored.
* Return the proxy log directory from the `coder.proxyLogDirectory` setting
* or the `CODER_SSH_LOG_DIR` environment variable, falling back to the `log`
* subdirectory inside the extension's global storage path.
*
* The CLI will write files here named after the process PID.
*
* Note: This directory is not currently used.
*/
public getLogPath(): string {
return path.join(this.basePath, "log");
public getProxyLogPath(): string {
return (
PathResolver.resolveOverride(
"coder.proxyLogDirectory",
"CODER_SSH_LOG_DIR",
) || path.join(this.basePath, "log")
);
}

/**
Expand Down Expand Up @@ -117,4 +121,18 @@ export class PathResolver {
public getCodeLogDir(): string {
return this.codeLogPath;
}

/**
* Read a path from a VS Code setting then an environment variable, returning
* the first non-empty value after trimming, tilde/variable expansion, and
* normalization. Returns an empty string when neither source provides a path.
*/
private static resolveOverride(setting: string, envVar: string): string {
const fromSetting = expandPath(
vscode.workspace.getConfiguration().get<string>(setting)?.trim() ?? "",
);
const resolved =
fromSetting || expandPath(process.env[envVar]?.trim() ?? "");
return resolved ? path.normalize(resolved) : "";
}
}
13 changes: 3 additions & 10 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ import { OAuthSessionManager } from "../oauth/sessionManager";
import {
AuthorityPrefix,
escapeCommandArg,
expandPath,
parseRemoteAuthority,
} from "../util";
import { vscodeProposed } from "../vscodeProposed";
Expand Down Expand Up @@ -701,22 +700,16 @@ export class Remote {
}

/**
* Return the --log-dir argument value for the ProxyCommand. It may be an
* empty string if the setting is not set or the cli does not support it.
* Return the --log-dir argument value for the ProxyCommand, or an empty
* string when the CLI does not support it.
*
* Value defined in the "coder.sshFlags" setting is not considered.
*/
private getLogDir(featureSet: FeatureSet): string {
if (!featureSet.proxyLogDirectory) {
return "";
}
// If the proxyLogDirectory is not set in the extension settings we don't send one.
return expandPath(
String(
vscode.workspace.getConfiguration().get("coder.proxyLogDirectory") ??
"",
).trim(),
);
return this.pathResolver.getProxyLogPath();
}

/**
Expand Down
70 changes: 69 additions & 1 deletion test/unit/core/pathResolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as path from "path";
import { beforeEach, describe, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { PathResolver } from "@/core/pathResolver";

Expand Down Expand Up @@ -28,6 +28,60 @@ describe("PathResolver", () => {
expectPathsEqual(pathResolver.getUrlPath(""), path.join(basePath, "url"));
});

describe("getProxyLogPath", () => {
const defaultLogPath = path.join(basePath, "log");

it.each([
{ setting: "/custom/log/dir", expected: "/custom/log/dir" },
{ setting: "", expected: defaultLogPath },
{ setting: " ", expected: defaultLogPath },
{ setting: undefined, expected: defaultLogPath },
])(
"should return $expected when setting is '$setting'",
({ setting, expected }) => {
if (setting !== undefined) {
mockConfig.set("coder.proxyLogDirectory", setting);
}
expectPathsEqual(pathResolver.getProxyLogPath(), expected);
},
);

it("should expand tilde and ${userHome} in configured path", () => {
mockConfig.set("coder.proxyLogDirectory", "~/logs");
expect(pathResolver.getProxyLogPath()).not.toContain("~");

mockConfig.set("coder.proxyLogDirectory", "${userHome}/logs");
expect(pathResolver.getProxyLogPath()).not.toContain("${userHome}");
});

it("should normalize configured path", () => {
mockConfig.set("coder.proxyLogDirectory", "/custom/../log/./dir");
expectPathsEqual(pathResolver.getProxyLogPath(), "/log/dir");
});

it("should use CODER_SSH_LOG_DIR environment variable with proper precedence", () => {
// Use the global storage when the environment variable and setting are unset/blank
vi.stubEnv("CODER_SSH_LOG_DIR", "");
mockConfig.set("coder.proxyLogDirectory", "");
expectPathsEqual(pathResolver.getProxyLogPath(), defaultLogPath);

// Test environment variable takes precedence over global storage
vi.stubEnv("CODER_SSH_LOG_DIR", " /env/log/path ");
expectPathsEqual(pathResolver.getProxyLogPath(), "/env/log/path");

// Test setting takes precedence over environment variable
mockConfig.set("coder.proxyLogDirectory", " /setting/log/path ");
expectPathsEqual(pathResolver.getProxyLogPath(), "/setting/log/path");
});

it("should expand tilde in CODER_SSH_LOG_DIR", () => {
vi.stubEnv("CODER_SSH_LOG_DIR", "~/logs");
const result = pathResolver.getProxyLogPath();
expect(result).not.toContain("~");
expect(result).toContain("logs");
});
});

describe("getBinaryCachePath", () => {
it("should use custom binary destination when configured", () => {
mockConfig.set("coder.binaryDestination", "/custom/binary/path");
Expand All @@ -54,6 +108,20 @@ describe("PathResolver", () => {
);
});

it("should expand tilde in configured path", () => {
mockConfig.set("coder.binaryDestination", "~/bin");
const result = pathResolver.getBinaryCachePath("deployment");
expect(result).not.toContain("~");
expect(result).toContain("bin");
});

it("should expand tilde in CODER_BINARY_DESTINATION", () => {
vi.stubEnv("CODER_BINARY_DESTINATION", "~/bin");
const result = pathResolver.getBinaryCachePath("deployment");
expect(result).not.toContain("~");
expect(result).toContain("bin");
});

it("should use CODER_BINARY_DESTINATION environment variable with proper precedence", () => {
// Use the global storage when the environment variable and setting are unset/blank
vi.stubEnv("CODER_BINARY_DESTINATION", "");
Expand Down
Loading