mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-26 00:34:13 +08:00
feat: send additional_permissions in token exchange request
Parse the ADDITIONAL_PERMISSIONS env var and send it as a JSON body in the OIDC token exchange request. Permissions are merged on top of the standard defaults (contents: write, pull_requests: write, issues: write).
This commit is contained in:
@@ -172,9 +172,11 @@ jobs:
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- The GitHub token must have the `actions: read` permission in your workflow
|
||||
- The GitHub token must have the corresponding permission in your workflow
|
||||
- If the permission is missing, Claude will warn you and suggest adding it
|
||||
- Currently, only `actions: read` is supported, but the format allows for future extensions
|
||||
- Any GitHub App permission can be requested (e.g. `actions: read`, `workflows: write`, `deployments: read`)
|
||||
- The GitHub App installation must have the requested permission enabled for it to take effect
|
||||
- Standard permissions (`contents: write`, `pull_requests: write`, `issues: write`) are always included and do not need to be specified
|
||||
|
||||
## Custom Environment Variables
|
||||
|
||||
|
||||
@@ -16,15 +16,60 @@ async function getOidcToken(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
||||
const DEFAULT_PERMISSIONS: Record<string, string> = {
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
};
|
||||
|
||||
export function parseAdditionalPermissions():
|
||||
| Record<string, string>
|
||||
| undefined {
|
||||
const raw = process.env.ADDITIONAL_PERMISSIONS;
|
||||
if (!raw || !raw.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const additional: Record<string, string> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const colonIndex = trimmed.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
const key = trimmed.slice(0, colonIndex).trim();
|
||||
const value = trimmed.slice(colonIndex + 1).trim();
|
||||
if (key && value) {
|
||||
additional[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(additional).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { ...DEFAULT_PERMISSIONS, ...additional };
|
||||
}
|
||||
|
||||
async function exchangeForAppToken(
|
||||
oidcToken: string,
|
||||
permissions?: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
};
|
||||
const fetchOptions: RequestInit = {
|
||||
method: "POST",
|
||||
headers,
|
||||
};
|
||||
|
||||
if (permissions) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
fetchOptions.body = JSON.stringify({ permissions });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.anthropic.com/api/github/github-app-token-exchange",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
},
|
||||
},
|
||||
fetchOptions,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -89,9 +134,11 @@ export async function setupGitHubToken(): Promise<string> {
|
||||
const oidcToken = await retryWithBackoff(() => getOidcToken());
|
||||
console.log("OIDC token successfully obtained");
|
||||
|
||||
const permissions = parseAdditionalPermissions();
|
||||
|
||||
console.log("Exchanging OIDC token for app token...");
|
||||
const appToken = await retryWithBackoff(() =>
|
||||
exchangeForAppToken(oidcToken),
|
||||
exchangeForAppToken(oidcToken, permissions),
|
||||
);
|
||||
console.log("App token successfully obtained");
|
||||
|
||||
|
||||
97
test/parse-permissions.test.ts
Normal file
97
test/parse-permissions.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { parseAdditionalPermissions } from "../src/github/token";
|
||||
|
||||
describe("parseAdditionalPermissions", () => {
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.ADDITIONAL_PERMISSIONS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ADDITIONAL_PERMISSIONS;
|
||||
} else {
|
||||
process.env.ADDITIONAL_PERMISSIONS = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns undefined when env var is not set", () => {
|
||||
delete process.env.ADDITIONAL_PERMISSIONS;
|
||||
expect(parseAdditionalPermissions()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when env var is empty string", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = "";
|
||||
expect(parseAdditionalPermissions()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when env var is only whitespace", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = " \n \n ";
|
||||
expect(parseAdditionalPermissions()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses single permission and merges with defaults", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = "actions: read";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses multiple permissions", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = "actions: read\nworkflows: write";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
workflows: "write",
|
||||
});
|
||||
});
|
||||
|
||||
test("additional permissions can override defaults", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = "contents: read";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "read",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles extra whitespace around keys and values", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = " actions : read ";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
});
|
||||
});
|
||||
|
||||
test("skips empty lines", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS =
|
||||
"actions: read\n\n\nworkflows: write\n\n";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
workflows: "write",
|
||||
});
|
||||
});
|
||||
|
||||
test("skips lines without colons", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS =
|
||||
"actions: read\ninvalid line\nworkflows: write";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
workflows: "write",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user