Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/patch-copilot-403-org-billing-message.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 26 additions & 2 deletions actions/setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ require("./shim.cjs");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { renderTemplateFromFile } = require("./messages_core.cjs");
const { runProcess, formatDuration, sleep, isCopilotSDKEnabled, buildCopilotSDKEnv } = require("./process_runner.cjs");
const { buildCopilotSDKServerArgs, getCopilotSDKServerPort, startCopilotSDKServer, stopCopilotSDKServer, waitForCopilotSDKServer } = require("./copilot_sdk_sidecar.cjs");
const {
Expand Down Expand Up @@ -80,6 +81,7 @@ const PROMPT_FILE_INLINE_THRESHOLD_LABEL = "100KB";
const MAX_ENV_VAR_PREVIEW_LENGTH = 120;
const OUTPUT_TAIL_MAX_CHARS = 600;
const OUTPUT_TAIL_MAX_LINES = 12;
const COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_PATH = path.join(__dirname, "../md/copilot_requests_proxy_auth_403.md");
// Pattern to detect transient CAPIError 400 in copilot output
const CAPI_ERROR_400_PATTERN = /CAPIError:\s*400/;

Expand Down Expand Up @@ -403,19 +405,40 @@ function detectCopilotAuthFailureStage(output) {
}

/**
* Build a more actionable Copilot auth diagnostic when a 401 came from the gh-aw API proxy.
* @param {string | undefined} value
* @returns {boolean}
*/
function envFlagEnabled(value) {
if (typeof value !== "string") {
return false;
}
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes";
Comment thread
pelikhan marked this conversation as resolved.
}

/**
* Build a more actionable Copilot auth diagnostic when a 401/403 came from the gh-aw API proxy.
* @param {string} output
* @param {NodeJS.ProcessEnv} [env]
* @returns {string}
*/
function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env) {
const authFailure = parseProviderAuthFailure(output);
if (!authFailure || authFailure.statusCode !== "401" || !isLikelyAWFAPIProxyURL(authFailure.providerUrl)) {
if (!authFailure || !isLikelyAWFAPIProxyURL(authFailure.providerUrl)) {
return "";
}

const selectedModel = typeof env.COPILOT_MODEL === "string" && env.COPILOT_MODEL.trim() ? env.COPILOT_MODEL.trim() : "(unset)";
const stage = detectCopilotAuthFailureStage(output);
if (authFailure.statusCode === "403" && envFlagEnabled(env.S2STOKENS)) {
return renderTemplateFromFile(COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_PATH, {
selected_model: selectedModel,
stage,
});
}
if (authFailure.statusCode !== "401") {
return "";
}
return (
`Copilot authentication failed through the gh-aw API proxy (HTTP 401, model=${selectedModel}, stage=${stage}). ` +
"Check that COPILOT_GITHUB_TOKEN is present, unexpired, and authorized for the selected COPILOT_MODEL. " +
Expand Down Expand Up @@ -1005,6 +1028,7 @@ if (typeof module !== "undefined" && module.exports) {
fetchAWFReflect,
fetchModelsFromUrl,
buildCopilotProxyAuthFailureDiagnostic,
envFlagEnabled,
generateCopilotConnectionToken,
buildCopilotSDKServerArgs,
getCopilotSDKServerPort,
Expand Down
68 changes: 68 additions & 0 deletions actions/setup/js/copilot_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const {
buildMissingToolAlternatives,
buildInfrastructureIncompletePayload,
buildCopilotProxyAuthFailureDiagnostic,
envFlagEnabled,
buildPromptFileFallbackInstruction,
countPermissionDeniedIssues,
detectCopilotErrors,
Expand Down Expand Up @@ -972,6 +973,59 @@ describe("copilot_harness.cjs", () => {
expect(diagnostic).not.toContain("COPILOT_PROVIDER_API_KEY");
});

it("rewrites local proxy 403 errors in copilot-requests mode to org-billing guidance", () => {
const diagnostic = buildCopilotProxyAuthFailureDiagnostic("Authentication failed with provider at http://172.30.0.30:10002 (HTTP 403).\nCheck your COPILOT_PROVIDER_API_KEY or COPILOT_PROVIDER_BEARER_TOKEN.", {
COPILOT_MODEL: "claude-sonnet-4.5",
S2STOKENS: "true",
});

expect(diagnostic).toContain("Copilot requests authentication failed");
expect(diagnostic).toContain("HTTP 403");
expect(diagnostic).toContain("model=claude-sonnet-4.5");
expect(diagnostic).toContain("stage=starting the Copilot CLI request");
expect(diagnostic).toContain("permissions.copilot-requests: write");
expect(diagnostic).toContain("centralized Copilot billing");
expect(diagnostic).toContain("https://github.github.com/gh-aw/reference/billing/");
expect(diagnostic).not.toContain("COPILOT_PROVIDER_API_KEY");
});
Comment thread
pelikhan marked this conversation as resolved.

it("treats truthy S2STOKENS values as copilot-requests mode for 403 guidance", () => {
const diagnostic = buildCopilotProxyAuthFailureDiagnostic("Authentication failed with provider at http://172.30.0.30:10002 (HTTP 403).", {
COPILOT_MODEL: "claude-sonnet-4.5",
S2STOKENS: " YES ",
});

expect(diagnostic).toContain("Copilot requests authentication failed");
Comment thread
pelikhan marked this conversation as resolved.
expect(diagnostic).toContain("https://github.github.com/gh-aw/reference/billing/");
expect(diagnostic).not.toContain("COPILOT_PROVIDER_API_KEY");
});

it("returns empty string for proxy 403 when S2STOKENS is not set (BYOK mode)", () => {
const diagnostic = buildCopilotProxyAuthFailureDiagnostic("Authentication failed with provider at http://172.30.0.30:10002 (HTTP 403).", {
COPILOT_MODEL: "claude-sonnet-4.5",
});

expect(diagnostic).toBe("");
});

it("returns empty string for proxy 403 when S2STOKENS is falsy", () => {
const diagnostic = buildCopilotProxyAuthFailureDiagnostic("Authentication failed with provider at http://172.30.0.30:10002 (HTTP 403).", {
COPILOT_MODEL: "claude-sonnet-4.5",
S2STOKENS: "false",
});

expect(diagnostic).toBe("");
});

it("returns empty string for non-proxy 403 even when S2STOKENS is true", () => {
const diagnostic = buildCopilotProxyAuthFailureDiagnostic("Authentication failed with provider at (api.anthropic.com/redacted) (HTTP 403).", {
COPILOT_MODEL: "claude-sonnet-4.5",
S2STOKENS: "true",
});

expect(diagnostic).toBe("");
});
Comment thread
pelikhan marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Falsy S2STOKENS and non-proxy-URL paths are completely untested: the test suite covers only truthy values, leaving two correctness gaps that would hide regressions.

💡 Suggested additions

Gap 1 — falsy S2STOKENS returns empty string (S2STOKENS gate correctness):

it("returns empty string for proxy 403 when S2STOKENS is not set", () => {
  const diagnostic = buildCopilotProxyAuthFailureDiagnostic(
    "Authentication failed with provider at (172.30.0.30/redacted) (HTTP 403).",
    { COPILOT_MODEL: "claude-sonnet-4.5" } // no S2STOKENS
  );
  expect(diagnostic).toBe("");
});

Gap 2 — non-proxy URL with S2STOKENS=true returns empty string (proxy-URL gate correctness):

it("returns empty string for non-proxy 403 even when S2STOKENS is true", () => {
  const diagnostic = buildCopilotProxyAuthFailureDiagnostic(
    "Authentication failed with provider at (api.anthropic.com/redacted) (HTTP 403).",
    { COPILOT_MODEL: "claude-sonnet-4.5", S2STOKENS: "true" }
  );
  expect(diagnostic).toBe("");
});

Without these, accidentally inverting the envFlagEnabled check or removing the isLikelyAWFAPIProxyURL guard would silently pass the entire test suite.


it("reports token-validation stage when present in the output", () => {
const diagnostic = buildCopilotProxyAuthFailureDiagnostic("Validating token with provider.\nAuthentication failed with provider at http://localhost:10002 (HTTP 401).", { COPILOT_MODEL: "gpt-4.1" });

Expand All @@ -997,6 +1051,20 @@ describe("copilot_harness.cjs", () => {
});
});

describe("envFlagEnabled", () => {
it.each(["true", "TRUE", "True", "1", "yes", " YES "])("returns true for '%s'", v => {
expect(envFlagEnabled(v)).toBe(true);
});

it.each(["false", "FALSE", "0", "no", "", " "])("returns false for '%s'", v => {
expect(envFlagEnabled(v)).toBe(false);
});

it("returns false for undefined", () => {
expect(envFlagEnabled(undefined)).toBe(false);
});
});

describe("auth error prevents retry", () => {
// Inline the same retry logic as the driver, including auth error check
const MCP_POLICY_BLOCKED_PATTERN = /MCP servers were blocked by policy:/;
Expand Down
1 change: 1 addition & 0 deletions actions/setup/md/copilot_requests_proxy_auth_403.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Copilot requests authentication failed through the gh-aw API proxy (HTTP 403, model={selected_model}, stage={stage}). This workflow is using permissions.copilot-requests: write, so Copilot requests must be allowed through your organization's centralized Copilot billing configuration. Verify that copilot-requests: write is granted to the workflow or job and that Copilot org billing is enabled for your organization. See https://github.github.com/gh-aw/reference/billing/ for details.
Loading