Skip to content

Commit efdf456

Browse files
authored
feat: collect remote editor logs in support bundles (#1042)
Support bundles now include the remote editor server's logs alongside the local Remote-SSH and Coder proxy logs. Remote files are passed through the CLI's repeatable `--workspace-file` flag, gated to Coder 2.36.0+. - Resolve the server data directory from `remote.SSH.serverInstallPath` for the workspace's SSH host, falling back to `~/{serverDataFolderName}` from the editor's `product.json`. No remote connection needed. - Handle each Remote-SSH fork's convention: parent directory for Microsoft and Cursor, final data directory for Open Remote SSH (with exact > wildcard > `*` precedence), home default for Windsurf and Antigravity. - Collect three known layouts: `data/logs/**/*.log`, `.*.log`, and `cli/servers/*/log.txt`. Both startup layouts are included rather than gated on `remote.SSH.useExecServer`, which can change after connection. - Omit remote logs rather than scan broad paths or pass unresolved environment variables. - Pass the selected agent to `coder support bundle`, reconstructing the SSH authority from workspace metadata when not connected so sidebar, picker, and palette flows resolve identically. - Add `raceWithAbort` to keep glob resolution cancellable, since the VS Code resolution APIs take no signal.
1 parent 82f5c6e commit efdf456

12 files changed

Lines changed: 1060 additions & 13 deletions

File tree

src/commands.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { runDiagnosticCli } from "./command/diagnosticFlow";
1313
import * as cliExec from "./core/cliExec";
1414
import { CertificateError } from "./error/certificateError";
15-
import { toError } from "./error/errorUtils";
15+
import { raceWithAbort, toError } from "./error/errorUtils";
1616
import { type FeatureSet, featureSetForVersion } from "./featureSet";
1717
import {
1818
AuthTelemetry,
@@ -45,6 +45,10 @@ import {
4545
} from "./remote/sshOverrides";
4646
import { resolveCliAuth } from "./settings/cli";
4747
import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs";
48+
import {
49+
getRemoteServerDataPath,
50+
toRemoteLogGlobs,
51+
} from "./supportBundle/remoteServerDataPath";
4852
import { runExportTelemetryCommand } from "./telemetry/export/command";
4953
import { toRemoteAuthority } from "./util/authority";
5054
import { openInBrowser, toSafeHost } from "./util/uri";
@@ -99,8 +103,11 @@ interface LoginArgs {
99103
type WorkspaceResolution =
100104
| {
101105
readonly status: "selected";
106+
readonly agentName?: string;
102107
readonly client: CoderApi;
103108
readonly workspaceId: string;
109+
/** Authority for the workspace, live or reconstructed from metadata. */
110+
readonly remoteAuthority?: string;
104111
}
105112
| { readonly status: "cancelled" }
106113
| {
@@ -150,6 +157,7 @@ export class Commands {
150157
// are logged into (for convenience; otherwise the recents menu can be a pain
151158
// if you use multiple deployments).
152159
public workspace?: Workspace;
160+
public agent?: WorkspaceAgent;
153161
public workspaceLogPath?: string;
154162
public remoteWorkspaceClient?: CoderApi;
155163

@@ -424,7 +432,7 @@ export class Commands {
424432
return;
425433
}
426434

427-
const { client, workspaceId } = resolved;
435+
const { agentName, client, workspaceId, remoteAuthority } = resolved;
428436

429437
const outputUri = await this.promptSupportBundlePath();
430438
if (!outputUri) {
@@ -435,13 +443,30 @@ export class Commands {
435443
const result = await withCancellableProgress(
436444
async ({ signal, progress }) => {
437445
progress.report({ message: "Resolving CLI..." });
446+
// Independent of the CLI and never rejects, so resolve the globs
447+
// concurrently and discard them when the CLI lacks support.
448+
const remoteLogGlobs = getRemoteServerDataPath({
449+
appRoot: vscode.env.appRoot,
450+
remoteAuthority,
451+
logger: this.logger,
452+
}).then(toRemoteLogGlobs);
438453
const env = await this.resolveCliEnv(client);
439454
if (!env.featureSet.supportBundle) {
440455
throw new SupportBundleUnsupportedCliError();
441456
}
442457

458+
// The resolution APIs take no signal, so race to keep cancel honest.
459+
const workspaceFiles = env.featureSet.supportBundleWorkspaceFiles
460+
? await raceWithAbort(remoteLogGlobs, signal)
461+
: [];
462+
443463
progress.report({ message: "Collecting diagnostics..." });
444-
await cliExec.supportBundle(env, workspaceId, outputUri.fsPath, signal);
464+
await cliExec.supportBundle(env, workspaceId, {
465+
outputPath: outputUri.fsPath,
466+
agentName,
467+
workspaceFiles,
468+
signal,
469+
});
445470

446471
progress.report({ message: "Adding VS Code logs..." });
447472
await appendVsCodeLogs(
@@ -1131,17 +1156,27 @@ export class Commands {
11311156
item?: OpenableTreeItem,
11321157
): Promise<WorkspaceResolution> {
11331158
if (item) {
1159+
const agentName =
1160+
item instanceof AgentTreeItem ? item.agent.name : undefined;
11341161
return {
11351162
status: "selected",
1163+
agentName,
11361164
client: this.extensionClient,
11371165
workspaceId: createWorkspaceIdentifier(item.workspace),
1166+
remoteAuthority: this.toWorkspaceAuthority(
1167+
this.extensionClient,
1168+
item.workspace,
1169+
agentName,
1170+
),
11381171
};
11391172
}
11401173
if (this.workspace && this.remoteWorkspaceClient) {
11411174
return {
11421175
status: "selected",
1176+
agentName: this.agent?.name,
11431177
client: this.remoteWorkspaceClient,
11441178
workspaceId: createWorkspaceIdentifier(this.workspace),
1179+
remoteAuthority: vscodeProposed.env.remoteAuthority,
11451180
};
11461181
}
11471182
const pick = await this.pickWorkspace("diagnostic", {
@@ -1155,11 +1190,36 @@ export class Commands {
11551190
status: "selected",
11561191
client: this.extensionClient,
11571192
workspaceId: createWorkspaceIdentifier(pick.workspace),
1193+
remoteAuthority: this.toWorkspaceAuthority(
1194+
this.extensionClient,
1195+
pick.workspace,
1196+
),
11581197
};
11591198
}
11601199
return pick;
11611200
}
11621201

1202+
/**
1203+
* Reconstruct the authority Remote-SSH would use, defaulting to the
1204+
* first agent like the CLI does.
1205+
*/
1206+
private toWorkspaceAuthority(
1207+
client: CoderApi,
1208+
workspace: Workspace,
1209+
agentName?: string,
1210+
): string | undefined {
1211+
const baseUrl = client.getAxiosInstance().defaults.baseURL;
1212+
if (!baseUrl) {
1213+
return undefined;
1214+
}
1215+
return toRemoteAuthority(
1216+
baseUrl,
1217+
workspace.owner_name,
1218+
workspace.name,
1219+
agentName ?? extractAgents(workspace.latest_build.resources)[0]?.name,
1220+
);
1221+
}
1222+
11631223
/** Resolve a CliEnv, preferring a locally cached binary over a network fetch. */
11641224
private async resolveCliEnv(
11651225
client: CoderApi,

src/core/cliExec.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,25 @@ export async function netcheck(
105105
export async function supportBundle(
106106
env: CliEnv,
107107
workspaceName: string,
108-
outputPath: string,
109-
signal?: AbortSignal,
108+
options: {
109+
outputPath: string;
110+
agentName?: string;
111+
workspaceFiles?: readonly string[];
112+
signal?: AbortSignal;
113+
},
110114
): Promise<void> {
115+
const { outputPath, agentName, workspaceFiles = [], signal } = options;
111116
const globalFlags = getGlobalFlags(env.configs, env.auth);
112117
const args = [
113118
...globalFlags,
114119
"support",
115120
"bundle",
116121
workspaceName,
122+
...(agentName ? [agentName] : []),
117123
"--output-file",
118124
outputPath,
119125
"--yes",
126+
...workspaceFiles.flatMap((file) => ["--workspace-file", file]),
120127
];
121128
try {
122129
await execFileAsync(env.binary, args, { signal });

src/error/errorUtils.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,37 @@ export function isAbortError(error: unknown): error is Error {
1414
*/
1515
export function throwIfAborted(signal: AbortSignal | undefined): void {
1616
if (!signal?.aborted) return;
17-
const reason: unknown = signal.reason;
18-
throw reason instanceof Error
17+
throw toAbortError(signal.reason);
18+
}
19+
20+
function toAbortError(reason: unknown): Error {
21+
return reason instanceof Error
1922
? reason
2023
: Object.assign(new Error("Aborted"), { name: "AbortError" });
2124
}
2225

26+
/**
27+
* Await `promise`, rejecting with an AbortError as soon as `signal` aborts.
28+
* The underlying work is abandoned, not stopped; use for operations that
29+
* cannot take a signal themselves.
30+
*/
31+
export async function raceWithAbort<T>(
32+
promise: Promise<T>,
33+
signal: AbortSignal,
34+
): Promise<T> {
35+
throwIfAborted(signal);
36+
let onAbort!: () => void;
37+
const aborted = new Promise<never>((_, reject) => {
38+
onAbort = () => reject(toAbortError(signal.reason));
39+
});
40+
signal.addEventListener("abort", onAbort, { once: true });
41+
try {
42+
return await Promise.race([promise, aborted]);
43+
} finally {
44+
signal.removeEventListener("abort", onAbort);
45+
}
46+
}
47+
2348
// getErrorDetail is copied from coder/site, but changes the default return.
2449
export const getErrorDetail = (error: unknown): string | undefined | null => {
2550
if (isApiError(error)) {

src/featureSet.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface FeatureSet {
99
keyringAuth: boolean;
1010
tokenRead: boolean;
1111
supportBundle: boolean;
12+
supportBundleWorkspaceFiles: boolean;
1213
}
1314

1415
/**
@@ -49,5 +50,7 @@ export function featureSetForVersion(
4950
tokenRead: versionAtLeast(version, "2.31.0"),
5051
// `coder support bundle` (officially released/unhidden in 2.10.0)
5152
supportBundle: versionAtLeast(version, "2.10.0"),
53+
// --workspace-file flag for `coder support bundle`
54+
supportBundleWorkspaceFiles: versionAtLeast(version, "2.36.0"),
5255
};
5356
}

src/remote/remote.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ export class Remote {
638638
});
639639

640640
this.commands.workspace = workspace;
641+
this.commands.agent = agent;
641642
return agent;
642643
}
643644

0 commit comments

Comments
 (0)