Skip to content

Commit c2cb35f

Browse files
authored
connect using job url and cleanup
1 parent e048a65 commit c2cb35f

File tree

6 files changed

+253
-59
lines changed

6 files changed

+253
-59
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
{
111111
"command": "github-actions.debugger.connect",
112112
"category": "GitHub Actions",
113-
"title": "Connect to Actions Job Debugger..."
113+
"title": "Debug Running Job"
114114
},
115115
{
116116
"command": "github-actions.explorer.refresh",

src/debugger/debugger.ts

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,61 @@
11
import * as crypto from "crypto";
22
import * as vscode from "vscode";
3+
import {getClient} from "../api/api";
34
import {getSession, newSession} from "../auth/auth";
5+
import {getGitHubApiUri} from "../configuration/configuration";
46
import {log, logDebug, logError} from "../log";
7+
import {parseJobUrl} from "./jobUrl";
58
import {validateTunnelUrl} from "./tunnelUrl";
69
import {WebSocketDapAdapter} from "./webSocketDapAdapter";
710

8-
/** The custom debug type registered in package.json contributes.debuggers. */
911
export const DEBUG_TYPE = "github-actions-job";
1012

1113
/**
12-
* Extension-private store for auth tokens, keyed by a one-time session
13-
* nonce. Tokens are never placed in DebugConfiguration (which is readable
14-
* by other extensions via vscode.debug.activeDebugSession.configuration).
14+
* Extension-private token store keyed by one-time nonce. Tokens are never
15+
* placed in DebugConfiguration (readable by other extensions).
1516
*/
1617
const pendingTokens = new Map<string, string>();
1718

18-
/**
19-
* Registers the Actions Job Debugger command and debug adapter factory.
20-
*
21-
* Contributes:
22-
* - A command-palette command that prompts for a tunnel URL and starts a debug session.
23-
* - A DebugAdapterDescriptorFactory that returns an inline DAP-over-WS adapter.
24-
*/
2519
export function registerDebugger(context: vscode.ExtensionContext): void {
26-
// Register the inline adapter factory for our debug type.
2720
context.subscriptions.push(
2821
vscode.debug.registerDebugAdapterDescriptorFactory(DEBUG_TYPE, new ActionsDebugAdapterFactory())
2922
);
3023

31-
// Register a tracker to log all DAP traffic for diagnostics.
3224
context.subscriptions.push(
3325
vscode.debug.registerDebugAdapterTrackerFactory(DEBUG_TYPE, new ActionsDebugTrackerFactory())
3426
);
3527

36-
// Register the connect command.
3728
context.subscriptions.push(
3829
vscode.commands.registerCommand("github-actions.debugger.connect", () => connectToDebugger())
3930
);
4031
}
4132

4233
async function connectToDebugger(): Promise<void> {
43-
// 1. Prompt for the tunnel URL.
4434
const rawUrl = await vscode.window.showInputBox({
4535
title: "Connect to Actions Job Debugger",
46-
prompt: "Enter the debugger tunnel URL (wss://…)",
47-
placeHolder: "wss://xxxx-4711.region.devtunnels.ms/",
36+
prompt: "Paste the URL of the Actions job to debug",
37+
placeHolder: "https://github.com/owner/repo/actions/runs/123/job/456",
4838
ignoreFocusOut: true,
4939
validateInput: input => {
5040
if (!input) {
51-
return "A tunnel URL is required";
41+
return "A job URL is required";
5242
}
53-
const result = validateTunnelUrl(input);
43+
const result = parseJobUrl(input, getGitHubApiUri());
5444
return result.valid ? null : result.reason;
5545
}
5646
});
5747

5848
if (!rawUrl) {
59-
return; // user cancelled
49+
return;
6050
}
6151

62-
const validation = validateTunnelUrl(rawUrl);
63-
if (!validation.valid) {
64-
void vscode.window.showErrorMessage(`Invalid tunnel URL: ${validation.reason}`);
52+
const parsed = parseJobUrl(rawUrl, getGitHubApiUri());
53+
if (!parsed.valid) {
54+
void vscode.window.showErrorMessage(`Invalid job URL: ${parsed.reason}`);
6555
return;
6656
}
6757

68-
// 2. Acquire a GitHub auth session. The token is used as a Bearer token
69-
// against the Dev Tunnel relay, which accepts VS Code's GitHub app tokens.
70-
// Try silently first; fall back to prompting for sign-in if needed.
58+
// Try silently first; fall back to prompting for sign-in if needed.
7159
let session = await getSession();
7260
if (!session) {
7361
try {
@@ -80,10 +68,48 @@ async function connectToDebugger(): Promise<void> {
8068
}
8169
}
8270

83-
// 3. Launch the debug session. The token is stored in extension-private
84-
// memory (not in the configuration) to avoid exposing it to other extensions.
71+
const token = session.accessToken;
72+
let debuggerUrl: string;
73+
try {
74+
debuggerUrl = await vscode.window.withProgress(
75+
{location: vscode.ProgressLocation.Notification, title: "Connecting to Actions job debugger…"},
76+
async () => {
77+
const octokit = getClient(token);
78+
const response = await octokit.request("GET /repos/{owner}/{repo}/actions/jobs/{job_id}/debugger", {
79+
owner: parsed.owner,
80+
repo: parsed.repo,
81+
job_id: parsed.jobId
82+
});
83+
return (response.data as {debugger_url: string}).debugger_url;
84+
}
85+
);
86+
} catch (e) {
87+
const status = (e as {status?: number}).status;
88+
if (status === 404) {
89+
void vscode.window.showErrorMessage(
90+
"Debugger is not available for this job. Make sure the job is running with debugging enabled."
91+
);
92+
} else if (status === 403) {
93+
void vscode.window.showErrorMessage(
94+
"Permission denied. You may need to re-authenticate or check your access to this repository."
95+
);
96+
} else {
97+
const msg = (e as Error).message || "Unknown error";
98+
void vscode.window.showErrorMessage(`Failed to fetch debugger URL: ${msg}`);
99+
}
100+
return;
101+
}
102+
103+
const validation = validateTunnelUrl(debuggerUrl);
104+
if (!validation.valid) {
105+
void vscode.window.showErrorMessage(`Invalid debugger URL returned by API: ${validation.reason}`);
106+
return;
107+
}
108+
109+
// Store token in extension-private memory (not in the config) to avoid
110+
// exposing it to other extensions.
85111
const nonce = crypto.randomBytes(16).toString("hex");
86-
pendingTokens.set(nonce, session.accessToken);
112+
pendingTokens.set(nonce, token);
87113

88114
const config: vscode.DebugConfiguration = {
89115
type: DEBUG_TYPE,
@@ -114,7 +140,7 @@ class ActionsDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory
114140
const nonce = session.configuration.__tokenNonce as string | undefined;
115141
const token = nonce ? pendingTokens.get(nonce) : undefined;
116142

117-
// Consume the token immediately so it cannot be replayed.
143+
// Consume immediately so it cannot be replayed.
118144
if (nonce) {
119145
pendingTokens.delete(nonce);
120146
}
@@ -125,7 +151,6 @@ class ActionsDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory
125151
);
126152
}
127153

128-
// Re-validate the tunnel URL as defense-in-depth
129154
const revalidation = validateTunnelUrl(tunnelUrl);
130155
if (!revalidation.valid) {
131156
throw new Error(`Invalid debugger tunnel URL: ${revalidation.reason}`);

src/debugger/jobUrl.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {parseJobUrl, getExpectedWebHost} from "./jobUrl";
2+
3+
const GITHUB_API_URI = "https://api.github.com";
4+
5+
describe("getExpectedWebHost", () => {
6+
it("maps api.github.com to github.com", () => {
7+
expect(getExpectedWebHost("https://api.github.com")).toBe("github.com");
8+
});
9+
10+
it("maps GHE Server api/v3 URL to the server host", () => {
11+
expect(getExpectedWebHost("https://github.mycompany.com/api/v3")).toBe("github.mycompany.com");
12+
});
13+
14+
it("maps GHE Cloud api.<org>.ghe.com to <org>.ghe.com", () => {
15+
expect(getExpectedWebHost("https://api.myorg.ghe.com")).toBe("myorg.ghe.com");
16+
});
17+
18+
it("handles trailing slash on /api/v3/", () => {
19+
expect(getExpectedWebHost("https://github.mycompany.com/api/v3/")).toBe("github.mycompany.com");
20+
});
21+
});
22+
23+
describe("parseJobUrl", () => {
24+
it("accepts a valid github.com job URL", () => {
25+
const result = parseJobUrl(
26+
"https://github.com/galactic-potatoes/rentziass-test/actions/runs/24241071410/job/70775904678",
27+
GITHUB_API_URI
28+
);
29+
expect(result).toEqual({valid: true, owner: "galactic-potatoes", repo: "rentziass-test", jobId: "70775904678"});
30+
});
31+
32+
it("accepts a valid URL with trailing slash", () => {
33+
const result = parseJobUrl(
34+
"https://github.com/owner/repo/actions/runs/111/job/222/",
35+
GITHUB_API_URI
36+
);
37+
expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"});
38+
});
39+
40+
it("ignores query string and hash", () => {
41+
const result = parseJobUrl(
42+
"https://github.com/owner/repo/actions/runs/111/job/222?pr=1#step:2:3",
43+
GITHUB_API_URI
44+
);
45+
expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"});
46+
});
47+
48+
it("rejects wrong host", () => {
49+
const result = parseJobUrl(
50+
"https://gitlab.com/owner/repo/actions/runs/111/job/222",
51+
GITHUB_API_URI
52+
);
53+
expect(result.valid).toBe(false);
54+
if (!result.valid) {
55+
expect(result.reason).toContain("gitlab.com");
56+
}
57+
});
58+
59+
it("rejects http:// scheme", () => {
60+
const result = parseJobUrl(
61+
"http://github.com/owner/repo/actions/runs/111/job/222",
62+
GITHUB_API_URI
63+
);
64+
expect(result.valid).toBe(false);
65+
if (!result.valid) {
66+
expect(result.reason).toContain("https://");
67+
}
68+
});
69+
70+
it("rejects a repo URL without /job/ segment", () => {
71+
const result = parseJobUrl("https://github.com/owner/repo", GITHUB_API_URI);
72+
expect(result.valid).toBe(false);
73+
if (!result.valid) {
74+
expect(result.reason).toContain("job URL");
75+
}
76+
});
77+
78+
it("rejects a run URL without /job/ segment", () => {
79+
const result = parseJobUrl("https://github.com/owner/repo/actions/runs/111", GITHUB_API_URI);
80+
expect(result.valid).toBe(false);
81+
if (!result.valid) {
82+
expect(result.reason).toContain("job URL");
83+
}
84+
});
85+
86+
it("rejects empty string", () => {
87+
const result = parseJobUrl("", GITHUB_API_URI);
88+
expect(result.valid).toBe(false);
89+
});
90+
91+
it("rejects malformed URL", () => {
92+
const result = parseJobUrl("not a url at all", GITHUB_API_URI);
93+
expect(result.valid).toBe(false);
94+
if (!result.valid) {
95+
expect(result.reason).toContain("Invalid URL");
96+
}
97+
});
98+
99+
it("rejects URL with credentials", () => {
100+
const result = parseJobUrl(
101+
"https://user:pass@github.com/owner/repo/actions/runs/111/job/222",
102+
GITHUB_API_URI
103+
);
104+
expect(result.valid).toBe(false);
105+
if (!result.valid) {
106+
expect(result.reason).toContain("Credentials");
107+
}
108+
});
109+
110+
it("accepts non-numeric job ID", () => {
111+
const result = parseJobUrl(
112+
"https://github.com/owner/repo/actions/runs/111/job/abc-123",
113+
GITHUB_API_URI
114+
);
115+
expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "abc-123"});
116+
});
117+
118+
it("accepts plural /jobs/ path variant", () => {
119+
const result = parseJobUrl(
120+
"https://github.com/owner/repo/actions/runs/111/jobs/222",
121+
GITHUB_API_URI
122+
);
123+
expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"});
124+
});
125+
126+
it("validates against GHE Server host", () => {
127+
const result = parseJobUrl(
128+
"https://github.mycompany.com/owner/repo/actions/runs/111/job/222",
129+
"https://github.mycompany.com/api/v3"
130+
);
131+
expect(result).toEqual({valid: true, owner: "owner", repo: "repo", jobId: "222"});
132+
});
133+
});

src/debugger/jobUrl.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
type ParseResult = {valid: true; owner: string; repo: string; jobId: string} | {valid: false; reason: string};
2+
3+
/**
4+
* Derives the expected web host from the configured GitHub API URI.
5+
*
6+
* https://api.github.com → github.com
7+
* https://api.myorg.ghe.com → myorg.ghe.com (GHE Cloud)
8+
* https://myserver.com/api/v3 → myserver.com (GHE Server)
9+
*/
10+
export function getExpectedWebHost(apiUri: string): string {
11+
const url = new URL(apiUri);
12+
// GHE Server: host/api/v3
13+
if (url.pathname.replace(/\/$/, "") === "/api/v3") {
14+
return url.hostname;
15+
}
16+
// github.com or GHE Cloud (api.<org>.ghe.com): strip leading "api."
17+
if (url.hostname.startsWith("api.")) {
18+
return url.hostname.slice(4);
19+
}
20+
return url.hostname;
21+
}
22+
23+
const JOB_PATH_RE = /^\/([^/]+)\/([^/]+)\/actions\/runs\/[^/]+\/jobs?\/([^/]+)\/?$/;
24+
25+
// Expected format: https://github.com/{owner}/{repo}/actions/runs/{runId}/job/{jobId}
26+
export function parseJobUrl(raw: string, apiUri: string): ParseResult {
27+
let parsed: URL;
28+
try {
29+
parsed = new URL(raw);
30+
} catch {
31+
return {valid: false, reason: "Invalid URL format"};
32+
}
33+
34+
if (parsed.protocol !== "https:") {
35+
return {valid: false, reason: "URL must use https:// scheme"};
36+
}
37+
38+
if (parsed.username || parsed.password) {
39+
return {valid: false, reason: "Credentials in URL are not allowed"};
40+
}
41+
42+
const expectedHost = getExpectedWebHost(apiUri);
43+
if (parsed.hostname !== expectedHost) {
44+
return {valid: false, reason: `Expected host "${expectedHost}", got "${parsed.hostname}"`};
45+
}
46+
47+
const match = JOB_PATH_RE.exec(parsed.pathname);
48+
if (!match) {
49+
return {
50+
valid: false,
51+
reason: "URL must be a GitHub Actions job URL (…/{owner}/{repo}/actions/runs/{runId}/job/{jobId})"
52+
};
53+
}
54+
55+
return {valid: true, owner: match[1], repo: match[2], jobId: match[3]};
56+
}

src/debugger/tunnelUrl.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,6 @@
44
*/
55
const ALLOWED_TUNNEL_HOST_PATTERN = /\.devtunnels\.ms$/;
66

7-
/**
8-
* Validates a Dev Tunnel websocket URL for the Actions job debugger.
9-
*
10-
* Requirements:
11-
* - Must use wss:// (cleartext ws:// is rejected to protect the auth token)
12-
* - Host must match an allowed tunnel domain (*.devtunnels.ms)
13-
*/
147
export function validateTunnelUrl(raw: string): {valid: true; url: string} | {valid: false; reason: string} {
158
let parsed: URL;
169
try {

0 commit comments

Comments
 (0)