From 2bf20c69b3f5e036d7a46af4d8556aa1d84ddcc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:32:19 +0000 Subject: [PATCH 01/13] Add debugger build workflow and README install instructions Agent-Logs-Url: https://github.com/github/vscode-github-actions/sessions/5c79f4b5-d19e-4768-8821-d2721886fc0e Co-authored-by: rentziass <6207785+rentziass@users.noreply.github.com> --- .github/workflows/debugger-build.yml | 41 ++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 43 insertions(+) create mode 100644 .github/workflows/debugger-build.yml diff --git a/.github/workflows/debugger-build.yml b/.github/workflows/debugger-build.yml new file mode 100644 index 00000000..1369f661 --- /dev/null +++ b/.github/workflows/debugger-build.yml @@ -0,0 +1,41 @@ +name: Build Debugger Extension + +on: + push: + branches: + - debugger + +permissions: + contents: read + packages: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 16.x + cache: "npm" + registry-url: "https://npm.pkg.github.com" + + - run: npm --no-git-tag-version version 0.0.${{ github.run_number }} + + - run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create a package.json without scoped name + run: | + cp package.json package.json.real + sed --regexp-extended '/"name"\s*:/ s#@[a-zA-Z\\-]+/##' package.json.real > package.json + + - run: npm run package + + - uses: actions/upload-artifact@v4 + with: + name: vscode-github-actions-debugger + path: ./vscode-github-actions-0.0.${{ github.run_number }}.vsix diff --git a/README.md b/README.md index 8b257498..4a89a585 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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…**. + The GitHub Actions extension lets you manage your workflows, view the workflow run history, and helps with authoring workflows. ![](./media/header.png) From 7f58e2c2fbc7cdc8bffd1c00b82fd739c7ea347c Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 9 Apr 2026 08:23:07 -0700 Subject: [PATCH 02/13] 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 03/13] 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 04/13] 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 05/13] 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}`); From f65bb0512a6a4431ecccfbf01786f3611dba5ed7 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 10 Jun 2026 09:16:19 +0100 Subject: [PATCH 06/13] Gate debugger behind manual opt-in Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 5 +++- src/configuration/configuration.ts | 37 +++++++++++++++++++++++++++- src/debugger/debugger.ts | 39 +++++++++++++++++++++++++++++- src/extension.ts | 13 +++++++--- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 93a81cb7..c6d9cf88 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "onView:workflows", "onView:settings", "onDebugResolve:github-actions-job", - "onCommand:github-actions.debugger.connect", "workspaceContains:**/.github/workflows/**", "workspaceContains:**/action.yml", "workspaceContains:**/action.yaml" @@ -535,6 +534,10 @@ { "command": "github-actions.sign-in", "when": "false" + }, + { + "command": "github-actions.debugger.connect", + "when": "github-actions.debugger.enabled" } ] } diff --git a/src/configuration/configuration.ts b/src/configuration/configuration.ts index d12711ac..c8437d63 100644 --- a/src/configuration/configuration.ts +++ b/src/configuration/configuration.ts @@ -4,13 +4,19 @@ import {resetGitHubContext} from "../git/repository"; const settingsKey = "github-actions"; const DEFAULT_GITHUB_API = "https://api.github.com"; +const reloadWindowAction = "Reload Window"; +const debuggerEnabledSettingsKey = getSettingsKey("debugger.enabled"); + +let debuggerSettingReloadPromptVisible = false; export function initConfiguration(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(async e => { if (e.affectsConfiguration(getSettingsKey("workflows.pinned"))) { pinnedWorkflowsChangeHandlers.forEach(h => h()); - } else if ( + } + + if ( e.affectsConfiguration(getSettingsKey("use-enterprise")) || (useEnterprise() && (e.affectsConfiguration("github-enterprise.uri") || e.affectsConfiguration(getSettingsKey("remote-name")))) @@ -19,6 +25,10 @@ export function initConfiguration(context: vscode.ExtensionContext) { resetGitHubContext(); await vscode.commands.executeCommand("github-actions.explorer.refresh"); } + + if (e.affectsConfiguration(debuggerEnabledSettingsKey)) { + await promptToReloadForDebuggerSettingChange(); + } }) ); } @@ -64,6 +74,10 @@ export function getRemoteName(): string { return getConfiguration().get(getSettingsKey("remote-name"), "origin"); } +export function isDebuggerEnabled(): boolean { + return getConfiguration().get(debuggerEnabledSettingsKey, false); +} + export function useEnterprise(): boolean { return getConfiguration().get(getSettingsKey("use-enterprise"), false); } @@ -87,3 +101,24 @@ async function updateLanguageServerApiUrl(context: vscode.ExtensionContext) { await initLanguageServer(context); } + +async function promptToReloadForDebuggerSettingChange() { + if (debuggerSettingReloadPromptVisible) { + return; + } + + debuggerSettingReloadPromptVisible = true; + + try { + const selection = await vscode.window.showInformationMessage( + "Reload VS Code to apply the GitHub Actions debugger preview setting change.", + reloadWindowAction + ); + + if (selection === reloadWindowAction) { + await vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + } finally { + debuggerSettingReloadPromptVisible = false; + } +} diff --git a/src/debugger/debugger.ts b/src/debugger/debugger.ts index cf7c15ec..594f590f 100644 --- a/src/debugger/debugger.ts +++ b/src/debugger/debugger.ts @@ -2,13 +2,16 @@ 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 {getGitHubApiUri, isDebuggerEnabled} from "../configuration/configuration"; import {log, logDebug, logError} from "../log"; import {parseJobUrl} from "./jobUrl"; import {validateTunnelUrl} from "./tunnelUrl"; import {WebSocketDapAdapter} from "./webSocketDapAdapter"; export const DEBUG_TYPE = "github-actions-job"; +const debuggerEnabledSettingSnippet = `"github-actions.debugger.enabled": true`; + +let debuggerRegistered = false; /** * Extension-private token store keyed by one-time nonce. Tokens are never @@ -16,7 +19,13 @@ export const DEBUG_TYPE = "github-actions-job"; */ const pendingTokens = new Map(); +export function registerDebuggerAvailabilityGuard(context: vscode.ExtensionContext): void { + context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider(DEBUG_TYPE, new ActionsDebugConfigurationProvider())); +} + export function registerDebugger(context: vscode.ExtensionContext): void { + debuggerRegistered = true; + context.subscriptions.push( vscode.debug.registerDebugAdapterDescriptorFactory(DEBUG_TYPE, new ActionsDebugAdapterFactory()) ); @@ -30,6 +39,34 @@ export function registerDebugger(context: vscode.ExtensionContext): void { ); } +class ActionsDebugConfigurationProvider implements vscode.DebugConfigurationProvider { + resolveDebugConfiguration( + _folder: vscode.WorkspaceFolder | undefined, + debugConfiguration: vscode.DebugConfiguration + ): vscode.DebugConfiguration | null { + if (vscode.env.uiKind !== vscode.UIKind.Desktop) { + void vscode.window.showInformationMessage("GitHub Actions job debugging is only available in desktop VS Code."); + return null; + } + + if (!isDebuggerEnabled()) { + void vscode.window.showInformationMessage( + `GitHub Actions job debugging is currently disabled. Add ${debuggerEnabledSettingSnippet} to settings.json and reload VS Code to enable it.` + ); + return null; + } + + if (!debuggerRegistered) { + void vscode.window.showInformationMessage( + "GitHub Actions job debugging was enabled, but VS Code must be reloaded before the debugger can be used." + ); + return null; + } + + return debugConfiguration; + } +} + async function connectToDebugger(): Promise { const rawUrl = await vscode.window.showInputBox({ title: "Connect to Actions Job Debugger", diff --git a/src/extension.ts b/src/extension.ts index 8ec7ea95..3179f401 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,7 +19,7 @@ import {registerAddVariable} from "./commands/variables/addVariable"; import {registerCopyVariable} from "./commands/variables/copyVariable"; import {registerDeleteVariable} from "./commands/variables/deleteVariable"; import {registerUpdateVariable} from "./commands/variables/updateVariable"; -import {initConfiguration} from "./configuration/configuration"; +import {initConfiguration, isDebuggerEnabled} from "./configuration/configuration"; import {getGitHubContext} from "./git/repository"; import {init as initLogger, log, revealLog} from "./log"; import {LogScheme} from "./logs/constants"; @@ -34,7 +34,9 @@ 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"; +import {registerDebugger, registerDebuggerAvailabilityGuard} from "./debugger/debugger"; + +const debuggerEnabledContextKey = "github-actions.debugger.enabled"; export async function activate(context: vscode.ExtensionContext) { initLogger(); @@ -47,11 +49,13 @@ export async function activate(context: vscode.ExtensionContext) { // Prefetch git repository origin url const ghContext = hasSession && (await getGitHubContext()); const hasGitHubRepos = ghContext && ghContext.repos.length > 0; + const debuggerEnabled = vscode.env.uiKind === vscode.UIKind.Desktop && isDebuggerEnabled(); await Promise.all([ vscode.commands.executeCommand("setContext", "github-actions.signed-in", hasSession), vscode.commands.executeCommand("setContext", "github-actions.internet-access", canReachAPI), - vscode.commands.executeCommand("setContext", "github-actions.has-repos", hasGitHubRepos) + vscode.commands.executeCommand("setContext", "github-actions.has-repos", hasGitHubRepos), + vscode.commands.executeCommand("setContext", debuggerEnabledContextKey, debuggerEnabled) ]); initResources(context); @@ -92,9 +96,10 @@ export async function activate(context: vscode.ExtensionContext) { registerUnPinWorkflow(context); registerSignIn(context); + registerDebuggerAvailabilityGuard(context); // Debugger — only available in Desktop VS Code (requires Node.js for WebSocket) - if (vscode.env.uiKind === vscode.UIKind.Desktop) { + if (debuggerEnabled) { registerDebugger(context); } From 932855a30d941b10fc3c1554730bc78e53574168 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 10 Jun 2026 09:16:19 +0100 Subject: [PATCH 07/13] Document debugger opt-in flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 47cd40e3..4ede2ea5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ # 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…**. +> **🐛 Actions Job Debugger (Preview):** The debugger preview is intentionally off by default. 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. +> Next, manually enable the preview in `settings.json`: +> +> ```json +> { +> "github-actions.debugger.enabled": true +> } +> ``` +> +> This opt-in is currently settings.json-only, so VS Code may show it as an unknown setting. After saving the change, reload VS Code. Once the window reloads, open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run **GitHub Actions: Debug Running Job…**. Paste the Actions job 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. From 34c4b504f7626af1910df0cb6ca1f8be77c4989a Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 10 Jun 2026 09:51:59 +0100 Subject: [PATCH 08/13] Restore debugger command discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 3 ++- src/extension.ts | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c6d9cf88..a946875f 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "onView:workflows", "onView:settings", "onDebugResolve:github-actions-job", + "onCommand:github-actions.debugger.connect", "workspaceContains:**/.github/workflows/**", "workspaceContains:**/action.yml", "workspaceContains:**/action.yaml" @@ -537,7 +538,7 @@ }, { "command": "github-actions.debugger.connect", - "when": "github-actions.debugger.enabled" + "when": "config.github-actions.debugger.enabled && !isWeb" } ] } diff --git a/src/extension.ts b/src/extension.ts index 3179f401..be9a157f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -36,8 +36,6 @@ import {deactivateLanguageServer, initLanguageServer} from "./workflow/languageS import {registerSignIn} from "./commands/signIn"; import {registerDebugger, registerDebuggerAvailabilityGuard} from "./debugger/debugger"; -const debuggerEnabledContextKey = "github-actions.debugger.enabled"; - export async function activate(context: vscode.ExtensionContext) { initLogger(); @@ -54,8 +52,7 @@ export async function activate(context: vscode.ExtensionContext) { await Promise.all([ vscode.commands.executeCommand("setContext", "github-actions.signed-in", hasSession), vscode.commands.executeCommand("setContext", "github-actions.internet-access", canReachAPI), - vscode.commands.executeCommand("setContext", "github-actions.has-repos", hasGitHubRepos), - vscode.commands.executeCommand("setContext", debuggerEnabledContextKey, debuggerEnabled) + vscode.commands.executeCommand("setContext", "github-actions.has-repos", hasGitHubRepos) ]); initResources(context); From 1e2fa651c4d33b9d4bfe7535959c9f8470ff68a4 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 10 Jun 2026 09:51:59 +0100 Subject: [PATCH 09/13] Skip web reload prompt for debugger Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/configuration/configuration.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/configuration/configuration.ts b/src/configuration/configuration.ts index c8437d63..9be6cc7d 100644 --- a/src/configuration/configuration.ts +++ b/src/configuration/configuration.ts @@ -103,6 +103,10 @@ async function updateLanguageServerApiUrl(context: vscode.ExtensionContext) { } async function promptToReloadForDebuggerSettingChange() { + if (vscode.env.uiKind !== vscode.UIKind.Desktop) { + return; + } + if (debuggerSettingReloadPromptVisible) { return; } From b643483c3df19cb3af4b431b08fbeb19c72ef627 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 10 Jun 2026 10:29:18 +0100 Subject: [PATCH 10/13] Avoid reload action in development host Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/configuration/configuration.ts | 11 +++++++++-- src/debugger/debugger.ts | 6 ++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/configuration/configuration.ts b/src/configuration/configuration.ts index 9be6cc7d..567a1981 100644 --- a/src/configuration/configuration.ts +++ b/src/configuration/configuration.ts @@ -27,7 +27,7 @@ export function initConfiguration(context: vscode.ExtensionContext) { } if (e.affectsConfiguration(debuggerEnabledSettingsKey)) { - await promptToReloadForDebuggerSettingChange(); + await promptToReloadForDebuggerSettingChange(context); } }) ); @@ -102,7 +102,7 @@ async function updateLanguageServerApiUrl(context: vscode.ExtensionContext) { await initLanguageServer(context); } -async function promptToReloadForDebuggerSettingChange() { +async function promptToReloadForDebuggerSettingChange(context: vscode.ExtensionContext) { if (vscode.env.uiKind !== vscode.UIKind.Desktop) { return; } @@ -114,6 +114,13 @@ async function promptToReloadForDebuggerSettingChange() { debuggerSettingReloadPromptVisible = true; try { + if (context.extensionMode !== vscode.ExtensionMode.Production) { + await vscode.window.showInformationMessage( + "Reload VS Code manually to apply the GitHub Actions debugger preview setting change. Automatic reload is disabled in the Extension Development Host." + ); + return; + } + const selection = await vscode.window.showInformationMessage( "Reload VS Code to apply the GitHub Actions debugger preview setting change.", reloadWindowAction diff --git a/src/debugger/debugger.ts b/src/debugger/debugger.ts index 594f590f..b09abbb5 100644 --- a/src/debugger/debugger.ts +++ b/src/debugger/debugger.ts @@ -10,6 +10,8 @@ import {WebSocketDapAdapter} from "./webSocketDapAdapter"; export const DEBUG_TYPE = "github-actions-job"; const debuggerEnabledSettingSnippet = `"github-actions.debugger.enabled": true`; +const emptyWindowManualReloadMessage = + "If you enable it in an empty window, reload VS Code manually because the extension cannot prompt until it activates."; let debuggerRegistered = false; @@ -51,14 +53,14 @@ class ActionsDebugConfigurationProvider implements vscode.DebugConfigurationProv if (!isDebuggerEnabled()) { void vscode.window.showInformationMessage( - `GitHub Actions job debugging is currently disabled. Add ${debuggerEnabledSettingSnippet} to settings.json and reload VS Code to enable it.` + `GitHub Actions job debugging is currently disabled. Add ${debuggerEnabledSettingSnippet} to settings.json and reload VS Code to enable it. ${emptyWindowManualReloadMessage}` ); return null; } if (!debuggerRegistered) { void vscode.window.showInformationMessage( - "GitHub Actions job debugging was enabled, but VS Code must be reloaded before the debugger can be used." + `GitHub Actions job debugging was enabled, but VS Code must be reloaded before the debugger can be used. ${emptyWindowManualReloadMessage}` ); return null; } From fcb4fc84e8a7079a2ffa2b7bb232f49ca40f9fe1 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 10 Jun 2026 10:29:18 +0100 Subject: [PATCH 11/13] Clarify empty-window reload guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ede2ea5..2991ac20 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ > } > ``` > -> This opt-in is currently settings.json-only, so VS Code may show it as an unknown setting. After saving the change, reload VS Code. Once the window reloads, open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run **GitHub Actions: Debug Running Job…**. Paste the Actions job URL from a debug-mode job and the extension will open a full debug session using your current GitHub credentials. +> This opt-in is currently settings.json-only, so VS Code may show it as an unknown setting. After saving the change, reload VS Code. If you enable it in an empty or no-folder window, reload manually because the extension cannot prompt until it activates. Once the window reloads, open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run **GitHub Actions: Debug Running Job…**. Paste the Actions job 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. From 7257164c9d8b5460db7269e37098db843b4d1c5e Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 10 Jun 2026 10:36:15 +0100 Subject: [PATCH 12/13] Format debugger guard messaging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/debugger/debugger.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/debugger/debugger.ts b/src/debugger/debugger.ts index b09abbb5..8ab30343 100644 --- a/src/debugger/debugger.ts +++ b/src/debugger/debugger.ts @@ -22,7 +22,9 @@ let debuggerRegistered = false; const pendingTokens = new Map(); export function registerDebuggerAvailabilityGuard(context: vscode.ExtensionContext): void { - context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider(DEBUG_TYPE, new ActionsDebugConfigurationProvider())); + context.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider(DEBUG_TYPE, new ActionsDebugConfigurationProvider()) + ); } export function registerDebugger(context: vscode.ExtensionContext): void { From 0ef534dc6ba41f1c33b21045729461d68a27a7d3 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 10 Jun 2026 14:19:39 +0100 Subject: [PATCH 13/13] Remove debugger preview scaffolding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/debugger-build.yml | 41 ---------------------------- README.md | 12 -------- 2 files changed, 53 deletions(-) delete mode 100644 .github/workflows/debugger-build.yml diff --git a/.github/workflows/debugger-build.yml b/.github/workflows/debugger-build.yml deleted file mode 100644 index 1369f661..00000000 --- a/.github/workflows/debugger-build.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Build Debugger Extension - -on: - push: - branches: - - debugger - -permissions: - contents: read - packages: read - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 16.x - cache: "npm" - registry-url: "https://npm.pkg.github.com" - - - run: npm --no-git-tag-version version 0.0.${{ github.run_number }} - - - run: npm ci - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Create a package.json without scoped name - run: | - cp package.json package.json.real - sed --regexp-extended '/"name"\s*:/ s#@[a-zA-Z\\-]+/##' package.json.real > package.json - - - run: npm run package - - - uses: actions/upload-artifact@v4 - with: - name: vscode-github-actions-debugger - path: ./vscode-github-actions-0.0.${{ github.run_number }}.vsix diff --git a/README.md b/README.md index 2991ac20..8b257498 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,5 @@ # GitHub Actions for VS Code -> **🐛 Actions Job Debugger (Preview):** The debugger preview is intentionally off by default. 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…**. -> -> Next, manually enable the preview in `settings.json`: -> -> ```json -> { -> "github-actions.debugger.enabled": true -> } -> ``` -> -> This opt-in is currently settings.json-only, so VS Code may show it as an unknown setting. After saving the change, reload VS Code. If you enable it in an empty or no-folder window, reload manually because the extension cannot prompt until it activates. Once the window reloads, open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run **GitHub Actions: Debug Running Job…**. Paste the Actions job 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. ![](./media/header.png)