From 7f58e2c2fbc7cdc8bffd1c00b82fd739c7ea347c Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 9 Apr 2026 08:23:07 -0700 Subject: [PATCH 1/4] Add 'Connect to Actions Job Debugger' command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the first step toward bringing the Actions job debugging experience into VS Code. The full vision is described in the ADR (c2c-actions/docs/adrs/9758-actions-job-debugger-mvp.md): users will be able to re-run a job with a debugger attached and connect their editor to inspect variables, evaluate expressions, and run commands in the job's runtime context — all through the standard Debug Adapter Protocol. This PR adds the extension-side connect flow: - A new command ('GitHub Actions: Connect to Actions Job Debugger...') that prompts for a Dev Tunnel wss:// URL and starts a debug session. - A WebSocket-based inline debug adapter (DebugAdapterInlineImplementation) that speaks DAP-over-WebSocket directly to the runner — no local TCP bridge needed. - Authentication using the existing VS Code GitHub session token, sent as a Bearer token on the WebSocket handshake. The tunnel URL is entered manually for now. Once the server-side endpoint that serves the debugger URL for a job is deployed, we can automate this (and eventually add 're-run with debugger' directly from the extension). Security hardening: - Tunnel URLs restricted to wss://*.devtunnels.ms only. - Auth tokens kept in extension-private memory with cryptographic nonces, never exposed through DebugConfiguration. - Tunnel URL re-validated in the adapter factory (defense in depth). - 30s connection timeout prevents indefinite hangs. - WebSocket send/ping errors trigger clean session teardown. - Debugger registration gated to Desktop VS Code (Node context). Workarounds for VS Code quirks (to be removed as the runner evolves): - Synthetic source references on stack frames so VS Code auto-focuses the top frame and loads variables on connect/step. - Buffering of early 'stopped' events that the runner sends before VS Code completes the DAP handshake. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + package-lock.json | 50 ++++- package.json | 18 +- src/debugger/debugger.ts | 181 +++++++++++++++++ src/debugger/tunnelUrl.test.ts | 68 +++++++ src/debugger/tunnelUrl.ts | 35 ++++ src/debugger/webSocketDapAdapter.ts | 305 ++++++++++++++++++++++++++++ src/extension.ts | 6 + 8 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 src/debugger/debugger.ts create mode 100644 src/debugger/tunnelUrl.test.ts create mode 100644 src/debugger/tunnelUrl.ts create mode 100644 src/debugger/webSocketDapAdapter.ts diff --git a/README.md b/README.md index 4a89a585..47cd40e3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # GitHub Actions for VS Code > **🐛 Actions Job Debugger (Preview):** To try the latest debugger build, download the `.vsix` artifact from the most recent [Build Debugger Extension](https://github.com/github/vscode-github-actions/actions/workflows/debugger-build.yml) workflow run. On the workflow run page, scroll to **Artifacts** and download **vscode-github-actions-debugger**. Then install it in VS Code by running `code --install-extension ` or via the Extensions view → `⋯` menu → **Install from VSIX…**. +> +> Once installed, open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run **GitHub Actions: Connect to Actions Job Debugger…**. Paste the `wss://` tunnel URL from a debug-mode job and the extension will open a full debug session using your current GitHub credentials. The GitHub Actions extension lets you manage your workflows, view the workflow run history, and helps with authoring workflows. diff --git a/package-lock.json b/package-lock.json index 32db182a..3899fe3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,13 +25,15 @@ "tunnel": "0.0.6", "util": "^0.12.1", "uuid": "^3.3.3", - "vscode-languageclient": "^8.0.2" + "vscode-languageclient": "^8.0.2", + "ws": "^8.20.0" }, "devDependencies": { "@types/jest": "^29.0.3", "@types/libsodium-wrappers": "^0.7.10", "@types/uuid": "^3.4.6", "@types/vscode": "^1.72.0", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/parser": "^5.40.0", "@vscode/test-web": "^0.0.69", @@ -1980,6 +1982,16 @@ "integrity": "sha512-WvHluhUo+lQvE3I4wUagRpnkHuysB4qSyOQUyIAS9n9PYMJjepzTUD8Jyks0YeXoPD0UGctjqp2u84/b3v6Ydw==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.20", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.20.tgz", @@ -10438,6 +10450,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", @@ -12029,6 +12062,15 @@ "integrity": "sha512-WvHluhUo+lQvE3I4wUagRpnkHuysB4qSyOQUyIAS9n9PYMJjepzTUD8Jyks0YeXoPD0UGctjqp2u84/b3v6Ydw==", "dev": true }, + "@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/yargs": { "version": "17.0.20", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.20.tgz", @@ -18227,6 +18269,12 @@ "signal-exit": "^3.0.7" } }, + "ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "requires": {} + }, "xmlbuilder": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", diff --git a/package.json b/package.json index 785b1034..1bc8143f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "activationEvents": [ "onView:workflows", "onView:settings", + "onDebugResolve:github-actions-job", + "onCommand:github-actions.debugger.connect", "workspaceContains:**/.github/workflows/**", "workspaceContains:**/action.yml", "workspaceContains:**/action.yaml" @@ -97,7 +99,19 @@ } } }, + "debuggers": [ + { + "type": "github-actions-job", + "label": "GitHub Actions Job Debugger", + "languages": [] + } + ], "commands": [ + { + "command": "github-actions.debugger.connect", + "category": "GitHub Actions", + "title": "Connect to Actions Job Debugger..." + }, { "command": "github-actions.explorer.refresh", "category": "GitHub Actions", @@ -544,6 +558,7 @@ "@types/libsodium-wrappers": "^0.7.10", "@types/uuid": "^3.4.6", "@types/vscode": "^1.72.0", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/parser": "^5.40.0", "@vscode/test-web": "^0.0.69", @@ -579,7 +594,8 @@ "tunnel": "0.0.6", "util": "^0.12.1", "uuid": "^3.3.3", - "vscode-languageclient": "^8.0.2" + "vscode-languageclient": "^8.0.2", + "ws": "^8.20.0" }, "overrides": { "browserify-sign": { diff --git a/src/debugger/debugger.ts b/src/debugger/debugger.ts new file mode 100644 index 00000000..48aede56 --- /dev/null +++ b/src/debugger/debugger.ts @@ -0,0 +1,181 @@ +import * as crypto from "crypto"; +import * as vscode from "vscode"; +import {getSession, newSession} from "../auth/auth"; +import {log, logDebug, logError} from "../log"; +import {validateTunnelUrl} from "./tunnelUrl"; +import {WebSocketDapAdapter} from "./webSocketDapAdapter"; + +/** The custom debug type registered in package.json contributes.debuggers. */ +export const DEBUG_TYPE = "github-actions-job"; + +/** + * Extension-private store for auth tokens, keyed by a one-time session + * nonce. Tokens are never placed in DebugConfiguration (which is readable + * by other extensions via vscode.debug.activeDebugSession.configuration). + */ +const pendingTokens = new Map(); + +/** + * Registers the Actions Job Debugger command and debug adapter factory. + * + * Contributes: + * - A command-palette command that prompts for a tunnel URL and starts a debug session. + * - A DebugAdapterDescriptorFactory that returns an inline DAP-over-WS adapter. + */ +export function registerDebugger(context: vscode.ExtensionContext): void { + // Register the inline adapter factory for our debug type. + context.subscriptions.push( + vscode.debug.registerDebugAdapterDescriptorFactory(DEBUG_TYPE, new ActionsDebugAdapterFactory()) + ); + + // Register a tracker to log all DAP traffic for diagnostics. + context.subscriptions.push( + vscode.debug.registerDebugAdapterTrackerFactory(DEBUG_TYPE, new ActionsDebugTrackerFactory()) + ); + + // Register the connect command. + context.subscriptions.push( + vscode.commands.registerCommand("github-actions.debugger.connect", () => connectToDebugger()) + ); +} + +async function connectToDebugger(): Promise { + // 1. Prompt for the tunnel URL. + const rawUrl = await vscode.window.showInputBox({ + title: "Connect to Actions Job Debugger", + prompt: "Enter the debugger tunnel URL (wss://…)", + placeHolder: "wss://xxxx-4711.region.devtunnels.ms/", + ignoreFocusOut: true, + validateInput: input => { + if (!input) { + return "A tunnel URL is required"; + } + const result = validateTunnelUrl(input); + return result.valid ? null : result.reason; + } + }); + + if (!rawUrl) { + return; // user cancelled + } + + const validation = validateTunnelUrl(rawUrl); + if (!validation.valid) { + void vscode.window.showErrorMessage(`Invalid tunnel URL: ${validation.reason}`); + return; + } + + // 2. Acquire a GitHub auth session. The token is used as a Bearer token + // against the Dev Tunnel relay, which accepts VS Code's GitHub app tokens. + // Try silently first; fall back to prompting for sign-in if needed. + let session = await getSession(); + if (!session) { + try { + session = await newSession("Sign in to GitHub to connect to the Actions job debugger."); + } catch { + void vscode.window.showErrorMessage( + "GitHub authentication is required to connect to the Actions job debugger. Please sign in and try again." + ); + return; + } + } + + // 3. Launch the debug session. The token is stored in extension-private + // memory (not in the configuration) to avoid exposing it to other extensions. + const nonce = crypto.randomBytes(16).toString("hex"); + pendingTokens.set(nonce, session.accessToken); + + const config: vscode.DebugConfiguration = { + type: DEBUG_TYPE, + name: "Actions Job Debugger", + request: "attach", + tunnelUrl: validation.url, + __tokenNonce: nonce + }; + + log(`Starting debug session for ${validation.url}`); + + try { + const started = await vscode.debug.startDebugging(undefined, config); + if (!started) { + void vscode.window.showErrorMessage( + "Failed to start the debug session. Check the GitHub Actions output for details." + ); + } + } finally { + // Clean up if the factory hasn't consumed the token yet + pendingTokens.delete(nonce); + } +} + +class ActionsDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory { + async createDebugAdapterDescriptor(session: vscode.DebugSession): Promise { + const tunnelUrl = session.configuration.tunnelUrl as string | undefined; + const nonce = session.configuration.__tokenNonce as string | undefined; + const token = nonce ? pendingTokens.get(nonce) : undefined; + + // Consume the token immediately so it cannot be replayed. + if (nonce) { + pendingTokens.delete(nonce); + } + + if (!tunnelUrl || !token) { + throw new Error( + "Missing tunnel URL or authentication token. Use the 'Connect to Actions Job Debugger' command to start a session." + ); + } + + // Re-validate the tunnel URL as defense-in-depth + const revalidation = validateTunnelUrl(tunnelUrl); + if (!revalidation.valid) { + throw new Error(`Invalid debugger tunnel URL: ${revalidation.reason}`); + } + + const adapter = new WebSocketDapAdapter(tunnelUrl, token); + + try { + await adapter.connect(); + } catch (e) { + adapter.dispose(); + const msg = (e as Error).message; + logError(e as Error, "Failed to connect debugger adapter"); + throw new Error(`Could not connect to the debugger tunnel: ${msg}`); + } + + return new vscode.DebugAdapterInlineImplementation(adapter); + } +} + +class ActionsDebugTrackerFactory implements vscode.DebugAdapterTrackerFactory { + createDebugAdapterTracker(): vscode.DebugAdapterTracker { + return { + onWillReceiveMessage(message: unknown) { + const m = message as Record; + logDebug( + `[tracker] VS Code → DA: ${String(m.type)}${m.command ? `:${String(m.command)}` : ""} (seq ${String(m.seq)})` + ); + }, + onDidSendMessage(message: unknown) { + const m = message as Record; + const body = m.body as Record | undefined; + let detail = String(m.type); + if (m.command) { + detail += `:${String(m.command)}`; + } + if (m.event) { + detail += `:${String(m.event)}`; + } + if (m.event === "stopped" && body) { + detail += ` threadId=${String(body.threadId)} allThreadsStopped=${String(body.allThreadsStopped)}`; + } + logDebug(`[tracker] DA → VS Code: ${detail} (seq ${String(m.seq)})`); + }, + onError(error: Error) { + logError(error, "[tracker] DAP error"); + }, + onExit(code: number | undefined, signal: string | undefined) { + log(`[tracker] DAP session exited: code=${String(code)} signal=${String(signal)}`); + } + }; + } +} diff --git a/src/debugger/tunnelUrl.test.ts b/src/debugger/tunnelUrl.test.ts new file mode 100644 index 00000000..d095456b --- /dev/null +++ b/src/debugger/tunnelUrl.test.ts @@ -0,0 +1,68 @@ +import {validateTunnelUrl} from "./tunnelUrl"; + +describe("validateTunnelUrl", () => { + it("accepts a valid wss:// devtunnels URL", () => { + const result = validateTunnelUrl("wss://abcdef-4711.uks1.devtunnels.ms/"); + expect(result).toEqual({valid: true, url: "wss://abcdef-4711.uks1.devtunnels.ms/"}); + }); + + it("accepts a devtunnels URL without trailing slash", () => { + const result = validateTunnelUrl("wss://abcdef-4711.uks1.devtunnels.ms"); + expect(result.valid).toBe(true); + }); + + it("accepts a devtunnels URL with a path", () => { + const result = validateTunnelUrl("wss://abcdef-4711.uks1.devtunnels.ms/connect"); + expect(result.valid).toBe(true); + }); + + it("rejects ws:// (cleartext)", () => { + const result = validateTunnelUrl("ws://abcdef-4711.uks1.devtunnels.ms/"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("wss://"); + } + }); + + it("rejects http:// scheme", () => { + const result = validateTunnelUrl("http://abcdef-4711.uks1.devtunnels.ms/"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("wss://"); + } + }); + + it("rejects https:// scheme", () => { + const result = validateTunnelUrl("https://abcdef-4711.uks1.devtunnels.ms/"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("wss://"); + } + }); + + it("rejects non-devtunnels host", () => { + const result = validateTunnelUrl("wss://evil.example.com/"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("not an allowed tunnel domain"); + } + }); + + it("rejects empty string", () => { + const result = validateTunnelUrl(""); + expect(result.valid).toBe(false); + }); + + it("rejects invalid URL format", () => { + const result = validateTunnelUrl("not a url at all"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("Invalid URL"); + } + }); + + it("rejects URL with just a scheme", () => { + const result = validateTunnelUrl("wss://"); + expect(result.valid).toBe(false); + }); +}); diff --git a/src/debugger/tunnelUrl.ts b/src/debugger/tunnelUrl.ts new file mode 100644 index 00000000..265bbddd --- /dev/null +++ b/src/debugger/tunnelUrl.ts @@ -0,0 +1,35 @@ +/** + * Allowed tunnel host patterns. The GitHub token is sent as a Bearer token + * to these hosts, so this list must be kept tight. + */ +const ALLOWED_TUNNEL_HOST_PATTERN = /\.devtunnels\.ms$/; + +/** + * Validates a Dev Tunnel websocket URL for the Actions job debugger. + * + * Requirements: + * - Must use wss:// (cleartext ws:// is rejected to protect the auth token) + * - Host must match an allowed tunnel domain (*.devtunnels.ms) + */ +export function validateTunnelUrl(raw: string): {valid: true; url: string} | {valid: false; reason: string} { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return {valid: false, reason: "Invalid URL format"}; + } + + if (parsed.protocol !== "wss:") { + return {valid: false, reason: `URL must use wss:// scheme, got "${parsed.protocol.replace(":", "")}://"`}; + } + + if (!parsed.hostname) { + return {valid: false, reason: "URL must include a host"}; + } + + if (!ALLOWED_TUNNEL_HOST_PATTERN.test(parsed.hostname)) { + return {valid: false, reason: `Host "${parsed.hostname}" is not an allowed tunnel domain`}; + } + + return {valid: true, url: parsed.toString()}; +} diff --git a/src/debugger/webSocketDapAdapter.ts b/src/debugger/webSocketDapAdapter.ts new file mode 100644 index 00000000..35ee0a39 --- /dev/null +++ b/src/debugger/webSocketDapAdapter.ts @@ -0,0 +1,305 @@ +import * as vscode from "vscode"; +import WebSocket from "ws"; +import {log, logDebug, logError} from "../log"; + +/** + * Interval between websocket ping frames, matching the proven keepalive + * behaviour in gh-actions-debugger. + */ +const PING_INTERVAL_MS = 25_000; + +/** Maximum time to wait for the websocket handshake to complete. */ +const CONNECT_TIMEOUT_MS = 30_000; + +/** + * A VS Code inline debug adapter that speaks DAP over a websocket connection + * to the Actions runner's Dev Tunnel endpoint. + * + * DAP JSON payloads are sent as individual text websocket messages — no + * Content-Length framing is used on the wire. This matches the runner's + * WebSocketDapBridge and the gh-actions-debugger CLI bridge. + */ +export class WebSocketDapAdapter implements vscode.DebugAdapter { + private readonly _onDidSendMessage = new vscode.EventEmitter(); + readonly onDidSendMessage: vscode.Event = this._onDidSendMessage.event; + + private _ws: WebSocket | undefined; + private _pingTimer: ReturnType | undefined; + private _disposed = false; + + /** + * Whether VS Code has completed the DAP initialization handshake. The + * runner sends a `stopped` event immediately on connect (before the client + * sends `configurationDone`), and VS Code ignores `stopped` events that + * arrive before configuration is done. We buffer early `stopped` events + * and replay them once the handshake completes. + */ + private _configurationDone = false; + private _pendingStoppedEvents: vscode.DebugProtocolMessage[] = []; + + constructor(private readonly _tunnelUrl: string, private readonly _token: string) {} + + /** + * Opens the websocket connection to the tunnel. Must be called before the + * debug session can exchange messages. + * + * @throws if the connection fails or times out. + */ + async connect(): Promise { + log(`Connecting to debugger tunnel: ${this._tunnelUrl}`); + + return new Promise((resolve, reject) => { + let settled = false; + + const ws = new WebSocket(this._tunnelUrl, { + headers: { + Authorization: `Bearer ${this._token}` + } + }); + + const connectTimer = setTimeout(() => { + if (!settled) { + settled = true; + cleanup(); + ws.terminate(); + reject(new Error(`Connection timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)); + } + }, CONNECT_TIMEOUT_MS); + + const onOpen = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(connectTimer); + cleanup(); + log("Connected to debugger tunnel"); + this._ws = ws; + this._setupReceiver(ws); + this._startPingLoop(ws); + resolve(); + }; + + const onError = (err: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(connectTimer); + cleanup(); + logError(err, "Debugger tunnel connection error"); + reject(new Error(`Failed to connect to debugger tunnel: ${err.message}`)); + }; + + const onClose = (code: number, reason: Buffer) => { + if (settled) { + return; + } + settled = true; + clearTimeout(connectTimer); + cleanup(); + const reasonStr = reason.toString() || `code ${code}`; + logError(new Error(reasonStr), "Debugger tunnel connection closed before open"); + reject(new Error(`Debugger tunnel connection closed: ${reasonStr}`)); + }; + + const cleanup = () => { + ws.removeListener("open", onOpen); + ws.removeListener("error", onError); + ws.removeListener("close", onClose); + }; + + ws.on("open", onOpen); + ws.on("error", onError); + ws.on("close", onClose); + }); + } + + /** + * Called by VS Code to send a DAP message (request or response) to the + * remote debug adapter. + */ + handleMessage(message: vscode.DebugProtocolMessage): void { + if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { + logError(new Error("Cannot send — websocket not open"), "Debugger tunnel send failed"); + return; + } + + const json = JSON.stringify(message); + logDebug(`→ DAP: ${describeDapMessage(message)}`); + + try { + this._ws.send(json); + } catch (e) { + logError(e as Error, "Debugger tunnel send threw"); + this._fireTerminated(); + this.dispose(); + } + } + + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + this._stopPingLoop(); + if (this._ws) { + try { + this._ws.close(1000, "debug session ended"); + } catch { + // ignore close errors during teardown + } + this._ws = undefined; + } + this._onDidSendMessage.dispose(); + log("Debugger tunnel connection closed"); + } + + private _setupReceiver(ws: WebSocket): void { + ws.on("message", (data: WebSocket.Data) => { + if (this._disposed) { + return; + } + + const text = typeof data === "string" ? data : data.toString(); + + let message: vscode.DebugProtocolMessage; + try { + message = JSON.parse(text) as vscode.DebugProtocolMessage; + } catch (e) { + logError(e as Error, "Failed to parse DAP message from tunnel"); + return; + } + + logDebug(`← DAP: ${describeDapMessage(message)}`); + + // Buffer stopped events that arrive before the configurationDone + // response — the runner re-sends the stopped event on connect + // (before the DAP handshake completes) and VS Code drops them. + const m = message as Record; + if (m.type === "event" && m.event === "stopped" && !this._configurationDone) { + logDebug("Buffering stopped event (configurationDone response not yet received)"); + this._pendingStoppedEvents.push(message); + return; + } + + // VS Code auto-focuses the top stack frame only if it has a source + // reference. The runner doesn't set one yet (the ADR calls for adding + // the workflow file later). Patch frames so VS Code auto-selects them. + if (m.type === "response" && m.command === "stackTrace") { + patchStackFrameSources(message); + } + + this._onDidSendMessage.fire(message); + + // When the configurationDone response arrives from the runner, + // replay any stopped events that were buffered during the + // handshake. We use a short delay so VS Code finishes processing + // the configurationDone response before receiving the event. + if (m.type === "response" && m.command === "configurationDone") { + this._configurationDone = true; + if (this._pendingStoppedEvents.length > 0) { + const events = this._pendingStoppedEvents; + this._pendingStoppedEvents = []; + logDebug(`Replaying ${events.length} buffered stopped event(s)`); + setTimeout(() => { + for (const evt of events) { + this._onDidSendMessage.fire(evt); + } + }, 50); + } + } + }); + + ws.on("close", (code: number, reason: Buffer) => { + if (this._disposed) { + return; + } + const reasonStr = reason.toString() || `code ${code}`; + log(`Debugger tunnel closed: ${reasonStr}`); + this._stopPingLoop(); + this._fireTerminated(); + }); + + ws.on("error", (err: Error) => { + logError(err, "Debugger tunnel error"); + }); + } + + private _startPingLoop(ws: WebSocket): void { + this._pingTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + try { + ws.ping("keepalive"); + } catch (e) { + logError(e as Error, "Websocket ping failed"); + this._stopPingLoop(); + this._fireTerminated(); + this.dispose(); + } + } else { + this._stopPingLoop(); + } + }, PING_INTERVAL_MS); + } + + private _stopPingLoop(): void { + if (this._pingTimer !== undefined) { + clearInterval(this._pingTimer); + this._pingTimer = undefined; + } + } + + /** Notify VS Code that the debug session is over. */ + private _fireTerminated(): void { + this._onDidSendMessage.fire({ + type: "event", + event: "terminated", + seq: 0 + } as unknown as vscode.DebugProtocolMessage); + } +} + +/** Build a short human-readable label for a DAP message for trace logging. */ +function describeDapMessage(msg: vscode.DebugProtocolMessage): string { + const m = msg as Record; + const type = (m.type as string) ?? "unknown"; + const detail = (m.command as string) ?? (m.event as string) ?? ""; + return detail ? `${type}:${detail}` : type; +} + +interface DapStackFrame { + id: number; + name: string; + source?: {name?: string; path?: string; sourceReference?: number; presentationHint?: string}; + line: number; + column: number; + presentationHint?: string; +} + +/** + * VS Code auto-focuses the top stack frame after a `stopped` event only when + * that frame carries a `source` reference. The runner doesn't set one yet (the + * ADR plans to add the workflow file as source later). Until then, we inject a + * minimal synthetic source so VS Code's auto-focus works. + */ +function patchStackFrameSources(message: vscode.DebugProtocolMessage): void { + const m = message as Record; + const body = m.body as {stackFrames?: DapStackFrame[]} | undefined; + if (!body?.stackFrames) { + return; + } + + for (const frame of body.stackFrames) { + if (!frame.source) { + frame.source = { + name: frame.name, + // A positive sourceReference tells VS Code to use the DAP `source` + // request to fetch content. We reuse the frame id; the runner will + // respond (or fail gracefully) when VS Code asks for it. + sourceReference: frame.id, + presentationHint: "deemphasize" + }; + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 210c9548..8ec7ea95 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,6 +34,7 @@ import {initResources} from "./treeViews/icons"; import {initTreeViews} from "./treeViews/treeViews"; import {deactivateLanguageServer, initLanguageServer} from "./workflow/languageServer"; import {registerSignIn} from "./commands/signIn"; +import {registerDebugger} from "./debugger/debugger"; export async function activate(context: vscode.ExtensionContext) { initLogger(); @@ -92,6 +93,11 @@ export async function activate(context: vscode.ExtensionContext) { registerSignIn(context); + // Debugger — only available in Desktop VS Code (requires Node.js for WebSocket) + if (vscode.env.uiKind === vscode.UIKind.Desktop) { + registerDebugger(context); + } + // Log providers context.subscriptions.push( vscode.workspace.registerTextDocumentContentProvider(LogScheme, new WorkflowStepLogProvider()) From e048a65089fca5e43a7fb9e7f8afa7ea2c83071f Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 10 Apr 2026 03:48:25 -0700 Subject: [PATCH 2/4] PR feedback --- src/debugger/tunnelUrl.test.ts | 16 ++++++++++++++++ src/debugger/tunnelUrl.ts | 4 ++++ src/debugger/webSocketDapAdapter.ts | 13 ++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/debugger/tunnelUrl.test.ts b/src/debugger/tunnelUrl.test.ts index d095456b..0cee9c04 100644 --- a/src/debugger/tunnelUrl.test.ts +++ b/src/debugger/tunnelUrl.test.ts @@ -61,6 +61,22 @@ describe("validateTunnelUrl", () => { } }); + it("rejects URL with username", () => { + const result = validateTunnelUrl("wss://user@abcdef-4711.uks1.devtunnels.ms/"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("Credentials in tunnel URL are not allowed"); + } + }); + + it("rejects URL with username and password", () => { + const result = validateTunnelUrl("wss://user:pass@abcdef-4711.uks1.devtunnels.ms/"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("Credentials in tunnel URL are not allowed"); + } + }); + it("rejects URL with just a scheme", () => { const result = validateTunnelUrl("wss://"); expect(result.valid).toBe(false); diff --git a/src/debugger/tunnelUrl.ts b/src/debugger/tunnelUrl.ts index 265bbddd..d28fd262 100644 --- a/src/debugger/tunnelUrl.ts +++ b/src/debugger/tunnelUrl.ts @@ -31,5 +31,9 @@ export function validateTunnelUrl(raw: string): {valid: true; url: string} | {va return {valid: false, reason: `Host "${parsed.hostname}" is not an allowed tunnel domain`}; } + if (parsed.username || parsed.password) { + return {valid: false, reason: "Credentials in tunnel URL are not allowed"}; + } + return {valid: true, url: parsed.toString()}; } diff --git a/src/debugger/webSocketDapAdapter.ts b/src/debugger/webSocketDapAdapter.ts index 35ee0a39..3cc75baa 100644 --- a/src/debugger/webSocketDapAdapter.ts +++ b/src/debugger/webSocketDapAdapter.ts @@ -25,6 +25,8 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { private _ws: WebSocket | undefined; private _pingTimer: ReturnType | undefined; + private _replayTimer: ReturnType | undefined; + private _terminatedFired = false; private _disposed = false; /** @@ -143,6 +145,10 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { } this._disposed = true; this._stopPingLoop(); + if (this._replayTimer) { + clearTimeout(this._replayTimer); + this._replayTimer = undefined; + } if (this._ws) { try { this._ws.close(1000, "debug session ended"); @@ -202,7 +208,9 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { const events = this._pendingStoppedEvents; this._pendingStoppedEvents = []; logDebug(`Replaying ${events.length} buffered stopped event(s)`); - setTimeout(() => { + this._replayTimer = setTimeout(() => { + this._replayTimer = undefined; + if (this._disposed) return; for (const evt of events) { this._onDidSendMessage.fire(evt); } @@ -219,6 +227,7 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { log(`Debugger tunnel closed: ${reasonStr}`); this._stopPingLoop(); this._fireTerminated(); + this.dispose(); }); ws.on("error", (err: Error) => { @@ -252,6 +261,8 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { /** Notify VS Code that the debug session is over. */ private _fireTerminated(): void { + if (this._terminatedFired) return; + this._terminatedFired = true; this._onDidSendMessage.fire({ type: "event", event: "terminated", From c2cb35f79d2d932cc8ee9b04da0cccab6705e7cf Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 10 Apr 2026 06:06:06 -0700 Subject: [PATCH 3/4] connect using job url and cleanup --- package.json | 2 +- src/debugger/debugger.ts | 87 +++++++++++------- src/debugger/jobUrl.test.ts | 133 ++++++++++++++++++++++++++++ src/debugger/jobUrl.ts | 56 ++++++++++++ src/debugger/tunnelUrl.ts | 7 -- src/debugger/webSocketDapAdapter.ts | 27 ++---- 6 files changed, 253 insertions(+), 59 deletions(-) create mode 100644 src/debugger/jobUrl.test.ts create mode 100644 src/debugger/jobUrl.ts diff --git a/package.json b/package.json index 1bc8143f..93a81cb7 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ { "command": "github-actions.debugger.connect", "category": "GitHub Actions", - "title": "Connect to Actions Job Debugger..." + "title": "Debug Running Job…" }, { "command": "github-actions.explorer.refresh", diff --git a/src/debugger/debugger.ts b/src/debugger/debugger.ts index 48aede56..cf7c15ec 100644 --- a/src/debugger/debugger.ts +++ b/src/debugger/debugger.ts @@ -1,73 +1,61 @@ import * as crypto from "crypto"; import * as vscode from "vscode"; +import {getClient} from "../api/api"; import {getSession, newSession} from "../auth/auth"; +import {getGitHubApiUri} from "../configuration/configuration"; import {log, logDebug, logError} from "../log"; +import {parseJobUrl} from "./jobUrl"; import {validateTunnelUrl} from "./tunnelUrl"; import {WebSocketDapAdapter} from "./webSocketDapAdapter"; -/** The custom debug type registered in package.json contributes.debuggers. */ export const DEBUG_TYPE = "github-actions-job"; /** - * Extension-private store for auth tokens, keyed by a one-time session - * nonce. Tokens are never placed in DebugConfiguration (which is readable - * by other extensions via vscode.debug.activeDebugSession.configuration). + * Extension-private token store keyed by one-time nonce. Tokens are never + * placed in DebugConfiguration (readable by other extensions). */ const pendingTokens = new Map(); -/** - * Registers the Actions Job Debugger command and debug adapter factory. - * - * Contributes: - * - A command-palette command that prompts for a tunnel URL and starts a debug session. - * - A DebugAdapterDescriptorFactory that returns an inline DAP-over-WS adapter. - */ export function registerDebugger(context: vscode.ExtensionContext): void { - // Register the inline adapter factory for our debug type. context.subscriptions.push( vscode.debug.registerDebugAdapterDescriptorFactory(DEBUG_TYPE, new ActionsDebugAdapterFactory()) ); - // Register a tracker to log all DAP traffic for diagnostics. context.subscriptions.push( vscode.debug.registerDebugAdapterTrackerFactory(DEBUG_TYPE, new ActionsDebugTrackerFactory()) ); - // Register the connect command. context.subscriptions.push( vscode.commands.registerCommand("github-actions.debugger.connect", () => connectToDebugger()) ); } async function connectToDebugger(): Promise { - // 1. Prompt for the tunnel URL. const rawUrl = await vscode.window.showInputBox({ title: "Connect to Actions Job Debugger", - prompt: "Enter the debugger tunnel URL (wss://…)", - placeHolder: "wss://xxxx-4711.region.devtunnels.ms/", + prompt: "Paste the URL of the Actions job to debug", + placeHolder: "https://github.com/owner/repo/actions/runs/123/job/456", ignoreFocusOut: true, validateInput: input => { if (!input) { - return "A tunnel URL is required"; + return "A job URL is required"; } - const result = validateTunnelUrl(input); + const result = parseJobUrl(input, getGitHubApiUri()); return result.valid ? null : result.reason; } }); if (!rawUrl) { - return; // user cancelled + return; } - const validation = validateTunnelUrl(rawUrl); - if (!validation.valid) { - void vscode.window.showErrorMessage(`Invalid tunnel URL: ${validation.reason}`); + const parsed = parseJobUrl(rawUrl, getGitHubApiUri()); + if (!parsed.valid) { + void vscode.window.showErrorMessage(`Invalid job URL: ${parsed.reason}`); return; } - // 2. Acquire a GitHub auth session. The token is used as a Bearer token - // against the Dev Tunnel relay, which accepts VS Code's GitHub app tokens. - // Try silently first; fall back to prompting for sign-in if needed. + // Try silently first; fall back to prompting for sign-in if needed. let session = await getSession(); if (!session) { try { @@ -80,10 +68,48 @@ async function connectToDebugger(): Promise { } } - // 3. Launch the debug session. The token is stored in extension-private - // memory (not in the configuration) to avoid exposing it to other extensions. + const token = session.accessToken; + let debuggerUrl: string; + try { + debuggerUrl = await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: "Connecting to Actions job debugger…"}, + async () => { + const octokit = getClient(token); + const response = await octokit.request("GET /repos/{owner}/{repo}/actions/jobs/{job_id}/debugger", { + owner: parsed.owner, + repo: parsed.repo, + job_id: parsed.jobId + }); + return (response.data as {debugger_url: string}).debugger_url; + } + ); + } catch (e) { + const status = (e as {status?: number}).status; + if (status === 404) { + void vscode.window.showErrorMessage( + "Debugger is not available for this job. Make sure the job is running with debugging enabled." + ); + } else if (status === 403) { + void vscode.window.showErrorMessage( + "Permission denied. You may need to re-authenticate or check your access to this repository." + ); + } else { + const msg = (e as Error).message || "Unknown error"; + void vscode.window.showErrorMessage(`Failed to fetch debugger URL: ${msg}`); + } + return; + } + + const validation = validateTunnelUrl(debuggerUrl); + if (!validation.valid) { + void vscode.window.showErrorMessage(`Invalid debugger URL returned by API: ${validation.reason}`); + return; + } + + // Store token in extension-private memory (not in the config) to avoid + // exposing it to other extensions. const nonce = crypto.randomBytes(16).toString("hex"); - pendingTokens.set(nonce, session.accessToken); + pendingTokens.set(nonce, token); const config: vscode.DebugConfiguration = { type: DEBUG_TYPE, @@ -114,7 +140,7 @@ class ActionsDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory const nonce = session.configuration.__tokenNonce as string | undefined; const token = nonce ? pendingTokens.get(nonce) : undefined; - // Consume the token immediately so it cannot be replayed. + // Consume immediately so it cannot be replayed. if (nonce) { pendingTokens.delete(nonce); } @@ -125,7 +151,6 @@ class ActionsDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory ); } - // Re-validate the tunnel URL as defense-in-depth const revalidation = validateTunnelUrl(tunnelUrl); if (!revalidation.valid) { throw new Error(`Invalid debugger tunnel URL: ${revalidation.reason}`); diff --git a/src/debugger/jobUrl.test.ts b/src/debugger/jobUrl.test.ts new file mode 100644 index 00000000..02125940 --- /dev/null +++ b/src/debugger/jobUrl.test.ts @@ -0,0 +1,133 @@ +import {parseJobUrl, getExpectedWebHost} from "./jobUrl"; + +const GITHUB_API_URI = "https://api.github.com"; + +describe("getExpectedWebHost", () => { + it("maps api.github.com to github.com", () => { + expect(getExpectedWebHost("https://api.github.com")).toBe("github.com"); + }); + + it("maps GHE Server api/v3 URL to the server host", () => { + expect(getExpectedWebHost("https://github.mycompany.com/api/v3")).toBe("github.mycompany.com"); + }); + + it("maps GHE Cloud api..ghe.com to .ghe.com", () => { + expect(getExpectedWebHost("https://api.myorg.ghe.com")).toBe("myorg.ghe.com"); + }); + + it("handles trailing slash on /api/v3/", () => { + expect(getExpectedWebHost("https://github.mycompany.com/api/v3/")).toBe("github.mycompany.com"); + }); +}); + +describe("parseJobUrl", () => { + it("accepts a valid github.com job URL", () => { + const result = parseJobUrl( + "https://github.com/galactic-potatoes/rentziass-test/actions/runs/24241071410/job/70775904678", + GITHUB_API_URI + ); + expect(result).toEqual({valid: true, owner: "galactic-potatoes", repo: "rentziass-test", jobId: "70775904678"}); + }); + + it("accepts a valid URL with trailing slash", () => { + const result = parseJobUrl( + "https://github.com/owner/repo/actions/runs/111/job/222/", + GITHUB_API_URI + ); + expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"}); + }); + + it("ignores query string and hash", () => { + const result = parseJobUrl( + "https://github.com/owner/repo/actions/runs/111/job/222?pr=1#step:2:3", + GITHUB_API_URI + ); + expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"}); + }); + + it("rejects wrong host", () => { + const result = parseJobUrl( + "https://gitlab.com/owner/repo/actions/runs/111/job/222", + GITHUB_API_URI + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("gitlab.com"); + } + }); + + it("rejects http:// scheme", () => { + const result = parseJobUrl( + "http://github.com/owner/repo/actions/runs/111/job/222", + GITHUB_API_URI + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("https://"); + } + }); + + it("rejects a repo URL without /job/ segment", () => { + const result = parseJobUrl("https://github.com/owner/repo", GITHUB_API_URI); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("job URL"); + } + }); + + it("rejects a run URL without /job/ segment", () => { + const result = parseJobUrl("https://github.com/owner/repo/actions/runs/111", GITHUB_API_URI); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("job URL"); + } + }); + + it("rejects empty string", () => { + const result = parseJobUrl("", GITHUB_API_URI); + expect(result.valid).toBe(false); + }); + + it("rejects malformed URL", () => { + const result = parseJobUrl("not a url at all", GITHUB_API_URI); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("Invalid URL"); + } + }); + + it("rejects URL with credentials", () => { + const result = parseJobUrl( + "https://user:pass@github.com/owner/repo/actions/runs/111/job/222", + GITHUB_API_URI + ); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.reason).toContain("Credentials"); + } + }); + + it("accepts non-numeric job ID", () => { + const result = parseJobUrl( + "https://github.com/owner/repo/actions/runs/111/job/abc-123", + GITHUB_API_URI + ); + expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "abc-123"}); + }); + + it("accepts plural /jobs/ path variant", () => { + const result = parseJobUrl( + "https://github.com/owner/repo/actions/runs/111/jobs/222", + GITHUB_API_URI + ); + expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"}); + }); + + it("validates against GHE Server host", () => { + const result = parseJobUrl( + "https://github.mycompany.com/owner/repo/actions/runs/111/job/222", + "https://github.mycompany.com/api/v3" + ); + expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"}); + }); +}); diff --git a/src/debugger/jobUrl.ts b/src/debugger/jobUrl.ts new file mode 100644 index 00000000..e685a669 --- /dev/null +++ b/src/debugger/jobUrl.ts @@ -0,0 +1,56 @@ +type ParseResult = {valid: true; owner: string; repo: string; jobId: string} | {valid: false; reason: string}; + +/** + * Derives the expected web host from the configured GitHub API URI. + * + * https://api.github.com → github.com + * https://api.myorg.ghe.com → myorg.ghe.com (GHE Cloud) + * https://myserver.com/api/v3 → myserver.com (GHE Server) + */ +export function getExpectedWebHost(apiUri: string): string { + const url = new URL(apiUri); + // GHE Server: host/api/v3 + if (url.pathname.replace(/\/$/, "") === "/api/v3") { + return url.hostname; + } + // github.com or GHE Cloud (api..ghe.com): strip leading "api." + if (url.hostname.startsWith("api.")) { + return url.hostname.slice(4); + } + return url.hostname; +} + +const JOB_PATH_RE = /^\/([^/]+)\/([^/]+)\/actions\/runs\/[^/]+\/jobs?\/([^/]+)\/?$/; + +// Expected format: https://github.com/{owner}/{repo}/actions/runs/{runId}/job/{jobId} +export function parseJobUrl(raw: string, apiUri: string): ParseResult { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return {valid: false, reason: "Invalid URL format"}; + } + + if (parsed.protocol !== "https:") { + return {valid: false, reason: "URL must use https:// scheme"}; + } + + if (parsed.username || parsed.password) { + return {valid: false, reason: "Credentials in URL are not allowed"}; + } + + const expectedHost = getExpectedWebHost(apiUri); + if (parsed.hostname !== expectedHost) { + return {valid: false, reason: `Expected host "${expectedHost}", got "${parsed.hostname}"`}; + } + + const match = JOB_PATH_RE.exec(parsed.pathname); + if (!match) { + return { + valid: false, + reason: "URL must be a GitHub Actions job URL (…/{owner}/{repo}/actions/runs/{runId}/job/{jobId})" + }; + } + + return {valid: true, owner: match[1], repo: match[2], jobId: match[3]}; +} diff --git a/src/debugger/tunnelUrl.ts b/src/debugger/tunnelUrl.ts index d28fd262..58bcbf60 100644 --- a/src/debugger/tunnelUrl.ts +++ b/src/debugger/tunnelUrl.ts @@ -4,13 +4,6 @@ */ const ALLOWED_TUNNEL_HOST_PATTERN = /\.devtunnels\.ms$/; -/** - * Validates a Dev Tunnel websocket URL for the Actions job debugger. - * - * Requirements: - * - Must use wss:// (cleartext ws:// is rejected to protect the auth token) - * - Host must match an allowed tunnel domain (*.devtunnels.ms) - */ export function validateTunnelUrl(raw: string): {valid: true; url: string} | {valid: false; reason: string} { let parsed: URL; try { diff --git a/src/debugger/webSocketDapAdapter.ts b/src/debugger/webSocketDapAdapter.ts index 3cc75baa..025f1b2a 100644 --- a/src/debugger/webSocketDapAdapter.ts +++ b/src/debugger/webSocketDapAdapter.ts @@ -8,16 +8,12 @@ import {log, logDebug, logError} from "../log"; */ const PING_INTERVAL_MS = 25_000; -/** Maximum time to wait for the websocket handshake to complete. */ const CONNECT_TIMEOUT_MS = 30_000; /** - * A VS Code inline debug adapter that speaks DAP over a websocket connection - * to the Actions runner's Dev Tunnel endpoint. - * - * DAP JSON payloads are sent as individual text websocket messages — no - * Content-Length framing is used on the wire. This matches the runner's - * WebSocketDapBridge and the gh-actions-debugger CLI bridge. + * Inline debug adapter that speaks DAP over a websocket. DAP JSON payloads + * are sent as individual text messages — no Content-Length framing. This + * matches the runner's WebSocketDapBridge and the gh-actions-debugger CLI. */ export class WebSocketDapAdapter implements vscode.DebugAdapter { private readonly _onDidSendMessage = new vscode.EventEmitter(); @@ -39,14 +35,11 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { private _configurationDone = false; private _pendingStoppedEvents: vscode.DebugProtocolMessage[] = []; - constructor(private readonly _tunnelUrl: string, private readonly _token: string) {} + constructor( + private readonly _tunnelUrl: string, + private readonly _token: string + ) {} - /** - * Opens the websocket connection to the tunnel. Must be called before the - * debug session can exchange messages. - * - * @throws if the connection fails or times out. - */ async connect(): Promise { log(`Connecting to debugger tunnel: ${this._tunnelUrl}`); @@ -117,10 +110,6 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { }); } - /** - * Called by VS Code to send a DAP message (request or response) to the - * remote debug adapter. - */ handleMessage(message: vscode.DebugProtocolMessage): void { if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { logError(new Error("Cannot send — websocket not open"), "Debugger tunnel send failed"); @@ -259,7 +248,6 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { } } - /** Notify VS Code that the debug session is over. */ private _fireTerminated(): void { if (this._terminatedFired) return; this._terminatedFired = true; @@ -271,7 +259,6 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { } } -/** Build a short human-readable label for a DAP message for trace logging. */ function describeDapMessage(msg: vscode.DebugProtocolMessage): string { const m = msg as Record; const type = (m.type as string) ?? "unknown"; From c7d9be3a856434a970a5ec77c1adfc0091ed50f2 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Fri, 10 Apr 2026 06:11:24 -0700 Subject: [PATCH 4/4] format --- src/debugger/jobUrl.test.ts | 35 ++++++----------------------- src/debugger/webSocketDapAdapter.ts | 5 +---- 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/src/debugger/jobUrl.test.ts b/src/debugger/jobUrl.test.ts index 02125940..aba49ba4 100644 --- a/src/debugger/jobUrl.test.ts +++ b/src/debugger/jobUrl.test.ts @@ -30,26 +30,17 @@ describe("parseJobUrl", () => { }); it("accepts a valid URL with trailing slash", () => { - const result = parseJobUrl( - "https://github.com/owner/repo/actions/runs/111/job/222/", - GITHUB_API_URI - ); + const result = parseJobUrl("https://github.com/owner/repo/actions/runs/111/job/222/", GITHUB_API_URI); expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"}); }); it("ignores query string and hash", () => { - const result = parseJobUrl( - "https://github.com/owner/repo/actions/runs/111/job/222?pr=1#step:2:3", - GITHUB_API_URI - ); + const result = parseJobUrl("https://github.com/owner/repo/actions/runs/111/job/222?pr=1#step:2:3", GITHUB_API_URI); expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"}); }); it("rejects wrong host", () => { - const result = parseJobUrl( - "https://gitlab.com/owner/repo/actions/runs/111/job/222", - GITHUB_API_URI - ); + const result = parseJobUrl("https://gitlab.com/owner/repo/actions/runs/111/job/222", GITHUB_API_URI); expect(result.valid).toBe(false); if (!result.valid) { expect(result.reason).toContain("gitlab.com"); @@ -57,10 +48,7 @@ describe("parseJobUrl", () => { }); it("rejects http:// scheme", () => { - const result = parseJobUrl( - "http://github.com/owner/repo/actions/runs/111/job/222", - GITHUB_API_URI - ); + const result = parseJobUrl("http://github.com/owner/repo/actions/runs/111/job/222", GITHUB_API_URI); expect(result.valid).toBe(false); if (!result.valid) { expect(result.reason).toContain("https://"); @@ -97,10 +85,7 @@ describe("parseJobUrl", () => { }); it("rejects URL with credentials", () => { - const result = parseJobUrl( - "https://user:pass@github.com/owner/repo/actions/runs/111/job/222", - GITHUB_API_URI - ); + const result = parseJobUrl("https://user:pass@github.com/owner/repo/actions/runs/111/job/222", GITHUB_API_URI); expect(result.valid).toBe(false); if (!result.valid) { expect(result.reason).toContain("Credentials"); @@ -108,18 +93,12 @@ describe("parseJobUrl", () => { }); it("accepts non-numeric job ID", () => { - const result = parseJobUrl( - "https://github.com/owner/repo/actions/runs/111/job/abc-123", - GITHUB_API_URI - ); + const result = parseJobUrl("https://github.com/owner/repo/actions/runs/111/job/abc-123", GITHUB_API_URI); expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "abc-123"}); }); it("accepts plural /jobs/ path variant", () => { - const result = parseJobUrl( - "https://github.com/owner/repo/actions/runs/111/jobs/222", - GITHUB_API_URI - ); + const result = parseJobUrl("https://github.com/owner/repo/actions/runs/111/jobs/222", GITHUB_API_URI); expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"}); }); diff --git a/src/debugger/webSocketDapAdapter.ts b/src/debugger/webSocketDapAdapter.ts index 025f1b2a..41aa8f4f 100644 --- a/src/debugger/webSocketDapAdapter.ts +++ b/src/debugger/webSocketDapAdapter.ts @@ -35,10 +35,7 @@ export class WebSocketDapAdapter implements vscode.DebugAdapter { private _configurationDone = false; private _pendingStoppedEvents: vscode.DebugProtocolMessage[] = []; - constructor( - private readonly _tunnelUrl: string, - private readonly _token: string - ) {} + constructor(private readonly _tunnelUrl: string, private readonly _token: string) {} async connect(): Promise { log(`Connecting to debugger tunnel: ${this._tunnelUrl}`);