From 7cea0f8f5b3b82dffd9a36c79a0c926951352a3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:40:53 +0000 Subject: [PATCH 1/2] Add Copilot connection token wiring for SDK mode Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 1 + actions/setup/js/copilot_harness.test.cjs | 36 ++++++++++++++++++++- actions/setup/js/copilot_sdk_driver.cjs | 12 +++++-- pkg/constants/engine_constants.go | 6 ++++ pkg/workflow/copilot_engine.go | 1 + pkg/workflow/copilot_engine_test.go | 22 +++++++++++++ pkg/workflow/engine_helpers_secrets_test.go | 6 ++-- 7 files changed, 79 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 15479af58bd..969ef0df2ec 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -719,6 +719,7 @@ async function main() { logger: log, attempt, model: sdkCustomProviderConfig?.model, + connectionToken: process.env.COPILOT_CONNECTION_TOKEN, provider: sdkCustomProviderConfig?.provider, }); } else { diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index f706593ea1e..2b2a907c62a 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -281,6 +281,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 +302,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 +314,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, }); diff --git a/pkg/constants/engine_constants.go b/pkg/constants/engine_constants.go index f7e54c9f3ed..9675f91bb5b 100644 --- a/pkg/constants/engine_constants.go +++ b/pkg/constants/engine_constants.go @@ -269,6 +269,12 @@ const ( // library can locate the running Copilot HTTP server. CopilotSDKURIEnvVar = "COPILOT_SDK_URI" + // CopilotConnectionTokenEnvVar is the environment variable name used by the + // Copilot CLI to enforce loopback authentication for headless/TCP connections. + // When set, the same token must be provided by SDK clients in the initial + // connect handshake. + CopilotConnectionTokenEnvVar = "COPILOT_CONNECTION_TOKEN" + // CopilotBYOKDummyAPIKey is the placeholder API key used to trigger AWF's // runtime BYOK detection for Copilot offline mode. The real credential remains // isolated in the AWF API proxy sidecar. diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index 8ce6ccdbe55..0285e72e30f 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -69,6 +69,7 @@ func (e *CopilotEngine) GetRequiredSecretNames(workflowData *WorkflowData) []str copilotLog.Print("Collecting required secrets for Copilot engine") secrets := []string{ "COPILOT_GITHUB_TOKEN", + constants.CopilotConnectionTokenEnvVar, // BYOK provider variables that may carry secrets in engine.env. // Listed unconditionally: checking for their presence in the current workflow's // EngineConfig.Env would add complexity without security benefit, since these diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index cd63a120c7f..7ec15f3ea15 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -1928,6 +1928,28 @@ func TestCopilotEngineEnvOverridesTokenExpression(t *testing.T) { t.Errorf("Expected engine.env to add CUSTOM_VAR, got:\n%s", stepContent) } }) + + t.Run("engine env preserves COPILOT_CONNECTION_TOKEN secret expression", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + constants.CopilotConnectionTokenEnvVar: "${{ secrets.MY_COPILOT_CONNECTION_TOKEN }}", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join([]string(steps[0]), "\n") + expected := constants.CopilotConnectionTokenEnvVar + ": ${{ secrets.MY_COPILOT_CONNECTION_TOKEN }}" + if !strings.Contains(stepContent, expected) { + t.Errorf("Expected engine.env to preserve %s secret expression, got:\n%s", constants.CopilotConnectionTokenEnvVar, stepContent) + } + }) } // TestCopilotEngineBYOKOmitsCopilotGitHubToken verifies that COPILOT_GITHUB_TOKEN is diff --git a/pkg/workflow/engine_helpers_secrets_test.go b/pkg/workflow/engine_helpers_secrets_test.go index cf65e6756db..45d7e58b363 100644 --- a/pkg/workflow/engine_helpers_secrets_test.go +++ b/pkg/workflow/engine_helpers_secrets_test.go @@ -136,11 +136,13 @@ func TestGetRequiredSecretNames_Copilot(t *testing.T) { secrets := engine.GetRequiredSecretNames(workflowData) - // Should include COPILOT_GITHUB_TOKEN plus the three BYOK provider secret keys + // Should include COPILOT_GITHUB_TOKEN, COPILOT_CONNECTION_TOKEN, + // plus the three BYOK provider secret keys // (COPILOT_PROVIDER_BASE_URL, COPILOT_PROVIDER_API_KEY, COPILOT_PROVIDER_BEARER_TOKEN) // which are always listed so that strict-mode validation recognises them as engine credentials. - require.Len(t, secrets, 4) + require.Len(t, secrets, 5) assert.Contains(t, secrets, "COPILOT_GITHUB_TOKEN") + assert.Contains(t, secrets, "COPILOT_CONNECTION_TOKEN") assert.Contains(t, secrets, "COPILOT_PROVIDER_BASE_URL") assert.Contains(t, secrets, "COPILOT_PROVIDER_API_KEY") assert.Contains(t, secrets, "COPILOT_PROVIDER_BEARER_TOKEN") From a9495dfd6036668a83260243847af1fd4c0b12b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:42:10 +0000 Subject: [PATCH 2/2] Generate Copilot SDK connection token in harness and revert Go plumbing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 24 +++++++++++++++++++-- actions/setup/js/copilot_harness.test.cjs | 16 ++++++++++++++ pkg/constants/engine_constants.go | 6 ------ pkg/workflow/copilot_engine.go | 1 - pkg/workflow/copilot_engine_test.go | 22 ------------------- pkg/workflow/engine_helpers_secrets_test.go | 6 ++---- 6 files changed, 40 insertions(+), 35 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 969ef0df2ec..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,7 +738,7 @@ async function main() { logger: log, attempt, model: sdkCustomProviderConfig?.model, - connectionToken: process.env.COPILOT_CONNECTION_TOKEN, + connectionToken: copilotConnectionToken, provider: sdkCustomProviderConfig?.provider, }); } else { @@ -924,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 2b2a907c62a..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 diff --git a/pkg/constants/engine_constants.go b/pkg/constants/engine_constants.go index 9675f91bb5b..f7e54c9f3ed 100644 --- a/pkg/constants/engine_constants.go +++ b/pkg/constants/engine_constants.go @@ -269,12 +269,6 @@ const ( // library can locate the running Copilot HTTP server. CopilotSDKURIEnvVar = "COPILOT_SDK_URI" - // CopilotConnectionTokenEnvVar is the environment variable name used by the - // Copilot CLI to enforce loopback authentication for headless/TCP connections. - // When set, the same token must be provided by SDK clients in the initial - // connect handshake. - CopilotConnectionTokenEnvVar = "COPILOT_CONNECTION_TOKEN" - // CopilotBYOKDummyAPIKey is the placeholder API key used to trigger AWF's // runtime BYOK detection for Copilot offline mode. The real credential remains // isolated in the AWF API proxy sidecar. diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index 0285e72e30f..8ce6ccdbe55 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -69,7 +69,6 @@ func (e *CopilotEngine) GetRequiredSecretNames(workflowData *WorkflowData) []str copilotLog.Print("Collecting required secrets for Copilot engine") secrets := []string{ "COPILOT_GITHUB_TOKEN", - constants.CopilotConnectionTokenEnvVar, // BYOK provider variables that may carry secrets in engine.env. // Listed unconditionally: checking for their presence in the current workflow's // EngineConfig.Env would add complexity without security benefit, since these diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index 7ec15f3ea15..cd63a120c7f 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -1928,28 +1928,6 @@ func TestCopilotEngineEnvOverridesTokenExpression(t *testing.T) { t.Errorf("Expected engine.env to add CUSTOM_VAR, got:\n%s", stepContent) } }) - - t.Run("engine env preserves COPILOT_CONNECTION_TOKEN secret expression", func(t *testing.T) { - workflowData := &WorkflowData{ - Name: "test-workflow", - EngineConfig: &EngineConfig{ - Env: map[string]string{ - constants.CopilotConnectionTokenEnvVar: "${{ secrets.MY_COPILOT_CONNECTION_TOKEN }}", - }, - }, - } - - steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") - if len(steps) != 1 { - t.Fatalf("Expected 1 step, got %d", len(steps)) - } - - stepContent := strings.Join([]string(steps[0]), "\n") - expected := constants.CopilotConnectionTokenEnvVar + ": ${{ secrets.MY_COPILOT_CONNECTION_TOKEN }}" - if !strings.Contains(stepContent, expected) { - t.Errorf("Expected engine.env to preserve %s secret expression, got:\n%s", constants.CopilotConnectionTokenEnvVar, stepContent) - } - }) } // TestCopilotEngineBYOKOmitsCopilotGitHubToken verifies that COPILOT_GITHUB_TOKEN is diff --git a/pkg/workflow/engine_helpers_secrets_test.go b/pkg/workflow/engine_helpers_secrets_test.go index 45d7e58b363..cf65e6756db 100644 --- a/pkg/workflow/engine_helpers_secrets_test.go +++ b/pkg/workflow/engine_helpers_secrets_test.go @@ -136,13 +136,11 @@ func TestGetRequiredSecretNames_Copilot(t *testing.T) { secrets := engine.GetRequiredSecretNames(workflowData) - // Should include COPILOT_GITHUB_TOKEN, COPILOT_CONNECTION_TOKEN, - // plus the three BYOK provider secret keys + // Should include COPILOT_GITHUB_TOKEN plus the three BYOK provider secret keys // (COPILOT_PROVIDER_BASE_URL, COPILOT_PROVIDER_API_KEY, COPILOT_PROVIDER_BEARER_TOKEN) // which are always listed so that strict-mode validation recognises them as engine credentials. - require.Len(t, secrets, 5) + require.Len(t, secrets, 4) assert.Contains(t, secrets, "COPILOT_GITHUB_TOKEN") - assert.Contains(t, secrets, "COPILOT_CONNECTION_TOKEN") assert.Contains(t, secrets, "COPILOT_PROVIDER_BASE_URL") assert.Contains(t, secrets, "COPILOT_PROVIDER_API_KEY") assert.Contains(t, secrets, "COPILOT_PROVIDER_BEARER_TOKEN")