diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 9d02b4ac921..7b58d677ffc 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -42,9 +42,8 @@ require("./shim.cjs"); const fs = require("fs"); -const path = require("path"); const crypto = require("crypto"); -const { renderTemplateFromFile } = require("./messages_core.cjs"); +const { getPromptPath, 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 { @@ -81,7 +80,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"); +const COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_NAME = "copilot_requests_proxy_auth_403.md"; // Pattern to detect transient CAPIError 400 in copilot output const CAPI_ERROR_400_PATTERN = /CAPIError:\s*400/; @@ -420,9 +419,10 @@ function envFlagEnabled(value) { * 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] + * @param {{ renderTemplateFromFile?: typeof renderTemplateFromFile }} [options] * @returns {string} */ -function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env) { +function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env, options = {}) { const authFailure = parseProviderAuthFailure(output); if (!authFailure || !isLikelyAWFAPIProxyURL(authFailure.providerUrl)) { return ""; @@ -431,7 +431,8 @@ function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env) { 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, { + const render = options.renderTemplateFromFile || renderTemplateFromFile; + return render(getPromptPath(COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_NAME), { selected_model: selectedModel, stage, }); diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index b413140382e..747062783c4 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -56,6 +56,50 @@ function makeHarnessTempDir(name) { return fs.mkdtempSync(path.join(agentTempDir, name)); } +function withTestPromptsDir(promptsDir, callback) { + const originalPromptsDir = process.env.GH_AW_PROMPTS_DIR; + if (typeof promptsDir === "string") { + process.env.GH_AW_PROMPTS_DIR = promptsDir; + } else { + delete process.env.GH_AW_PROMPTS_DIR; + } + try { + return callback(); + } finally { + if (typeof originalPromptsDir === "string") { + process.env.GH_AW_PROMPTS_DIR = originalPromptsDir; + } else { + delete process.env.GH_AW_PROMPTS_DIR; + } + } +} + +function withRunnerTemp(runnerTempDir, callback) { + const originalRunnerTemp = process.env.RUNNER_TEMP; + process.env.RUNNER_TEMP = runnerTempDir; + try { + return callback(); + } finally { + if (typeof originalRunnerTemp === "string") { + process.env.RUNNER_TEMP = originalRunnerTemp; + } else { + delete process.env.RUNNER_TEMP; + } + } +} + +function withTemporaryPromptTemplate(prefix, sourceTemplateDir, promptDirResolver, callback) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + try { + const promptsDir = promptDirResolver(tempDir); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.copyFileSync(path.join(sourceTemplateDir, "copilot_requests_proxy_auth_403.md"), path.join(promptsDir, "copilot_requests_proxy_auth_403.md")); + return callback(tempDir, promptsDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + describe("copilot_harness.cjs", () => { // Test the core logic patterns used by the driver without importing the module // (importing the module would invoke main() which calls process.exit). @@ -959,6 +1003,8 @@ describe("copilot_harness.cjs", () => { }); describe("gh-aw API proxy auth diagnostics", () => { + const promptsSourceDir = path.resolve("../md"); + it("rewrites local proxy 401 errors to COPILOT_GITHUB_TOKEN guidance", () => { const diagnostic = buildCopilotProxyAuthFailureDiagnostic("Authentication failed with provider at http://172.30.0.30:10002 (HTTP 401).\nCheck your COPILOT_PROVIDER_API_KEY or COPILOT_PROVIDER_BEARER_TOKEN.", { COPILOT_MODEL: "claude-sonnet-4.5", @@ -974,30 +1020,87 @@ describe("copilot_harness.cjs", () => { }); 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", - }); + withTestPromptsDir(promptsSourceDir, () => { + 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"); + 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"); + }); }); 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 ", + withTestPromptsDir(promptsSourceDir, () => { + 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"); + expect(diagnostic).toContain("https://github.github.com/gh-aw/reference/billing/"); + expect(diagnostic).not.toContain("COPILOT_PROVIDER_API_KEY"); }); + }); - expect(diagnostic).toContain("Copilot requests authentication failed"); - expect(diagnostic).toContain("https://github.github.com/gh-aw/reference/billing/"); - expect(diagnostic).not.toContain("COPILOT_PROVIDER_API_KEY"); + it("resolves the 403 guidance template from the runtime prompts directory", () => { + withTemporaryPromptTemplate( + "runtime-prompts-", + promptsSourceDir, + tempDir => tempDir, + (_tempDir, runtimePromptsDir) => { + withTestPromptsDir(runtimePromptsDir, () => { + const renderTemplateFromFile = vi.fn((templatePath, context) => { + return fs.readFileSync(templatePath, "utf8").replace("{selected_model}", context.selected_model).replace("{stage}", context.stage); + }); + const diagnostic = buildCopilotProxyAuthFailureDiagnostic( + "Authentication failed with provider at http://172.30.0.30:10002 (HTTP 403).", + { + COPILOT_MODEL: "claude-sonnet-4.5", + S2STOKENS: "true", + }, + { renderTemplateFromFile } + ); + + expect(diagnostic).toContain("Copilot requests authentication failed"); + expect(diagnostic).toContain("model=claude-sonnet-4.5"); + expect(diagnostic).toContain("stage=starting the Copilot CLI request"); + expect(renderTemplateFromFile).toHaveBeenCalledWith(path.join(runtimePromptsDir, "copilot_requests_proxy_auth_403.md"), { + selected_model: "claude-sonnet-4.5", + stage: "starting the Copilot CLI request", + }); + }); + } + ); + }); + + it("resolves the 403 guidance template from RUNNER_TEMP when GH_AW_PROMPTS_DIR is unset", () => { + withTemporaryPromptTemplate( + "runner-temp-", + promptsSourceDir, + tempDir => path.join(tempDir, "gh-aw", "prompts"), + runnerTempDir => { + withTestPromptsDir(undefined, () => { + withRunnerTemp(runnerTempDir, () => { + const diagnostic = buildCopilotProxyAuthFailureDiagnostic("Authentication failed with provider at http://172.30.0.30:10002 (HTTP 403).", { + COPILOT_MODEL: "claude-sonnet-4.5", + S2STOKENS: "true", + }); + + expect(diagnostic).toContain("Copilot requests authentication failed"); + expect(diagnostic).toContain("model=claude-sonnet-4.5"); + expect(diagnostic).toContain("stage=starting the Copilot CLI request"); + }); + }); + } + ); }); it("returns empty string for proxy 403 when S2STOKENS is not set (BYOK mode)", () => {