diff --git a/.changeset/patch-fix-copilot-sdk-provider.md b/.changeset/patch-fix-copilot-sdk-provider.md new file mode 100644 index 00000000000..1ac7bc6daf4 --- /dev/null +++ b/.changeset/patch-fix-copilot-sdk-provider.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Fixed the standalone Copilot SDK driver so it resolves the custom provider from the AWF reflect payload and can authenticate in offline/BYOK mode. diff --git a/actions/setup/js/awf_reflect.cjs b/actions/setup/js/awf_reflect.cjs index ed0491dc56d..b3a393b4389 100644 --- a/actions/setup/js/awf_reflect.cjs +++ b/actions/setup/js/awf_reflect.cjs @@ -226,6 +226,7 @@ async function enrichReflectModels(reflectData, timeoutMs, logger) { * reflectUrl: string, * outputPath: string, * bytesWritten?: number, + * reflectData?: object, * reason?: "unexpected_status"|"timeout"|"request_failed", * status?: number, * error?: string, @@ -275,6 +276,7 @@ async function fetchAWFReflect(options) { reflectUrl, outputPath, bytesWritten: enrichedBody.length, + reflectData, }; } catch (err) { const e = /** @type {Error} */ err; @@ -300,6 +302,75 @@ async function fetchAWFReflect(options) { } } +/** + * Resolve Copilot SDK BYOK custom provider configuration from AWF /reflect data. + * Chooses a configured endpoint and maps it to an OpenAI-compatible provider base URL. + * Returns null when no suitable endpoint is found (e.g. no reflect data, or endpoints not + * configured). + * + * Requires live reflect data passed directly via `reflectData`. + * + * @param {{ + * model?: string, + * reflectData: object | null | undefined, + * logger?: (msg: string) => void, + * }} [options] + * @returns {{ model: string, provider: { type: "openai", baseUrl: string } } | null} + */ +function resolveCopilotSDKCustomProviderFromReflect(options) { + const configuredModel = typeof options?.model === "string" ? options.model.trim() : ""; + const logger = (options && options.logger) || DEFAULT_REFLECT_LOGGER; + + const reflectData = options?.reflectData; + if (reflectData == null) { + logger("sdk-mode: no reflect data provided; cannot resolve custom provider"); + return null; + } + + const endpoints = Array.isArray(reflectData?.endpoints) ? reflectData.endpoints.filter(ep => ep && ep.configured === true) : []; + if (endpoints.length === 0) { + logger("sdk-mode: no configured endpoints in awf-reflect data; cannot resolve custom provider"); + return null; + } + + const endpoint = + (configuredModel ? endpoints.find(ep => Array.isArray(ep.models) && ep.models.includes(configuredModel)) : null) || + endpoints.find(ep => String(ep.provider || "").toLowerCase() === "copilot") || + endpoints[0]; + + let baseUrl = ""; + if (typeof endpoint?.models_url === "string" && endpoint.models_url) { + try { + baseUrl = new URL(endpoint.models_url).origin; + } catch { + // ignore malformed URL and fall back to port-based construction below + } + } + if (!baseUrl && endpoint?.port != null) { + baseUrl = `http://api-proxy:${String(endpoint.port)}`; + } + if (!baseUrl) { + logger("sdk-mode: unable to derive provider baseUrl from awf-reflect endpoint data; cannot resolve custom provider"); + return null; + } + + let model = configuredModel; + if (!model && Array.isArray(endpoint?.models)) { + const firstModel = endpoint.models.find(m => typeof m === "string" && m.trim().length > 0); + model = typeof firstModel === "string" ? firstModel.trim() : ""; + } + if (!model) { + logger("sdk-mode: unable to derive model for custom provider from awf-reflect; cannot resolve custom provider"); + return null; + } + + logger(`sdk-mode: custom provider resolved from awf-reflect (provider=${String(endpoint.provider || "unknown")} baseUrl=${baseUrl} model=${model})`); + return { + model, + provider: { type: "openai", baseUrl }, + }; +} + if (typeof module !== "undefined" && module.exports) { module.exports = { AWF_API_PROXY_REFLECT_URL, @@ -314,5 +385,6 @@ if (typeof module !== "undefined" && module.exports) { extractModelIds, fetchAWFReflect, fetchModelsFromUrl, + resolveCopilotSDKCustomProviderFromReflect, }; } diff --git a/actions/setup/js/awf_reflect.test.cjs b/actions/setup/js/awf_reflect.test.cjs index 30b904a2cfb..a37d0d1ff49 100644 --- a/actions/setup/js/awf_reflect.test.cjs +++ b/actions/setup/js/awf_reflect.test.cjs @@ -18,6 +18,7 @@ const { extractModelIds, fetchAWFReflect, fetchModelsFromUrl, + resolveCopilotSDKCustomProviderFromReflect, } = require("./awf_reflect.cjs"); describe("awf_reflect.cjs", () => { @@ -244,6 +245,7 @@ describe("awf_reflect.cjs", () => { reflectUrl: "http://api-proxy:10000/reflect", outputPath, bytesWritten: expect.any(Number), + reflectData: expect.objectContaining({ endpoints: expect.any(Array) }), }); const saved = JSON.parse(fs.readFileSync(outputPath, "utf8")); expect(saved.endpoints[0].models).toEqual(["gpt-4o", "gpt-4o-mini"]); @@ -305,4 +307,69 @@ describe("awf_reflect.cjs", () => { expect(collected.length).toBeGreaterThan(0); }); }); + + describe("resolveCopilotSDKCustomProviderFromReflect", () => { + it("resolves provider baseUrl and model from port when models_url is absent", () => { + const reflectData = { + endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.4", "claude-sonnet-4.6"] }], + }; + expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData })).toEqual({ + model: "gpt-5.4", + provider: { type: "openai", baseUrl: "http://api-proxy:10002" }, + }); + }); + + it("prefers the endpoint matching the configured model", () => { + const reflectData = { + endpoints: [ + { provider: "openai", port: 10001, configured: true, models: ["gpt-4o"] }, + { provider: "anthropic", port: 10002, configured: true, models: ["claude-sonnet-4.6"] }, + ], + }; + expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "claude-sonnet-4.6" })).toEqual({ + model: "claude-sonnet-4.6", + provider: { type: "openai", baseUrl: "http://api-proxy:10002" }, + }); + }); + + it("derives baseUrl from models_url origin when available", () => { + const reflectData = { + endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-4o"], models_url: "http://172.30.0.30:10002/v1/models" }], + }; + expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData })).toEqual({ + model: "gpt-4o", + provider: { type: "openai", baseUrl: "http://172.30.0.30:10002" }, + }); + }); + + it("returns null when no configured endpoints exist", () => { + const logs = []; + const result = resolveCopilotSDKCustomProviderFromReflect({ + reflectData: { endpoints: [{ provider: "copilot", port: 10002, configured: false, models: [] }] }, + logger: msg => logs.push(msg), + }); + expect(result).toBeNull(); + expect(logs.some(l => l.includes("no configured endpoints"))).toBe(true); + }); + + it("returns null when reflectData is null", () => { + const logs = []; + const result = resolveCopilotSDKCustomProviderFromReflect({ + reflectData: null, + logger: msg => logs.push(msg), + }); + expect(result).toBeNull(); + expect(logs.some(l => l.includes("no reflect data provided"))).toBe(true); + }); + + it("returns null when reflectData is undefined", () => { + const logs = []; + const result = resolveCopilotSDKCustomProviderFromReflect({ + reflectData: undefined, + logger: msg => logs.push(msg), + }); + expect(result).toBeNull(); + expect(logs.some(l => l.includes("no reflect data provided"))).toBe(true); + }); + }); }); diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 05223828746..8e53179e836 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -57,6 +57,7 @@ const { extractModelIds, fetchAWFReflect, fetchModelsFromUrl, + resolveCopilotSDKCustomProviderFromReflect, } = require("./awf_reflect.cjs"); const { runSafeOutputsCLI, buildMissingToolAlternatives, emitMissingToolPermissionIssue, emitInfrastructureIncomplete } = require("./safeoutputs_cli.cjs"); const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs"); @@ -230,73 +231,6 @@ function isModelAvailableInReflectFile(model, options) { } } -/** - * Resolve Copilot SDK BYOK custom provider configuration from saved AWF /reflect data. - * Chooses a configured endpoint and maps it to an OpenAI-compatible provider base URL. - * - * @param {{ - * model?: string, - * reflectPath?: string, - * readFileSync?: (path: string, encoding: string) => string, - * logger?: (msg: string) => void, - * }} [options] - * @returns {{ model: string, provider: { type: "openai", baseUrl: string } } | null} - */ -function resolveCopilotSDKCustomProviderFromReflect(options) { - const configuredModel = typeof options?.model === "string" ? options.model.trim() : ""; - const reflectPath = (options && options.reflectPath) || AWF_REFLECT_OUTPUT_PATH; - const readFile = (options && options.readFileSync) || fs.readFileSync; - const logger = (options && options.logger) || log; - - try { - const raw = readFile(reflectPath, "utf8"); - const reflectData = JSON.parse(raw); - const endpoints = Array.isArray(reflectData?.endpoints) ? reflectData.endpoints.filter(ep => ep && ep.configured === true) : []; - if (endpoints.length === 0) { - logger(`sdk-mode: no configured endpoints in ${reflectPath}; skipping custom provider config`); - return null; - } - - const endpoint = (configuredModel ? endpoints.find(ep => Array.isArray(ep.models) && ep.models.includes(configuredModel)) : null) || endpoints.find(ep => String(ep.provider || "").toLowerCase() === "copilot") || endpoints[0]; - - let baseUrl = ""; - if (typeof endpoint?.models_url === "string" && endpoint.models_url) { - try { - baseUrl = new URL(endpoint.models_url).origin; - } catch { - // ignore malformed URL and fall back to port-based construction below - } - } - if (!baseUrl && endpoint?.port != null) { - baseUrl = `http://api-proxy:${String(endpoint.port)}`; - } - if (!baseUrl) { - logger("sdk-mode: unable to derive provider baseUrl from awf-reflect endpoint data; skipping custom provider config"); - return null; - } - - let model = configuredModel; - if (!model && Array.isArray(endpoint?.models)) { - const firstModel = endpoint.models.find(m => typeof m === "string" && m.trim().length > 0); - model = typeof firstModel === "string" ? firstModel.trim() : ""; - } - if (!model) { - logger("sdk-mode: unable to derive model for custom provider from awf-reflect; skipping custom provider config"); - return null; - } - - logger(`sdk-mode: custom provider resolved from awf-reflect (provider=${String(endpoint.provider || "unknown")} baseUrl=${baseUrl} model=${model})`); - return { - model, - provider: { type: "openai", baseUrl }, - }; - } catch (error) { - const err = /** @type {Error} */ error; - logger(`sdk-mode: unable to read custom provider config from ${reflectPath}: ${err.message}`); - return null; - } -} - /** * Determines if the collected output contains a "No authentication information found" error. * This means no auth token (COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN) is available @@ -604,14 +538,6 @@ async function main() { log("copilot-sdk mode active: generated per-run COPILOT_CONNECTION_TOKEN"); log(`copilot-sdk mode active: COPILOT_SDK_URI=${sdkEnv.COPILOT_SDK_URI || "(not set)"}`); } - // 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). - // sdkEnv already contains SDK-mode variables (e.g. COPILOT_SDK_URI) when enabled. - // Always attach the generated per-run COPILOT_CONNECTION_TOKEN so both the sidecar - // (started by the harness) and the SDK client share the same token. - const sdkChildEnv = copilotSDKMode ? { ...sdkEnv, COPILOT_CONNECTION_TOKEN: copilotConnectionToken } : sdkEnv; - const childEnv = Object.keys(sdkChildEnv).length > 0 ? { ...process.env, ...sdkChildEnv } : undefined; // In driver mode the args are the driver command + copilot binary path; no stdin payload. // In CLI mode, args are resolved to inline prompt text. @@ -622,13 +548,53 @@ async function main() { resolvedArgs = resolvePromptFileArgs(args); } - // Fetch AWF API proxy reflection data before running the agent to capture initial proxy state. - // This is best-effort: failures are logged but do not affect the agent run. + // Fetch AWF API proxy reflection data before running the agent. + // In SDK/BYOK mode the live data is used immediately to resolve the custom provider + // configuration that is injected into the driver subprocess environment. // Skip when AWF_REFLECT_ENABLED is not "1" (e.g. sandbox.agent: false — no api-proxy running). + let awfReflectData = null; if (process.env.AWF_REFLECT_ENABLED === "1") { - await fetchAWFReflect({ logger: log }); + const reflectResult = await fetchAWFReflect({ logger: log }); + if (reflectResult.ok && reflectResult.reflectData) { + awfReflectData = reflectResult.reflectData; + } } + // Resolve BYOK custom provider from live reflect data (SDK mode only). + // BYOK is the only supported mode for SDK sessions — fail immediately if the provider + // cannot be resolved so retries are not wasted on a misconfigured environment. + let providerBaseUrl = ""; + let resolvedModel = ""; + if (copilotSDKMode) { + const configuredModel = process.env.COPILOT_MODEL || ""; + const customProvider = resolveCopilotSDKCustomProviderFromReflect({ model: configuredModel, reflectData: awfReflectData, logger: log }); + if (!customProvider) { + log("copilot-sdk driver mode: BYOK provider is required but could not be resolved from awf-reflect data — aborting"); + process.exit(1); + } + providerBaseUrl = customProvider.provider.baseUrl; + resolvedModel = customProvider.model; + log(`copilot-sdk driver mode: BYOK provider resolved (baseUrl=${providerBaseUrl} model=${resolvedModel})`); + } + + // 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). + // sdkEnv already contains SDK-mode variables (e.g. COPILOT_SDK_URI) when enabled. + // Always attach the generated per-run COPILOT_CONNECTION_TOKEN so both the sidecar + // (started by the harness) and the SDK client share the same token. + // In SDK mode also inject the resolved BYOK provider base URL and model so the driver + // subprocess does not need to re-read the reflect file. + const sdkChildEnv = copilotSDKMode + ? { + ...sdkEnv, + COPILOT_CONNECTION_TOKEN: copilotConnectionToken, + GH_AW_COPILOT_SDK_PROVIDER_BASE_URL: providerBaseUrl, + COPILOT_MODEL: resolvedModel, + } + : sdkEnv; + const childEnv = Object.keys(sdkChildEnv).length > 0 ? { ...process.env, ...sdkChildEnv } : undefined; + let delay = INITIAL_DELAY_MS; let lastExitCode = 1; const isScheduledRun = process.env.GITHUB_EVENT_NAME === "schedule"; diff --git a/actions/setup/js/copilot_sdk_driver.cjs b/actions/setup/js/copilot_sdk_driver.cjs index 7e9e307a7d4..953cd18b3ad 100644 --- a/actions/setup/js/copilot_sdk_driver.cjs +++ b/actions/setup/js/copilot_sdk_driver.cjs @@ -492,6 +492,20 @@ async function main() { log(`connecting to sidecar at ${sdkUri}`); + // --- Resolve BYOK custom provider from environment ------------------ + // The harness resolves the BYOK provider from live AWF reflect data before launching + // this driver and injects the result as GH_AW_COPILOT_SDK_PROVIDER_BASE_URL. + // BYOK is the only supported mode — fail immediately if the env var is missing. + const providerBaseUrl = process.env.GH_AW_COPILOT_SDK_PROVIDER_BASE_URL; + if (!providerBaseUrl) { + process.stderr.write( + "[copilot-sdk-driver] error: GH_AW_COPILOT_SDK_PROVIDER_BASE_URL is not set — " + + "BYOK provider is required; ensure the harness resolved a custom provider from awf-reflect data\n" + ); + process.exit(1); + } + const provider = /** @type {import("@github/copilot-sdk").ProviderConfig} */ ({ type: "openai", baseUrl: providerBaseUrl }); + // --- Run SDK session ------------------------------------------------- const result = await runWithCopilotSDK({ @@ -500,6 +514,7 @@ async function main() { logger: log, model, connectionToken, + provider, }); process.exit(result.exitCode);