diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 15479af58bd..54beb051f7c 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -41,6 +41,7 @@ const fs = require("fs"); const path = require("path"); +const crypto = require("crypto"); const { runProcess, formatDuration, sleep, isCopilotSDKEnabled, buildCopilotSDKEnv } = require("./process_runner.cjs"); const { buildCopilotSDKServerArgs, getCopilotSDKServerPort, startCopilotSDKServer, stopCopilotSDKServer, waitForCopilotSDKServer } = require("./copilot_sdk_sidecar.cjs"); const { extractPromptFromArgs, runWithCopilotSDK } = require("./copilot_sdk_driver.cjs"); @@ -118,6 +119,18 @@ function log(message) { process.stderr.write(`[copilot-harness] ${message}\n`); } +/** + * Generate a per-run connection token for Copilot SDK headless authentication. + * Produces 32 random bytes encoded as a 64-character hexadecimal string. + * @param {{ randomBytes?: (size: number) => Buffer }} [options] + * @returns {string} 64-character hexadecimal token (32 random bytes). + */ +function generateCopilotConnectionToken(options) { + // randomBytes injection exists only for unit tests; production uses crypto.randomBytes. + const randomBytes = options?.randomBytes ?? crypto.randomBytes; + return randomBytes(32).toString("hex"); +} + /** * Determines if the collected output contains a transient CAPIError 400 * @param {string} output - Collected stdout+stderr from the process @@ -583,13 +596,19 @@ async function main() { // correct SDK endpoint URI. const sdkEnv = buildCopilotSDKEnv(); const copilotSDKMode = isCopilotSDKEnabled(); + let copilotConnectionToken; if (copilotSDKMode) { + copilotConnectionToken = generateCopilotConnectionToken(); log(`copilot-sdk mode active: COPILOT_SDK_URI=${sdkEnv.COPILOT_SDK_URI || "(not set)"}`); + log("copilot-sdk mode active: generated per-run COPILOT_CONNECTION_TOKEN"); } // Merge SDK env additions into the child process env only when the SDK helper // returned at least one variable; otherwise leave the env undefined so that // runProcess inherits the full process.env (the common case). - const childEnv = Object.keys(sdkEnv).length > 0 ? { ...process.env, ...sdkEnv } : undefined; + // sdkEnv already contains SDK-mode variables (e.g. COPILOT_SDK_URI) when enabled. + // In SDK mode, also attach the generated per-run COPILOT_CONNECTION_TOKEN. + const sdkChildEnv = copilotSDKMode ? { ...sdkEnv, COPILOT_CONNECTION_TOKEN: copilotConnectionToken } : sdkEnv; + const childEnv = Object.keys(sdkChildEnv).length > 0 ? { ...process.env, ...sdkChildEnv } : undefined; // In SDK mode, the engine pipes a JSON options payload via stdin containing the promptFile // path, serverArgs (complete CLI argument list for the headless server), and optionally addWorkspaceDir. @@ -719,6 +738,7 @@ async function main() { logger: log, attempt, model: sdkCustomProviderConfig?.model, + connectionToken: copilotConnectionToken, provider: sdkCustomProviderConfig?.provider, }); } else { @@ -923,6 +943,7 @@ if (typeof module !== "undefined" && module.exports) { fetchAWFReflect, fetchModelsFromUrl, buildCopilotProxyAuthFailureDiagnostic, + generateCopilotConnectionToken, buildCopilotSDKServerArgs, getCopilotSDKServerPort, isDetectionPhase, diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index f706593ea1e..3ea9de3cf41 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -34,6 +34,7 @@ const { extractModelIds, fetchAWFReflect, fetchModelsFromUrl, + generateCopilotConnectionToken, GEMINI_MODEL_NAME_PREFIX, PROMPT_FILE_INLINE_THRESHOLD_BYTES, resolvePromptFileArgs, @@ -76,6 +77,21 @@ describe("copilot_harness.cjs", () => { }); }); + describe("generateCopilotConnectionToken", () => { + it("generates a 32-byte hex token", () => { + const token = generateCopilotConnectionToken(); + expect(token).toMatch(/^[a-f0-9]{64}$/); + }); + + it("uses a pluggable random byte source", () => { + const randomBytes = vi.fn(() => Buffer.alloc(32, 0xab)); + const token = generateCopilotConnectionToken({ randomBytes }); + expect(token).toMatch(/^[a-f0-9]{64}$/); + expect(token).toBe("ab".repeat(32)); + expect(randomBytes).toHaveBeenCalledWith(32); + }); + }); + describe("retry policy: continue on partial execution", () => { // Inline the same retry-eligibility logic as the driver for unit testing. // The driver retries whenever the session produced output (hasOutput), regardless @@ -281,6 +297,7 @@ describe("copilot_harness.cjs", () => { it("passes custom provider and model through to SDK createSession", async () => { const disconnect = vi.fn().mockResolvedValue(undefined); const stop = vi.fn().mockResolvedValue(undefined); + const forUri = vi.fn(() => ({})); const createSession = vi.fn().mockResolvedValue({ sessionId: "session-provider", on: () => {}, @@ -301,7 +318,7 @@ describe("copilot_harness.cjs", () => { provider: { type: "openai", baseUrl: "http://api-proxy:10002" }, sdkModule: { CopilotClient: FakeCopilotClient, - RuntimeConnection: { forUri: vi.fn(() => ({})) }, + RuntimeConnection: { forUri }, approveAll: () => "allow", }, }); @@ -313,6 +330,39 @@ describe("copilot_harness.cjs", () => { provider: { type: "openai", baseUrl: "http://api-proxy:10002" }, }) ); + expect(forUri).toHaveBeenCalledWith("http://127.0.0.1:3002", {}); + }); + + it("passes COPILOT_CONNECTION_TOKEN to RuntimeConnection.forUri", async () => { + const disconnect = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const forUri = vi.fn(() => ({})); + const createSession = vi.fn().mockResolvedValue({ + sessionId: "session-connection-token", + on: () => {}, + sendAndWait: vi.fn().mockResolvedValue({ data: { content: "ok" } }), + disconnect, + }); + class FakeCopilotClient { + start = vi.fn().mockResolvedValue(undefined); + createSession = createSession; + stop = stop; + } + + const result = await runWithCopilotSDK({ + sdkUri: "http://127.0.0.1:3002", + prompt: "test prompt", + logger: () => {}, + connectionToken: "token-123", + sdkModule: { + CopilotClient: FakeCopilotClient, + RuntimeConnection: { forUri }, + approveAll: () => "allow", + }, + }); + + expect(result.exitCode).toBe(0); + expect(forUri).toHaveBeenCalledWith("http://127.0.0.1:3002", { connectionToken: "token-123" }); }); }); diff --git a/actions/setup/js/copilot_sdk_driver.cjs b/actions/setup/js/copilot_sdk_driver.cjs index 905b783be48..28572efea3c 100644 --- a/actions/setup/js/copilot_sdk_driver.cjs +++ b/actions/setup/js/copilot_sdk_driver.cjs @@ -64,6 +64,7 @@ function extractPromptFromArgs(args) { * logger: (msg: string) => void, * attempt?: number, * model?: string, + * connectionToken?: string, * provider?: import("@github/copilot-sdk").ProviderConfig, * sdkModule?: { * CopilotClient: typeof import("@github/copilot-sdk").CopilotClient, @@ -73,7 +74,7 @@ function extractPromptFromArgs(args) { * }} options * @returns {Promise<{exitCode: number, output: string, hasOutput: boolean, durationMs: number}>} */ -async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, provider, sdkModule }) { +async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, connectionToken, provider, sdkModule }) { // Lazy-require to avoid loading the SDK when it is not needed. // The SDK is large and has side-effects on import (worker threads, etc.). const { CopilotClient, RuntimeConnection, approveAll } = sdkModule ?? require("@github/copilot-sdk"); @@ -105,7 +106,14 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, p const logLevel = isValidLogLevel(rawLogLevel) ? rawLogLevel : "warning"; const client = new CopilotClient({ - connection: RuntimeConnection.forUri(sdkUri, {}), + connection: RuntimeConnection.forUri( + sdkUri, + connectionToken + ? { + connectionToken, + } + : {} + ), workingDirectory: process.env.GITHUB_WORKSPACE || process.cwd(), logLevel, });