Skip to content

Commit d090d03

Browse files
committed
Reapply "feat: send additional_permissions in token exchange request (#859)" (#864)
This reverts commit 231bd75.
1 parent fe72061 commit d090d03

File tree

3 files changed

+159
-10
lines changed

3 files changed

+159
-10
lines changed

docs/configuration.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,14 @@ jobs:
172172

173173
**Important Notes**:
174174

175-
- The GitHub token must have the `actions: read` permission in your workflow
175+
- The GitHub token must have the corresponding permission in your workflow
176176
- If the permission is missing, Claude will warn you and suggest adding it
177-
- Currently, only `actions: read` is supported, but the format allows for future extensions
177+
- The following additional permissions can be requested beyond the defaults:
178+
- `actions: read`
179+
- `checks: read`
180+
- `discussions: read` or `discussions: write`
181+
- `workflows: read` or `workflows: write`
182+
- Standard permissions (`contents: write`, `pull_requests: write`, `issues: write`) are always included and do not need to be specified
178183

179184
## Custom Environment Variables
180185

src/github/token.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,60 @@ async function getOidcToken(): Promise<string> {
1616
}
1717
}
1818

19-
async function exchangeForAppToken(oidcToken: string): Promise<string> {
19+
const DEFAULT_PERMISSIONS: Record<string, string> = {
20+
contents: "write",
21+
pull_requests: "write",
22+
issues: "write",
23+
};
24+
25+
export function parseAdditionalPermissions():
26+
| Record<string, string>
27+
| undefined {
28+
const raw = process.env.ADDITIONAL_PERMISSIONS;
29+
if (!raw || !raw.trim()) {
30+
return undefined;
31+
}
32+
33+
const additional: Record<string, string> = {};
34+
for (const line of raw.split("\n")) {
35+
const trimmed = line.trim();
36+
if (!trimmed) continue;
37+
const colonIndex = trimmed.indexOf(":");
38+
if (colonIndex === -1) continue;
39+
const key = trimmed.slice(0, colonIndex).trim();
40+
const value = trimmed.slice(colonIndex + 1).trim();
41+
if (key && value) {
42+
additional[key] = value;
43+
}
44+
}
45+
46+
if (Object.keys(additional).length === 0) {
47+
return undefined;
48+
}
49+
50+
return { ...DEFAULT_PERMISSIONS, ...additional };
51+
}
52+
53+
async function exchangeForAppToken(
54+
oidcToken: string,
55+
permissions?: Record<string, string>,
56+
): Promise<string> {
57+
const headers: Record<string, string> = {
58+
Authorization: `Bearer ${oidcToken}`,
59+
};
60+
const fetchOptions: RequestInit = {
61+
method: "POST",
62+
headers,
63+
};
64+
65+
if (permissions) {
66+
headers["Content-Type"] = "application/json";
67+
fetchOptions.body = JSON.stringify({ permissions });
68+
}
69+
2070
const response = await fetch(
2171
"https://api.anthropic.com/api/github/github-app-token-exchange",
22-
{
23-
method: "POST",
24-
headers: {
25-
Authorization: `Bearer ${oidcToken}`,
26-
},
27-
},
72+
fetchOptions,
2873
);
2974

3075
if (!response.ok) {
@@ -89,9 +134,11 @@ export async function setupGitHubToken(): Promise<string> {
89134
const oidcToken = await retryWithBackoff(() => getOidcToken());
90135
console.log("OIDC token successfully obtained");
91136

137+
const permissions = parseAdditionalPermissions();
138+
92139
console.log("Exchanging OIDC token for app token...");
93140
const appToken = await retryWithBackoff(() =>
94-
exchangeForAppToken(oidcToken),
141+
exchangeForAppToken(oidcToken, permissions),
95142
);
96143
console.log("App token successfully obtained");
97144

test/parse-permissions.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2+
import { parseAdditionalPermissions } from "../src/github/token";
3+
4+
describe("parseAdditionalPermissions", () => {
5+
let originalEnv: string | undefined;
6+
7+
beforeEach(() => {
8+
originalEnv = process.env.ADDITIONAL_PERMISSIONS;
9+
});
10+
11+
afterEach(() => {
12+
if (originalEnv === undefined) {
13+
delete process.env.ADDITIONAL_PERMISSIONS;
14+
} else {
15+
process.env.ADDITIONAL_PERMISSIONS = originalEnv;
16+
}
17+
});
18+
19+
test("returns undefined when env var is not set", () => {
20+
delete process.env.ADDITIONAL_PERMISSIONS;
21+
expect(parseAdditionalPermissions()).toBeUndefined();
22+
});
23+
24+
test("returns undefined when env var is empty string", () => {
25+
process.env.ADDITIONAL_PERMISSIONS = "";
26+
expect(parseAdditionalPermissions()).toBeUndefined();
27+
});
28+
29+
test("returns undefined when env var is only whitespace", () => {
30+
process.env.ADDITIONAL_PERMISSIONS = " \n \n ";
31+
expect(parseAdditionalPermissions()).toBeUndefined();
32+
});
33+
34+
test("parses single permission and merges with defaults", () => {
35+
process.env.ADDITIONAL_PERMISSIONS = "actions: read";
36+
expect(parseAdditionalPermissions()).toEqual({
37+
contents: "write",
38+
pull_requests: "write",
39+
issues: "write",
40+
actions: "read",
41+
});
42+
});
43+
44+
test("parses multiple permissions", () => {
45+
process.env.ADDITIONAL_PERMISSIONS = "actions: read\nworkflows: write";
46+
expect(parseAdditionalPermissions()).toEqual({
47+
contents: "write",
48+
pull_requests: "write",
49+
issues: "write",
50+
actions: "read",
51+
workflows: "write",
52+
});
53+
});
54+
55+
test("additional permissions can override defaults", () => {
56+
process.env.ADDITIONAL_PERMISSIONS = "contents: read";
57+
expect(parseAdditionalPermissions()).toEqual({
58+
contents: "read",
59+
pull_requests: "write",
60+
issues: "write",
61+
});
62+
});
63+
64+
test("handles extra whitespace around keys and values", () => {
65+
process.env.ADDITIONAL_PERMISSIONS = " actions : read ";
66+
expect(parseAdditionalPermissions()).toEqual({
67+
contents: "write",
68+
pull_requests: "write",
69+
issues: "write",
70+
actions: "read",
71+
});
72+
});
73+
74+
test("skips empty lines", () => {
75+
process.env.ADDITIONAL_PERMISSIONS =
76+
"actions: read\n\n\nworkflows: write\n\n";
77+
expect(parseAdditionalPermissions()).toEqual({
78+
contents: "write",
79+
pull_requests: "write",
80+
issues: "write",
81+
actions: "read",
82+
workflows: "write",
83+
});
84+
});
85+
86+
test("skips lines without colons", () => {
87+
process.env.ADDITIONAL_PERMISSIONS =
88+
"actions: read\ninvalid line\nworkflows: write";
89+
expect(parseAdditionalPermissions()).toEqual({
90+
contents: "write",
91+
pull_requests: "write",
92+
issues: "write",
93+
actions: "read",
94+
workflows: "write",
95+
});
96+
});
97+
});

0 commit comments

Comments
 (0)