From 98e183b843b0d80de5951d3c2ce7cf01b6418f0d Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 16 Oct 2026 00:03:06 +0300 Subject: [PATCH 1/2] Include SSH logs in workspace diagnostics Currently, workspace log collection includes only the generated support bundle and omits local CLI SSH logs needed to investigate connection failures. This commit adds an SSH log collector that reads the directory resolved by the CLI manager and copies matching session and rotated logs into workspace diagnostics. The collector applies a three-day cutoff and retains the last 50 MiB per file to limit archive size. The remote environment supplies its known SSH process ID so the collector includes the active log regardless of age without repeating process discovery. The support bundle collector now continues SSH log collection after bundle failures, and the diagnostic collectors include the matching session ID in warnings when available. The README documents the included files and fixed limits, while regression tests cover directory resolution, log selection, truncation, cancellation, and failure handling. - resolves https://linear.app/codercom/issue/JET-34 --- CHANGELOG.md | 12 +- README.md | 29 ++- .../coder/toolbox/CoderRemoteEnvironment.kt | 17 +- .../com/coder/toolbox/cli/CoderCLIManager.kt | 45 ++++ .../diagnostics/CoderSshLogCollector.kt | 98 +++++++++ .../CoderSupportBundleCollector.kt | 31 ++- .../toolbox/CoderRemoteEnvironmentTest.kt | 201 +++++++++++++++++- .../coder/toolbox/cli/CoderCLIManagerTest.kt | 13 ++ .../coder/toolbox/cli/SshLogDirectoryTest.kt | 97 +++++++++ .../diagnostics/CoderSshLogCollectorTest.kt | 197 +++++++++++++++++ .../CoderSupportBundleCollectorTest.kt | 84 +++++++- 11 files changed, 802 insertions(+), 22 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/diagnostics/CoderSshLogCollector.kt create mode 100644 src/test/kotlin/com/coder/toolbox/cli/SshLogDirectoryTest.kt create mode 100644 src/test/kotlin/com/coder/toolbox/diagnostics/CoderSshLogCollectorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c8b32a..025bf456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,19 @@ ### Added -- include a Coder support bundle for the selected workspace and agent only when using the environment's - _Collect logs_ action in Toolbox +- include a Coder support bundle for the selected workspace and agent only when using the environment's _Collect logs_ + action in Toolbox +- include active and recent local CLI SSH logs in workspace log collection, using the configured or default CLI log + directory ### Changed - store CLI session tokens in the OS keyring by default on supported platforms; set `useKeyring` to `false` to opt out -- share the same session token between REST and CLI authentication, passing it through the environment and sanitizing logs +- share the same session token between REST and CLI authentication, passing it through the environment and sanitizing + logs - remove stored credentials on explicit logout, and remove old plaintext session files after successful keyring login -- bound credential and header commands to 60 seconds while preserving unbounded workspace starts and complete progress lines +- bound credential and header commands to 60 seconds while preserving unbounded workspace starts and complete progress + lines - upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2 - display live workspace start output from the Coder CLI and poll build status and provisioner output while updating, diff --git a/README.md b/README.md index 0ba43fdf..4ee29f7e 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,33 @@ The plugin runs `coder support bundle` using the deployment's existing CLI login and its agent, when available. It places `coder-support.zip` inside the environment's diagnostic directory in the Toolbox log archive. Collection does not start a stopped workspace. +The same diagnostic directory also includes recent local CLI SSH logs under `coder-ssh/`, even if support bundle +generation fails. The plugin uses the configured SSH log directory, then `CODER_SSH_LOG_DIR`, or otherwise the CLI's +default state directory: `$XDG_STATE_HOME/coder/logs` when set to an absolute path, falling back to +`~/.local/state/coder/logs` on Linux, `~/Library/Application Support/coder/logs` on macOS, or +`%LOCALAPPDATA%/coder/logs` on Windows. + +Only regular files directly inside the SSH log directory with these names are eligible: + +- `coder-ssh*.log`: CLI SSH session logs. +- `coder-ssh*.log.`: rotated CLI SSH logs, where `` contains only digits, such as `.log.1`. +- `.log`: legacy SSH logs with a numeric filename, such as `12345.log`. + +Collection skips subdirectories, symbolic links, and other filenames, including rotated legacy files such as +`12345.log.1`. These local logs can include sessions for other workspaces that share the log directory. + +The following fixed limits apply and are not configurable: + +- **Age:** include files modified within the last **3 days (72 hours)**, based on the file's last-modified time. + The identified active SSH session's log for the selected workspace/agent is included regardless of age. + Rotated logs remain subject to the age cutoff. If the active log cannot be identified, the cutoff applies to + every file. +- **Size:** copy only the last **50 MiB (52,428,800 bytes) per file**, including the active log. This is a limit on + each file's uncompressed contents, not on the total archive size. + +These limits apply only to the collected copies; the original log files are unchanged. Missing or unreadable SSH +logs do not prevent collecting the remaining diagnostics. + The bundle can contain deployment health and configuration, network diagnostics, workspace build logs, template source, and agent diagnostics. Available information depends on your permissions and workspace connectivity. Review the bundle before sharing, following @@ -417,7 +444,7 @@ Review the bundle before sharing, following Support bundles require a Coder CLI that supports `coder support bundle` (Coder 2.10 or newer). Collection runs until the CLI finishes or you cancel log collection. If the CLI is unavailable or unsupported, or authentication or connectivity fails, the plugin removes any partial bundle and attempts to include -`coder-support-error.txt` instead. Toolbox's other diagnostics remain available. +`coder-support-error.txt` instead. Local SSH logs and Toolbox's other diagnostics remain available. ### HTTP Request Logging diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 0852be0e..6524a2c1 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -114,6 +115,8 @@ class CoderRemoteEnvironment( private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) private val proxyCommandHandle = SshCommandProcessHandle(context) private var pollJob: Job? = null + @Volatile + private var sshPid: Long? = null init { if (context.settingsStore.shouldAutoConnect(id)) { @@ -128,8 +131,13 @@ class CoderRemoteEnvironment( internal fun currentSessionId(): SessionId? = agent?.let { SessionIdRegistry.findSession(workspace.name, it.name) } - override val diagnosticInfoCollector: DiagnosticInfoCollector = - CoderSupportBundleCollector(context.logger) { outputFile -> + override val diagnosticInfoCollector: DiagnosticInfoCollector + get() = CoderSupportBundleCollector( + context.logger, + cli.getSshLogDirectory(), + ::currentSessionId, + sshPid, + ) { outputFile -> cli.supportBundle(WorkspaceAddress.from(workspace, agent), outputFile) } @@ -312,6 +320,7 @@ class CoderRemoteEnvironment( // Toolbox can invoke this hook again while retrying without first reporting a disconnect. // Replace the previous poller so one environment never leaves multiple pollers behind. pollJob?.cancel() + sshPid = null pollJob = pollNetworkMetrics() } @@ -323,6 +332,8 @@ class CoderRemoteEnvironment( val sessionId = currentSessionId() context.logger.debug(sessionId, "Searching SSH command's PID for workspace $id...") val pid = proxyCommandHandle.findByWorkspaceAndAgent(workspace, currentAgent) + ensureActive() + sshPid = pid if (pid == null) { context.logger.debug(sessionId, "No SSH command PID was found for workspace $id") delay(POLL_INTERVAL) @@ -366,6 +377,7 @@ class CoderRemoteEnvironment( fun dispose() { pollJob?.cancel() pollJob = null + sshPid = null isConnected.update { false } val removedSessionId = agent?.let { SessionIdRegistry.removeSession(workspace.name, it.name) @@ -401,6 +413,7 @@ class CoderRemoteEnvironment( context.logger.info(sessionId, "Stopping the network metrics poll job for $id") pollJob?.cancel() pollJob = null + sshPid = null connectionRequest.update { false } isConnected.update { false } if (isManual) { diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index e11b7f1a..420208fe 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -129,6 +129,12 @@ class CoderCLIManager( val localBinaryPath: Path = context.settingsStore.binPath(deploymentURL) val coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config") + /** Returns the effective SSH log directory for this CLI's current settings. */ + internal fun getSshLogDirectory(): Path = getSshLogDirectory( + context.settingsStore.sshLogDirectory, + os = currentOs, + ) + private fun createDownloadService(): CoderDownloadService { val okHttpClient = CoderHttpClientBuilder.default(context) @@ -679,6 +685,45 @@ class CoderCLIManager( private const val CODER_SESSION_TOKEN_ENV_VAR = "CODER_SESSION_TOKEN" internal fun supportsKeyringStorage(os: OS?): Boolean = os == OS.MAC || os == OS.WINDOWS + + /** Resolves the log directory used by `coder ssh`, including its XDG state directory default. */ + internal fun getSshLogDirectory( + configuredDirectory: String?, + env: Map = System.getenv(), + os: OS? = getOS(), + home: Path = Path.of( + env[if (os == OS.WINDOWS) "USERPROFILE" else "HOME"].orEmpty().ifEmpty { + System.getProperty("user.home") + }, + ), + ): Path { + val directoryOverride = configuredDirectory?.takeIf { it.isNotBlank() } + ?: env["CODER_SSH_LOG_DIR"]?.takeIf { it.isNotEmpty() } + if (directoryOverride != null) return Path.of(directoryOverride) + + // The CLI uses github.com/adrg/xdg: XDG_STATE_HOME must be absolute after home expansion. + val stateHome = absoluteStatePath(env["XDG_STATE_HOME"], home, os) ?: when (os) { + OS.MAC -> home.resolve("Library/Application Support") + OS.WINDOWS -> absoluteStatePath(env["LOCALAPPDATA"], home, os) ?: home.resolve("AppData/Local") + else -> home.resolve(".local/state") + } + return stateHome.resolve("coder/logs") + } + + private fun absoluteStatePath(value: String?, home: Path, os: OS?): Path? { + if (value.isNullOrEmpty()) return null + val prefix = if (os == OS.WINDOWS) { + "%USERPROFILE%".takeIf { value.startsWith(it) } + } else { + listOf("~", "\$HOME").firstOrNull { value.startsWith(it) } + } + val path = if (prefix == null) { + Path.of(value) + } else { + home.resolve(value.removePrefix(prefix).trimStart('/', '\\')) + } + return path.takeIf { it.isAbsolute } + } } private fun globalConfigArgs(): List = listOf("--global-config", coderConfigPath.toString()) diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSshLogCollector.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSshLogCollector.kt new file mode 100644 index 00000000..801f4320 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSshLogCollector.kt @@ -0,0 +1,98 @@ +package com.coder.toolbox.diagnostics + +import com.coder.toolbox.session.SessionId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.LinkOption.NOFOLLOW_LINKS +import java.nio.file.Path +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.READ +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE +import java.nio.file.attribute.BasicFileAttributes +import java.time.Instant + +// Include current CLI session filenames and older PID-based logs. +private val SSH_LOG_FILE = Regex("coder-ssh.*\\.log(?:\\.\\d+)?|\\d+\\.log") +private const val SSH_LOG_MAX_AGE_SECONDS = 3L * 24 * 60 * 60 +private const val SSH_LOG_MAX_BYTES = 50L * 1024 * 1024 + +internal class CoderSshLogCollector( + private val logger: CoderLogger, + private val logDirectory: Path, +) { + suspend fun collect( + logsRootFolder: Path, + sessionId: SessionId? = null, + activeSshPid: Long? = null, + ) = withContext(Dispatchers.IO) { + // Older CLIs may not have created any session logs. + if (Files.notExists(logDirectory)) return@withContext + + val cutoff = Instant.now().minusSeconds(SSH_LOG_MAX_AGE_SECONDS) + val entries = Files.newDirectoryStream(logDirectory).use { it.toList() } + // CLI proxy log names end in the parent SSH PID; timestamps distinguish reused PIDs. + val activeLog = activeSshPid?.let { pid -> + entries.filter { entry -> + val name = entry.fileName.toString() + (name == "$pid.log" || (name.startsWith("coder-ssh") && name.endsWith("-$pid.log"))) && + Files.isRegularFile(entry, NOFOLLOW_LINKS) + }.maxByOrNull { it.fileName.toString() } + } + for (entry in entries) { + ensureActive() + // An active session can remain quiet longer than the collection window. + collectFile(entry, logsRootFolder.resolve("coder-ssh"), cutoff.takeUnless { entry == activeLog }, sessionId) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun CoroutineScope.collectFile(source: Path, destination: Path, cutoff: Instant?, sessionId: SessionId?) { + if (!SSH_LOG_FILE.matches(source.fileName.toString())) return + var output: Path? = null + try { + val attributes = Files.readAttributes(source, BasicFileAttributes::class.java, NOFOLLOW_LINKS) + val expired = cutoff != null && attributes.lastModifiedTime().toInstant().isBefore(cutoff) + if (!attributes.isRegularFile || expired) return + + FileChannel.open(source, READ, NOFOLLOW_LINKS).use { input -> + val size = input.size() + if (size > SSH_LOG_MAX_BYTES) { + logger.warn( + sessionId, + "Truncating SSH log $source ($size bytes) to its last $SSH_LOG_MAX_BYTES bytes", + ) + } + Files.createDirectories(destination) + val targetPath = destination.resolve(source.fileName) + output = targetPath + FileChannel.open(targetPath, CREATE, WRITE, TRUNCATE_EXISTING).use { target -> + copyTail(input, target, size) + } + } + } catch (ex: CancellationException) { + output?.let { runCatching { Files.deleteIfExists(it) } } + throw ex + } catch (ex: Exception) { + output?.let { runCatching { Files.deleteIfExists(it) } } + logger.warn(sessionId, ex, "Could not collect SSH log $source") + } + } + + private fun CoroutineScope.copyTail(input: FileChannel, target: FileChannel, size: Long) { + var remaining = minOf(size, SSH_LOG_MAX_BYTES) + var offset = size - remaining + while (remaining > 0) { + ensureActive() + val copied = input.transferTo(offset, remaining, target) + if (copied == 0L) break + offset += copied + remaining -= copied + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt index 77e4c0b8..3056ed9b 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.diagnostics +import com.coder.toolbox.session.SessionId import com.jetbrains.toolbox.api.remoteDev.deploy.DiagnosticInfoCollector import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -7,33 +8,51 @@ import kotlinx.coroutines.withContext import java.nio.file.Files import java.nio.file.Path -/** Collects a Coder support bundle for a workspace and its optional agent. */ +/** Collects a workspace support bundle and recent local Coder SSH logs. */ internal class CoderSupportBundleCollector( private val logger: CoderLogger, + sshLogDirectory: Path, + private val currentSessionId: () -> SessionId? = { null }, + private val sshPid: Long? = null, private val collectBundle: suspend (Path) -> Unit, ) : DiagnosticInfoCollector { + private val sshLogs = CoderSshLogCollector(logger, sshLogDirectory) + // Toolbox discards the additional-diagnostics directory if the collector throws. @Suppress("TooGenericExceptionCaught") override suspend fun collectAdditionalDiagnostics(logsRootFolder: Path) = withContext(Dispatchers.IO) { - val bundle = logsRootFolder.resolve("coder-support.zip") + val sessionId = currentSessionId() try { Files.createDirectories(logsRootFolder) + collectSupportBundle(logsRootFolder, sessionId) + sshLogs.collect(logsRootFolder, sessionId, sshPid) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + logger.warn(sessionId, ex, "Could not collect Coder workspace diagnostics") + } + } + + // A failed bundle is replaced by an error report so the SSH logs are still collected. + @Suppress("TooGenericExceptionCaught") + private suspend fun collectSupportBundle(logsRootFolder: Path, sessionId: SessionId?) { + val bundle = logsRootFolder.resolve("coder-support.zip") + try { collectBundle(bundle) } catch (ex: CancellationException) { runCatching { Files.deleteIfExists(bundle) } throw ex } catch (ex: Exception) { - logger.warn(ex, "Could not collect the Coder support bundle") + runCatching { Files.deleteIfExists(bundle) } + logger.warn(sessionId, ex, "Could not collect the Coder support bundle") runCatching { - Files.deleteIfExists(bundle) Files.writeString( logsRootFolder.resolve("coder-support-error.txt"), "Coder support bundle collection failed (${ex.javaClass.simpleName}). " + "Check deployment connectivity, login, and CLI support for 'coder support bundle' " + "(Coder 2.10 or newer). Other Toolbox logs are still available.\n", ) - }.onFailure { logger.warn(it, "Could not write the Coder diagnostic failure report") } + }.onFailure { logger.warn(sessionId, it, "Could not write the Coder diagnostic failure report") } } - Unit } } diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt index 8c7064d9..9fbe8ca8 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt @@ -1,6 +1,7 @@ package com.coder.toolbox import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.cli.SshCommandProcessHandle import com.coder.toolbox.diagnostics.CoderLogger import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.DataGen @@ -27,22 +28,29 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.unmockkConstructor import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import java.nio.file.Files +import java.nio.file.attribute.FileTime +import java.time.Duration import java.time.Instant import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotEquals import kotlin.test.assertNotNull @@ -51,19 +59,160 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class CoderRemoteEnvironmentTest { + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `diagnostics reuse the SSH process resolved by network polling and clear it on disconnect`() = runTest { + mockkConstructor(SshCommandProcessHandle::class) + every { anyConstructed().findByWorkspaceAndAgent(any(), any()) } returns 12345 + val fixture = fixture(backgroundScope) + val root = Files.createTempDirectory("coder-diagnostics-active-log-test") + try { + val failures = mutableListOf() + every { fixture.logger.warn(capture(failures), any()) } answers { Unit } + val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) + val activeLog = Files.writeString(sshLogs.resolve("coder-ssh-active-12345.log"), "active session") + Files.setLastModifiedTime(activeLog, FileTime.from(Instant.now().minus(Duration.ofDays(4)))) + every { fixture.cli.getSshLogDirectory() } returns sshLogs + every { fixture.settingsStore.networkInfoDir } returns root.resolve("metrics").toString() + runBlocking { + fixture.environment.diagnosticInfoCollector.collectAdditionalDiagnostics( + root.resolve("before-connection"), + ) + } + assertFalse(Files.exists(root.resolve("before-connection/coder-ssh"))) + verify(exactly = 0) { + anyConstructed().findByWorkspaceAndAgent(any(), any()) + } + + fixture.environment.beforeConnection() + runCurrent() + + runBlocking { + fixture.environment.diagnosticInfoCollector.collectAdditionalDiagnostics(root.resolve("diagnostics")) + } + + assertTrue(failures.isEmpty(), failures.joinToString { it.stackTraceToString() }) + verify(exactly = 1) { + anyConstructed().findByWorkspaceAndAgent(fixture.workspace, fixture.agent) + } + assertEquals( + "active session", + Files.readString(root.resolve("diagnostics/coder-ssh/coder-ssh-active-12345.log")), + ) + + fixture.environment.afterDisconnect(isManual = false) + runBlocking { + fixture.environment.diagnosticInfoCollector.collectAdditionalDiagnostics( + root.resolve("after-disconnect"), + ) + } + assertFalse(Files.exists(root.resolve("after-disconnect/coder-ssh"))) + verify(exactly = 1) { + anyConstructed().findByWorkspaceAndAgent(any(), any()) + } + } finally { + root.toFile().deleteRecursively() + fixture.environment.dispose() + unmockkConstructor(SshCommandProcessHandle::class) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `diagnostics stop treating a log as active when polling no longer finds the process`() = runTest { + mockkConstructor(SshCommandProcessHandle::class) + every { + anyConstructed().findByWorkspaceAndAgent(any(), any()) + } returnsMany listOf(12345L, null) + val fixture = fixture(backgroundScope) + val root = Files.createTempDirectory("coder-diagnostics-ended-log-test") + try { + val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) + val activeLog = Files.writeString(sshLogs.resolve("12345.log"), "active session") + Files.setLastModifiedTime(activeLog, FileTime.from(Instant.now().minus(Duration.ofDays(4)))) + every { fixture.cli.getSshLogDirectory() } returns sshLogs + every { fixture.settingsStore.networkInfoDir } returns root.resolve("metrics").toString() + fixture.environment.beforeConnection() + runCurrent() + + runBlocking { + fixture.environment.diagnosticInfoCollector.collectAdditionalDiagnostics(root.resolve("connected")) + } + assertTrue(Files.exists(root.resolve("connected/coder-ssh/12345.log"))) + + advanceTimeBy(5_000) + runCurrent() + runBlocking { + fixture.environment.diagnosticInfoCollector.collectAdditionalDiagnostics(root.resolve("ended")) + } + + assertFalse(Files.exists(root.resolve("ended/coder-ssh"))) + verify(exactly = 2) { + anyConstructed().findByWorkspaceAndAgent(fixture.workspace, fixture.agent) + } + } finally { + root.toFile().deleteRecursively() + fixture.environment.dispose() + unmockkConstructor(SshCommandProcessHandle::class) + } + } + + @Test + fun `diagnostic failures use the matching session at collection time without creating one`() = runTest { + val fixture = fixture(backgroundScope) + val root = Files.createTempDirectory("coder-diagnostics-session-test") + val failure = IllegalStateException("bundle unavailable") + try { + every { fixture.cli.getSshLogDirectory() } returns root.resolve("missing-logs") + coEvery { fixture.cli.supportBundle(any(), any()) } throws failure + val collector = fixture.environment.diagnosticInfoCollector + + collector.collectAdditionalDiagnostics(root) + assertNull(fixture.currentSessionId()) + verify(exactly = 1) { fixture.logger.warn(failure, "Could not collect the Coder support bundle") } + + fixture.environment.beforeConnection() + val sessionId = assertNotNull(fixture.currentSessionId()) + collector.collectAdditionalDiagnostics(root) + verify(exactly = 1) { + fixture.logger.warn(failure, "client_session_id=$sessionId Could not collect the Coder support bundle") + } + + val otherAgent = fixture.agent.copy(name = "other-agent") + fixture.environment.update( + fixture.workspace.copy( + latestBuild = fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING), + ), + otherAgent, + ) + collector.collectAdditionalDiagnostics(root) + assertNull(SessionIdRegistry.findSession(fixture.workspaceName, otherAgent.name)) + verify(exactly = 2) { fixture.logger.warn(failure, "Could not collect the Coder support bundle") } + } finally { + root.toFile().deleteRecursively() + fixture.environment.dispose() + fixture.removeSession() + } + } + @Test fun `diagnostic collector uses current workspace agent and CLI after refresh`() = runTest { val fixture = fixture(backgroundScope) - val collector = fixture.environment.diagnosticInfoCollector val refreshedCli = mockk(relaxed = true) val updatedAgent = fixture.agent.copy(name = "updated-agent") - fixture.environment.update( - fixture.workspace.copy(latestBuild = fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING)), - updatedAgent, - ) - fixture.environment.updateClientAndCli(mockk(relaxed = true), refreshedCli) val root = Files.createTempDirectory("coder-diagnostics-test") try { + val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) + Files.writeString(sshLogs.resolve("coder-ssh-session.log"), "SSH session") + every { fixture.cli.getSshLogDirectory() } returns sshLogs + val collector = fixture.environment.diagnosticInfoCollector + fixture.environment.update( + fixture.workspace.copy( + latestBuild = fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING), + ), + updatedAgent, + ) + fixture.environment.updateClientAndCli(mockk(relaxed = true), refreshedCli) coEvery { refreshedCli.supportBundle(any(), any()) } answers { Files.writeString(secondArg(), "bundle") Unit @@ -78,6 +227,8 @@ class CoderRemoteEnvironmentTest { root.resolve("coder-support.zip"), ) } + verify(exactly = 1) { fixture.cli.getSshLogDirectory() } + assertEquals("SSH session", Files.readString(root.resolve("coder-ssh/coder-ssh-session.log"))) fixture.environment.update(fixture.workspace, null) collector.collectAdditionalDiagnostics(root) coVerify(exactly = 1) { refreshedCli.supportBundle(match { it.agentName == null }, any()) } @@ -87,6 +238,44 @@ class CoderRemoteEnvironmentTest { } } + @Test + fun `diagnostic collector resolves the SSH directory when requested`() = runTest { + val fixture = fixture(backgroundScope) + val root = Files.createTempDirectory("coder-diagnostics-directory-test") + try { + val firstLogs = Files.createDirectories(root.resolve("first-logs")) + val refreshedLogs = Files.createDirectories(root.resolve("refreshed-logs")) + Files.writeString(firstLogs.resolve("coder-ssh-session.log"), "first session") + Files.writeString(refreshedLogs.resolve("coder-ssh-session.log"), "refreshed session") + every { fixture.cli.getSshLogDirectory() } returns firstLogs + val firstCollector = fixture.environment.diagnosticInfoCollector + verify(exactly = 1) { fixture.cli.getSshLogDirectory() } + + val refreshedCli = mockk(relaxed = true) + every { refreshedCli.getSshLogDirectory() } returns refreshedLogs + fixture.environment.updateClientAndCli(mockk(relaxed = true), refreshedCli) + val refreshedCollector = fixture.environment.diagnosticInfoCollector + verify(exactly = 1) { refreshedCli.getSshLogDirectory() } + + firstCollector.collectAdditionalDiagnostics(root.resolve("first-diagnostics")) + refreshedCollector.collectAdditionalDiagnostics(root.resolve("refreshed-diagnostics")) + + assertEquals( + "first session", + Files.readString(root.resolve("first-diagnostics/coder-ssh/coder-ssh-session.log")), + ) + assertEquals( + "refreshed session", + Files.readString(root.resolve("refreshed-diagnostics/coder-ssh/coder-ssh-session.log")), + ) + verify(exactly = 1) { fixture.cli.getSshLogDirectory() } + verify(exactly = 1) { refreshedCli.getSshLogDirectory() } + } finally { + root.toFile().deleteRecursively() + fixture.environment.dispose() + } + } + @Test fun `auto-connect requests SSH while the environment is initialized`() = runTest { val fixture = fixture(backgroundScope, autoConnect = true) diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 21e93a93..d8fb2ea0 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -93,6 +93,19 @@ internal class CoderCLIManagerTest { @TempDir lateinit var supportBundleProcessRoot: Path + @Test + fun `SSH log directory follows current settings`() { + val cli = supportBundleCli + val firstDirectory = supportBundleProcessRoot.resolve("first-logs") + val secondDirectory = supportBundleProcessRoot.resolve("second-logs") + + context.settingsStore.updateSshLogDir(firstDirectory.toString()) + assertEquals(firstDirectory, cli.getSshLogDirectory()) + + context.settingsStore.updateSshLogDir(secondDirectory.toString()) + assertEquals(secondDirectory, cli.getSshLogDirectory()) + } + private fun waitingCommand(): List = listOf( "/bin/sh", "-c", "echo $$ > \"\$1\"; exec sleep 30", "test", supportBundleProcessRoot.resolve("pid").toString(), ) diff --git a/src/test/kotlin/com/coder/toolbox/cli/SshLogDirectoryTest.kt b/src/test/kotlin/com/coder/toolbox/cli/SshLogDirectoryTest.kt new file mode 100644 index 00000000..f45b7d5b --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/cli/SshLogDirectoryTest.kt @@ -0,0 +1,97 @@ +package com.coder.toolbox.cli + +import com.coder.toolbox.cli.CoderCLIManager.Companion.getSshLogDirectory +import com.coder.toolbox.util.OS +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals + +class SshLogDirectoryTest { + @TempDir + lateinit var home: Path + + @Test + fun `uses the CLI state directory default on each platform`() { + val defaults = mapOf( + OS.LINUX to ".local/state/coder/logs", + OS.MAC to "Library/Application Support/coder/logs", + OS.WINDOWS to "AppData/Local/coder/logs", + ) + for ((os, directory) in defaults) { + assertEquals(home.resolve(directory), getSshLogDirectory(null, emptyMap(), os, home)) + } + } + + @Test + fun `configured directory takes precedence over environment overrides`() { + val configured = home.resolve("configured logs") + val env = mapOf( + "CODER_SSH_LOG_DIR" to home.resolve("env-logs").toString(), + "XDG_STATE_HOME" to home.resolve("state").toString(), + ) + assertEquals(configured, getSshLogDirectory(configured.toString(), env, OS.LINUX, home)) + assertEquals(home.resolve("env-logs"), getSshLogDirectory(" ", env, OS.LINUX, home)) + } + + @Test + fun `XDG state override takes precedence on every platform`() { + val state = home.resolve("custom-state") + val env = mapOf( + "CODER_SSH_LOG_DIR" to "", + "XDG_STATE_HOME" to state.toString(), + "LOCALAPPDATA" to home.resolve("local-app-data").toString(), + ) + for (os in OS.entries) { + assertEquals(state.resolve("coder/logs"), getSshLogDirectory(null, env, os, home)) + } + } + + @Test + fun `relative XDG state overrides fall back to the platform default`() { + for (state in listOf("", "relative/state")) { + assertEquals( + home.resolve(".local/state/coder/logs"), + getSshLogDirectory(null, mapOf("XDG_STATE_HOME" to state), OS.LINUX, home), + ) + } + } + + @Test + fun `expands home references in XDG paths using CLI platform conventions`() { + val overrides = mapOf( + OS.LINUX to listOf("~/state", "\$HOME/state"), + OS.MAC to listOf("~/state", "\$HOME/state"), + OS.WINDOWS to listOf("%USERPROFILE%/state"), + ) + for ((os, values) in overrides) { + for (value in values) { + assertEquals( + home.resolve("state/coder/logs"), + getSshLogDirectory(null, mapOf("XDG_STATE_HOME" to value), os, home), + ) + } + } + } + + @Test + fun `uses Windows local app data when set`() { + val localAppData = home.resolve("local-app-data") + assertEquals( + localAppData.resolve("coder/logs"), + getSshLogDirectory(null, mapOf("LOCALAPPDATA" to localAppData.toString()), OS.WINDOWS, home), + ) + } + + @Test + fun `uses the CLI home environment when no state override is set`() { + assertEquals( + home.resolve(".local/state/coder/logs"), + getSshLogDirectory(null, mapOf("HOME" to home.toString()), OS.LINUX), + ) + assertEquals( + home.resolve("AppData/Local/coder/logs"), + getSshLogDirectory(null, mapOf("USERPROFILE" to home.toString()), OS.WINDOWS), + ) + } +} diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSshLogCollectorTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSshLogCollectorTest.kt new file mode 100644 index 00000000..d2254f2f --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSshLogCollectorTest.kt @@ -0,0 +1,197 @@ +package com.coder.toolbox.diagnostics + +import com.coder.toolbox.session.SessionId +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.api.io.TempDir +import java.io.RandomAccessFile +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.FileTime +import java.time.Duration +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoderSshLogCollectorTest { + @TempDir + lateinit var root: Path + + private val logs get() = root.resolve("logs") + private val diagnostics get() = root.resolve("diagnostics") + + private fun collector() = CoderSshLogCollector(mockk(relaxed = true), logs) + + @Test + fun `only the newest log for the active SSH process bypasses the age cutoff`() = runTest { + Files.createDirectories(logs) + val activeName = "coder-ssh-20261010-120000-abcde-12345.log" + val oldNames = listOf( + activeName, + "coder-ssh-20261009-120000-abcde-12345.log", + "$activeName.1", + "coder-ssh-20261010-120000-abcde-67890.log", + "coder-ssh-20261010-120000-abcde-112345.log", + "12345.log", + ) + val oldTime = FileTime.from(Instant.now().minus(Duration.ofDays(4))) + for (name in oldNames) { + val file = Files.writeString(logs.resolve(name), name) + Files.setLastModifiedTime(file, oldTime) + } + Files.writeString(logs.resolve("coder-ssh-recent.log"), "recent") + + collector().collect(diagnostics, activeSshPid = 12345) + + val collected = Files.list(diagnostics.resolve("coder-ssh")).use { files -> + files.map { it.fileName.toString() }.toList().toSet() + } + assertEquals(setOf(activeName, "coder-ssh-recent.log"), collected) + assertEquals(activeName, Files.readString(diagnostics.resolve("coder-ssh/$activeName"))) + } + + @Test + fun `active PID-only logs bypass the age cutoff`() = runTest { + Files.createDirectories(logs) + val source = Files.writeString(logs.resolve("12345.log"), "active session") + Files.setLastModifiedTime(source, FileTime.from(Instant.now().minus(Duration.ofDays(4)))) + + collector().collect(diagnostics, activeSshPid = 12345) + + assertEquals("active session", Files.readString(diagnostics.resolve("coder-ssh/12345.log"))) + } + + @Test + fun `collects recent CLI logs including PID and rotated logs only from the directory itself`() = runTest { + Files.createDirectories(logs) + val expected = mapOf( + "coder-ssh-20261015-120000-abcde-123.log" to "session", + "coder-ssh-session.log.1" to "rotated", + "12345.log" to "older CLI", + ) + for ((name, contents) in expected) Files.writeString(logs.resolve(name), contents) + for (name in listOf("other.log", "secret.env", "12345.log.1", "coder-ssh.log.zip")) { + Files.writeString(logs.resolve(name), "unrelated") + } + val old = Files.writeString(logs.resolve("coder-ssh-old.log"), "old") + Files.setLastModifiedTime(old, FileTime.from(Instant.now().minus(Duration.ofDays(4)))) + Files.createDirectories(logs.resolve("nested")) + Files.writeString(logs.resolve("nested/coder-ssh-nested.log"), "nested") + Files.createDirectory(logs.resolve("coder-ssh-directory.log")) + + collector().collect(diagnostics) + + val actual = Files.list(diagnostics.resolve("coder-ssh")).use { files -> + files.toList().associate { it.fileName.toString() to Files.readString(it) } + } + assertEquals(expected, actual) + } + + @Test + fun `missing or empty log directory leaves other diagnostics available`() = runTest { + Files.createDirectories(diagnostics) + Files.writeString(diagnostics.resolve("coder-support.zip"), "bundle") + collector().collect(diagnostics) + Files.createDirectories(logs) + collector().collect(diagnostics) + assertEquals("bundle", Files.readString(diagnostics.resolve("coder-support.zip"))) + assertFalse(Files.exists(diagnostics.resolve("coder-ssh"))) + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `skips symbolic links to files and directories`() = runTest { + Files.createDirectories(logs) + val secret = Files.writeString(root.resolve("secret.txt"), "secret") + Files.createSymbolicLink(logs.resolve("coder-ssh-secret-12345.log"), secret) + Files.createSymbolicLink(logs.resolve("coder-ssh-directory-12345.log"), root) + collector().collect(diagnostics, activeSshPid = 12345) + assertFalse(Files.exists(diagnostics.resolve("coder-ssh"))) + } + + @Test + fun `one failed copy does not prevent collecting other logs`() = runTest { + Files.createDirectories(logs) + val source = Files.writeString(logs.resolve("coder-ssh-blocked.log"), "blocked") + Files.writeString(logs.resolve("coder-ssh-readable.log"), "readable") + val blocked = Files.createDirectories(diagnostics.resolve("coder-ssh/coder-ssh-blocked.log")) + Files.writeString(blocked.resolve("keep.txt"), "existing diagnostics") + val delegate = mockk(relaxed = true) + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk()) + val sessionId = SessionId.generate() + + CoderSshLogCollector(logger, logs).collect(diagnostics, sessionId) + + assertEquals("readable", Files.readString(diagnostics.resolve("coder-ssh/coder-ssh-readable.log"))) + assertEquals("existing diagnostics", Files.readString(blocked.resolve("keep.txt"))) + verify(exactly = 1) { + delegate.warn(any(), "client_session_id=$sessionId Could not collect SSH log $source") + } + } + + @Test + fun `copies only the last 50 MiB of an old active log without changing the source`() = runTest { + Files.createDirectories(logs) + val source = logs.resolve("coder-ssh-large-12345.log") + val limit = 50L * 1024 * 1024 + RandomAccessFile(source.toFile(), "rw").use { + it.writeBytes("head") + it.seek(limit) + it.writeBytes("tail") + } + Files.setLastModifiedTime(source, FileTime.from(Instant.now().minus(Duration.ofDays(4)))) + + collector().collect(diagnostics, activeSshPid = 12345) + + val output = diagnostics.resolve("coder-ssh/coder-ssh-large-12345.log") + assertEquals(limit, Files.size(output)) + RandomAccessFile(output.toFile(), "r").use { + assertEquals(0, it.read()) + it.seek(limit - 4) + assertEquals("tail", it.readLine()) + } + assertEquals(limit + 4, Files.size(source)) + RandomAccessFile(source.toFile(), "r").use { assertEquals('h'.code, it.read()) } + } + + @Test + fun `cancellation propagates without collecting SSH logs`() = runTest { + Files.createDirectories(logs) + Files.writeString(logs.resolve("coder-ssh-session.log"), "SSH session") + launch { + currentCoroutineContext().cancel() + assertFailsWith { collector().collect(diagnostics) } + }.join() + assertFalse(Files.exists(diagnostics)) + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `unreadable files do not prevent collecting other logs`() = runTest { + Files.createDirectories(logs) + val unreadable = Files.writeString(logs.resolve("coder-ssh-unreadable.log"), "unreadable") + Files.writeString(logs.resolve("coder-ssh-readable.log"), "readable") + val permissions = Files.getPosixFilePermissions(unreadable) + try { + Files.setPosixFilePermissions(unreadable, emptySet()) + collector().collect(diagnostics) + assertTrue(Files.exists(diagnostics.resolve("coder-ssh/coder-ssh-readable.log"))) + if (!Files.isReadable(unreadable)) { + assertFalse(Files.exists(diagnostics.resolve("coder-ssh/coder-ssh-unreadable.log"))) + } + } finally { + Files.setPosixFilePermissions(unreadable, permissions) + } + } +} diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt index 13eb7bbc..2f6b7084 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt @@ -1,6 +1,12 @@ package com.coder.toolbox.diagnostics +import com.coder.toolbox.cli.CoderCLIManager.Companion.getSshLogDirectory +import com.coder.toolbox.session.SessionId +import com.coder.toolbox.util.OS +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.confirmVerified import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.io.TempDir @@ -16,19 +22,64 @@ class CoderSupportBundleCollectorTest { @TempDir lateinit var root: Path + @Test + fun `unavailable diagnostics directory stops collection and logs one warning`() = runTest { + val destination = Files.writeString(root.resolve("diagnostics"), "existing file") + val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) + val sshLog = Files.writeString(sshLogs.resolve("coder-ssh-session.log"), "SSH session") + val logger = mockk(relaxed = true) + var bundleCollected = false + val collector = CoderSupportBundleCollector(logger, sshLogs) { + bundleCollected = true + } + + collector.collectAdditionalDiagnostics(destination) + + assertFalse(bundleCollected) + assertEquals("existing file", Files.readString(destination)) + assertEquals("SSH session", Files.readString(sshLog)) + verify(exactly = 1) { logger.warn(any(), "Could not collect Coder workspace diagnostics") } + confirmVerified(logger) + } + @Test fun `writes the bundle inside Toolbox diagnostics`() = runTest { - val collector = CoderSupportBundleCollector(mockk(relaxed = true)) { + val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) + Files.writeString(sshLogs.resolve("coder-ssh-session.log"), "SSH session") + val collector = CoderSupportBundleCollector(mockk(relaxed = true), sshLogs) { Files.writeString(it, "bundle") } collector.collectAdditionalDiagnostics(root.resolve("diagnostics")) assertEquals("bundle", Files.readString(root.resolve("diagnostics/coder-support.zip"))) + assertEquals("SSH session", Files.readString(root.resolve("diagnostics/coder-ssh/coder-ssh-session.log"))) + } + + @Test + fun `collects SSH logs from the CLI default when no log directory is configured`() = runTest { + val sshLogs = Files.createDirectories(root.resolve(".local/state/coder/logs")) + Files.writeString(sshLogs.resolve("coder-ssh-default.log"), "default SSH session") + val collector = CoderSupportBundleCollector( + mockk(relaxed = true), + getSshLogDirectory(null, emptyMap(), OS.LINUX, root), + ) { Files.writeString(it, "bundle") } + + collector.collectAdditionalDiagnostics(root.resolve("diagnostics")) + + assertEquals( + "default SSH session", + Files.readString(root.resolve("diagnostics/coder-ssh/coder-ssh-default.log")), + ) } @Test fun `failure removes partial bundle and preserves other diagnostics without leaking error details`() = runTest { Files.writeString(root.resolve("other.log"), "other diagnostics") - val collector = CoderSupportBundleCollector(mockk(relaxed = true)) { + val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) + Files.writeString(sshLogs.resolve("12345.log"), "SSH session") + val delegate = mockk(relaxed = true) + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk()) + val sessionId = SessionId.generate() + val collector = CoderSupportBundleCollector(logger, sshLogs, currentSessionId = { sessionId }) { Files.writeString(it, "partial") error("sensitive command output") } @@ -38,16 +89,43 @@ class CoderSupportBundleCollectorTest { val report = Files.readString(root.resolve("coder-support-error.txt")) assertTrue(report.contains("IllegalStateException")) assertFalse(report.contains("sensitive command output")) + assertEquals("SSH session", Files.readString(root.resolve("coder-ssh/12345.log"))) + verify(exactly = 1) { + delegate.warn(any(), "client_session_id=$sessionId Could not collect the Coder support bundle") + } } @Test fun `cancellation propagates and removes partial output`() = runTest { - val collector = CoderSupportBundleCollector(mockk(relaxed = true)) { + val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) + Files.writeString(sshLogs.resolve("coder-ssh-session.log"), "SSH session") + val collector = CoderSupportBundleCollector(mockk(relaxed = true), sshLogs) { Files.writeString(it, "partial") throw CancellationException("cancelled") } assertFailsWith { collector.collectAdditionalDiagnostics(root) } assertFalse(Files.exists(root.resolve("coder-support.zip"))) assertFalse(Files.exists(root.resolve("coder-support-error.txt"))) + assertFalse(Files.exists(root.resolve("coder-ssh"))) + } + + @Test + fun `SSH log failure preserves the support bundle`() = runTest { + val invalidDirectory = Files.writeString(root.resolve("ssh-logs"), "not a directory") + val delegate = mockk(relaxed = true) + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk()) + val sessionId = SessionId.generate() + val collector = CoderSupportBundleCollector(logger, invalidDirectory, currentSessionId = { sessionId }) { + Files.writeString(it, "bundle") + } + collector.collectAdditionalDiagnostics(root) + assertEquals("bundle", Files.readString(root.resolve("coder-support.zip"))) + assertFalse(Files.exists(root.resolve("coder-support-error.txt"))) + verify(exactly = 1) { + delegate.warn( + any(), + "client_session_id=$sessionId Could not collect Coder workspace diagnostics", + ) + } } } From e167e813cba30a7e719f568602ce6a4a4640c27f Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 16 Oct 2026 00:17:57 +0300 Subject: [PATCH 2/2] chore: next version is 0.9.5 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 98f923f0..594d1845 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.9.4 +version=0.9.5 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file