Skip to content

Commit 02e7782

Browse files
rentziassCopilot
andcommitted
Add diagnostic logging for debugger tunnel 403
Instruments the Actions job debugger flow with non-sensitive diagnostic logging so we can investigate devtunnel 403 upgrade failures that only reproduce on some users' machines. Logged (never the full token): - Prefix (first 4 chars) and length of the access token used, plus the session's granted scopes and account label. Distinguishes ghu_ (VS Code GitHub App) from gho_ (OAuth app) / ghp_ (PAT); Dev Tunnels only trusts ghu_. - A silent probe for a scope-less ("App-backed") session so we can tell whether the requested ["repo","workflow"] scopes forced VS Code onto the OAuth path. - Top-level field names of the /actions/jobs/{job_id}/debugger response, in case a separate tunnel access token is returned. - On WS upgrade failure, the tunnel's HTTP status, statusText, a whitelist of diagnostic headers, and a truncated response body via the ws 'unexpected-response' event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8d961c5 commit 02e7782

2 files changed

Lines changed: 89 additions & 0 deletions

File tree

src/debugger/debugger.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,36 @@ async function connectToDebugger(): Promise<void> {
6969
}
7070

7171
const token = session.accessToken;
72+
73+
// Diagnostic: log token shape (prefix + length) and session scopes to help
74+
// diagnose devtunnel 403 errors. `ghu_` = VS Code GitHub App user-to-server
75+
// token (accepted by devtunnels); `gho_`/`ghp_` = OAuth/PAT (may be rejected).
76+
// The full token is never logged.
77+
log(
78+
`[debugger] Using session: tokenPrefix=${token.slice(0, 4)} tokenLen=${token.length} ` +
79+
`scopes=[${session.scopes.join(",")}] account=${session.account.label}`
80+
);
81+
82+
// Diagnostic probe: silently check if a scope-less GitHub session exists.
83+
// Dev Tunnels only trusts VS Code GitHub App tokens (`ghu_`). A scope-less
84+
// session is always backed by the App, so if its prefix differs from the
85+
// one above we know the requested scopes forced VS Code onto the OAuth
86+
// app path (which returns `gho_` and gets rejected by the tunnel).
87+
try {
88+
const appSession = await vscode.authentication.getSession("github", [], {createIfNone: false, silent: true});
89+
if (appSession) {
90+
log(
91+
`[debugger] App-backed session probe: tokenPrefix=${appSession.accessToken.slice(0, 4)} ` +
92+
`tokenLen=${appSession.accessToken.length} scopes=[${appSession.scopes.join(",")}] ` +
93+
`sameAsAbove=${appSession.accessToken === token}`
94+
);
95+
} else {
96+
log(`[debugger] App-backed session probe: no scope-less session cached`);
97+
}
98+
} catch (e) {
99+
log(`[debugger] App-backed session probe failed: ${(e as Error).message}`);
100+
}
101+
72102
let debuggerUrl: string;
73103
try {
74104
debuggerUrl = await vscode.window.withProgress(
@@ -80,6 +110,13 @@ async function connectToDebugger(): Promise<void> {
80110
repo: parsed.repo,
81111
job_id: parsed.jobId
82112
});
113+
// Diagnostic: log top-level fields in the API response. If the API
114+
// returns a tunnel-specific access token (separate from the GitHub
115+
// token), we'd see it here and can switch the WS to use it.
116+
if (response.data && typeof response.data === "object") {
117+
const keys = Object.keys(response.data as object);
118+
log(`[debugger] /debugger API response fields: [${keys.join(", ")}]`);
119+
}
83120
return (response.data as {debugger_url: string}).debugger_url;
84121
}
85122
);

src/debugger/webSocketDapAdapter.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type {IncomingMessage} from "http";
12
import * as vscode from "vscode";
23
import WebSocket from "ws";
34
import {log, logDebug, logError} from "../log";
@@ -49,6 +50,56 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter {
4950
}
5051
});
5152

53+
// Diagnostic: the tunnel server may reject the WS upgrade with an HTTP
54+
// response (e.g. 401/403). The plain `error` event only surfaces
55+
// "Unexpected server response: 403" — grab the full response here so
56+
// we can log status text and diagnostic headers (which token auth the
57+
// tunnel checked, rejection reason, etc.).
58+
let unexpectedResponse: {status: number; statusMessage: string; headers: string} | undefined;
59+
const onUnexpectedResponse = (_req: unknown, res: IncomingMessage) => {
60+
const interestingHeaders = [
61+
"www-authenticate",
62+
"x-tunnel-authorization",
63+
"x-tunnel-service-version",
64+
"x-powered-by",
65+
"server",
66+
"date",
67+
"content-type",
68+
"content-length",
69+
"x-request-id",
70+
"x-correlation-id",
71+
"x-ms-request-id",
72+
"x-ms-error-code"
73+
];
74+
const headerLines = interestingHeaders
75+
.map(h => {
76+
const v = res.headers[h];
77+
return v ? `${h}=${Array.isArray(v) ? v.join(",") : v}` : undefined;
78+
})
79+
.filter(Boolean)
80+
.join(" ");
81+
unexpectedResponse = {
82+
status: res.statusCode ?? 0,
83+
statusMessage: res.statusMessage ?? "",
84+
headers: headerLines
85+
};
86+
log(
87+
`[debugger] Tunnel rejected WS upgrade: ${unexpectedResponse.status} ` +
88+
`${unexpectedResponse.statusMessage} | ${headerLines}`
89+
);
90+
// Drain the body so the socket can close cleanly, and capture any
91+
// error text the tunnel included (e.g. a JSON {message: "..."}).
92+
const chunks: Buffer[] = [];
93+
res.on("data", (c: Buffer) => chunks.push(c));
94+
res.on("end", () => {
95+
if (chunks.length > 0) {
96+
const body = Buffer.concat(chunks).toString("utf8").slice(0, 500);
97+
log(`[debugger] Tunnel rejection body: ${body}`);
98+
}
99+
});
100+
};
101+
ws.on("unexpected-response", onUnexpectedResponse);
102+
52103
const connectTimer = setTimeout(() => {
53104
if (!settled) {
54105
settled = true;
@@ -99,6 +150,7 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter {
99150
ws.removeListener("open", onOpen);
100151
ws.removeListener("error", onError);
101152
ws.removeListener("close", onClose);
153+
ws.removeListener("unexpected-response", onUnexpectedResponse);
102154
};
103155

104156
ws.on("open", onOpen);

0 commit comments

Comments
 (0)