Skip to content

Commit 4c3bce0

Browse files
committed
fix: add authority diagnostics for workspace open
1 parent 80d181a commit 4c3bce0

6 files changed

Lines changed: 120 additions & 19 deletions

File tree

src/commands.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,7 +1348,6 @@ export class Commands {
13481348
if (!vscode.workspace.workspaceFolders?.length) {
13491349
newWindow = false;
13501350
}
1351-
13521351
if (!folderPath && useDefaultDirectory) {
13531352
folderPath = agent.expanded_directory;
13541353
}
@@ -1366,7 +1365,6 @@ export class Commands {
13661365
// reference a workspace without an agent name, which will be missed.
13671366
(opened) => opened.folderUri?.authority === remoteAuthority,
13681367
);
1369-
13701368
// openRecent will always use the most recent. Otherwise, if there are
13711369
// multiple we ask the user which to use.
13721370
if (opened.length === 1 || (opened.length > 1 && openRecent)) {
@@ -1399,6 +1397,13 @@ export class Commands {
13991397
this.logger.error(`IPC ping failed for ${remoteAuthority}`, err);
14001398
});
14011399

1400+
this.logger.info("Opening remote workspace", {
1401+
remoteAuthority,
1402+
workspace: `${workspace.owner_name}/${workspace.name}`,
1403+
agent: agent.name,
1404+
handoff: folderPath ? "folder" : "empty_window",
1405+
});
1406+
14021407
if (folderPath) {
14031408
await vscode.commands.executeCommand(
14041409
"vscode.openFolder",

src/remote/remote.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,23 @@ export class Remote {
135135
startupMode: StartupMode,
136136
remoteSshExtensionId: string,
137137
): Promise<RemoteDetails | undefined> {
138-
const parts = parseRemoteAuthority(remoteAuthority);
138+
let parts: AuthorityParts | null;
139+
try {
140+
parts = parseRemoteAuthority(remoteAuthority);
141+
} catch (error) {
142+
this.logger.warn("Failed to parse remote authority", {
143+
remoteAuthority,
144+
error: toError(error).message,
145+
});
146+
throw error;
147+
}
139148
if (!parts) {
140149
// Not a Coder host.
141150
return;
142151
}
143152

144-
this.logger.debug("Setting up remote connection", {
153+
this.logger.info("Setting up remote connection", {
154+
remoteAuthority,
145155
hostname: parts.safeHostname,
146156
workspace: `${parts.username}/${parts.workspace}`,
147157
agent: parts.agent || "(default)",

src/uri/uriHandler.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface UriRouteContext extends UriHandlerDeps {
2222
}
2323

2424
type UriRouteHandler = (ctx: UriRouteContext) => Promise<void>;
25+
type CoderUriRoute = "open" | "openDevContainer";
2526

2627
const routes: Readonly<Record<string, UriRouteHandler>> = {
2728
"/open": handleOpen,
@@ -48,7 +49,10 @@ export function registerUriHandler(deps: UriHandlerDeps): vscode.Disposable {
4849
});
4950
} catch (error) {
5051
const message = errToStr(error, "No error message was provided");
51-
output.warn(`Failed to handle URI ${uri.toString()}: ${message}`);
52+
output.warn("Failed to handle URI", {
53+
...summarizeUri(uri),
54+
error: message,
55+
});
5256
vscodeProposed.window.showErrorMessage("Failed to handle URI", {
5357
detail: message,
5458
modal: true,
@@ -67,6 +71,17 @@ function getRequiredParam(params: URLSearchParams, name: string): string {
6771
return value;
6872
}
6973

74+
function summarizeUri(uri: vscode.Uri): Record<string, string | boolean> {
75+
const params = new URLSearchParams(uri.query);
76+
return {
77+
path: uri.path,
78+
hasOwner: params.has("owner"),
79+
hasWorkspace: params.has("workspace"),
80+
hasAgent: params.has("agent"),
81+
hasToken: params.has("token"),
82+
};
83+
}
84+
7085
async function handleOpen(ctx: UriRouteContext): Promise<void> {
7186
const { params, serviceContainer, deploymentManager, commands } = ctx;
7287

@@ -78,7 +93,7 @@ async function handleOpen(ctx: UriRouteContext): Promise<void> {
7893
params.has("openRecent") &&
7994
(!params.get("openRecent") || params.get("openRecent") === "true");
8095

81-
await setupDeployment(params, serviceContainer, deploymentManager);
96+
await setupDeployment("open", params, serviceContainer, deploymentManager);
8297

8398
await commands.open({
8499
workspaceOwner: owner,
@@ -108,7 +123,12 @@ async function handleOpenDevContainer(ctx: UriRouteContext): Promise<void> {
108123
);
109124
}
110125

111-
await setupDeployment(params, serviceContainer, deploymentManager);
126+
await setupDeployment(
127+
"openDevContainer",
128+
params,
129+
serviceContainer,
130+
deploymentManager,
131+
);
112132

113133
await commands.openDevContainer(
114134
owner,
@@ -126,6 +146,7 @@ async function handleOpenDevContainer(ctx: UriRouteContext): Promise<void> {
126146
* and token storage. Throws if user cancels URL input or login fails.
127147
*/
128148
async function setupDeployment(
149+
route: CoderUriRoute,
129150
params: URLSearchParams,
130151
serviceContainer: ServiceContainer,
131152
deploymentManager: Pick<DeploymentManager, "setDeployment">,
@@ -154,6 +175,14 @@ async function setupDeployment(
154175
}
155176

156177
const safeHostname = toSafeHost(url);
178+
const owner = params.get("owner") ?? "";
179+
const workspace = params.get("workspace") ?? "";
180+
serviceContainer.getLogger().info("Handling Coder URI", {
181+
route,
182+
safeHostname,
183+
workspace: owner && workspace ? `${owner}/${workspace}` : "",
184+
agent: params.get("agent") ?? "(unspecified)",
185+
});
157186

158187
const token: string | undefined = params.get("token") ?? undefined;
159188
const result = await authTelemetry.traceLogin("uri", () =>

src/util.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ export function findPort(text: string): number | null {
5858
* SSH host names created by this extension match the format:
5959
* coder-vscode.<safeHostname>--<username>--<workspace>(.<agent?>)
6060
*
61-
* If this is not a Coder host, return null.
61+
* If this is not a full Coder authority, return null.
6262
*
63-
* Throw an error if the host is invalid.
63+
* Throw an error if a full Coder authority is invalid.
6464
*/
6565
export function parseRemoteAuthority(authority: string): AuthorityParts | null {
6666
const authorityParts = authority.split("+");
@@ -75,7 +75,7 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null {
7575
}
7676

7777
if (parts.length < 3) {
78-
throw new Error(invalidAuthorityMessage);
78+
return null;
7979
}
8080

8181
// Parse from the right because safe hostnames can contain "--".

test/unit/uri/uriHandler.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,11 +363,43 @@ describe("uriHandler", () => {
363363
await handleUri(createMockUri("/open", "workspace=w"));
364364

365365
expect(logger.warn).toHaveBeenCalledWith(
366-
expect.stringContaining("Failed to handle URI"),
366+
"Failed to handle URI",
367+
expect.objectContaining({
368+
error: expect.stringContaining("owner must be specified"),
369+
}),
367370
);
368371
expect(showErrorMessage).toHaveBeenCalled();
369372
});
370373

374+
it("does not log URI tokens on failure", async () => {
375+
const { handleUri, commands, logger } = createTestContext();
376+
commands.open.mockRejectedValue(new Error("Connection failed"));
377+
378+
await handleUri(
379+
createMockUri(
380+
"/open",
381+
`owner=o&workspace=w&url=${encodeURIComponent(TEST_URL)}&token=secret-token`,
382+
),
383+
);
384+
385+
expect(logger.warn).toHaveBeenCalledWith(
386+
"Failed to handle URI",
387+
expect.objectContaining({
388+
error: "Connection failed",
389+
}),
390+
);
391+
const logged = [
392+
...vi.mocked(logger.debug).mock.calls,
393+
...vi.mocked(logger.info).mock.calls,
394+
...vi.mocked(logger.warn).mock.calls,
395+
]
396+
.map((call) => JSON.stringify(call))
397+
.join("\n");
398+
expect(logged).not.toContain("secret-token");
399+
expect(logged).not.toContain("token=");
400+
expect(logged).not.toContain("vscode://coder.coder-remote");
401+
});
402+
371403
it("propagates command errors", async () => {
372404
const { handleUri, commands, showErrorMessage } = createTestContext();
373405
commands.open.mockRejectedValue(new Error("Connection failed"));

test/unit/util.test.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
openInBrowser,
1313
parseRemoteAuthority,
1414
resolveUiUrl,
15+
toRemoteAuthority,
1516
toSafeHost,
1617
} from "@/util";
1718

@@ -36,19 +37,23 @@ describe("parseRemoteAuthority", () => {
3637
input: remoteAuthority("coder-vscode-test--foo--bar"),
3738
},
3839
{ label: "wrong prefix", input: remoteAuthority("coder--foo--bar") },
40+
{
41+
label: "incomplete Coder host",
42+
input: remoteAuthority("coder-vscode.dev.coder.com"),
43+
},
44+
{
45+
label: "incomplete Coder host with username",
46+
input: remoteAuthority("coder-vscode.dev.coder.com--foo"),
47+
},
48+
{
49+
label: "manual non-Coder host with Coder prefix",
50+
input: remoteAuthority("coder-vscode.personal-host"),
51+
},
3952
])("ignores unrelated authority: $label", ({ input }) => {
4053
expect(parseRemoteAuthority(input)).toBe(null);
4154
});
4255

4356
it.each([
44-
{
45-
label: "missing user and workspace",
46-
sshHost: "coder-vscode.dev.coder.com",
47-
},
48-
{
49-
label: "missing workspace",
50-
sshHost: "coder-vscode.dev.coder.com--foo",
51-
},
5257
{
5358
label: "empty username",
5459
sshHost: "coder-vscode.dev.coder.com----bar",
@@ -88,6 +93,26 @@ describe("parseRemoteAuthority", () => {
8893
username?: string;
8994
}
9095

96+
it("round trips generated remote authorities", () => {
97+
const authority = toRemoteAuthority(
98+
"https://ほげ",
99+
"alice",
100+
"workspace",
101+
"main",
102+
);
103+
104+
expect(authority).toBe(
105+
"ssh-remote+coder-vscode.xn--18j4d--alice--workspace.main",
106+
);
107+
expect(parseRemoteAuthority(authority)).toStrictEqual({
108+
agent: "main",
109+
sshHost: "coder-vscode.xn--18j4d--alice--workspace.main",
110+
safeHostname: "xn--18j4d",
111+
username: "alice",
112+
workspace: "workspace",
113+
} satisfies AuthorityParts);
114+
});
115+
91116
it.each<ParseCase>([
92117
{
93118
label: "hostname without agent",

0 commit comments

Comments
 (0)