Skip to content

Commit e80eec8

Browse files
authored
Harden Copilot threat-detection retries against awf-reflect model-registry race (#35355)
1 parent 14e5da0 commit e80eec8

3 files changed

Lines changed: 128 additions & 4 deletions

File tree

actions/setup/js/copilot_harness.cjs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,74 @@ function isModelNotSupportedError(output) {
150150
return MODEL_NOT_SUPPORTED_PATTERN.test(output);
151151
}
152152

153+
/**
154+
* Determine whether the current run phase is threat detection.
155+
* @param {string | undefined | null} phase
156+
* @returns {boolean}
157+
*/
158+
function isDetectionPhase(phase) {
159+
return (
160+
String(phase || "")
161+
.trim()
162+
.toLowerCase() === "detection"
163+
);
164+
}
165+
166+
/**
167+
* Check whether a model is present in AWF /reflect endpoint data.
168+
* @param {string} model
169+
* @param {unknown} reflectData
170+
* @returns {boolean}
171+
*/
172+
function isModelAvailableInReflectData(model, reflectData) {
173+
const normalizedModel = typeof model === "string" ? model.trim() : "";
174+
if (!normalizedModel) return false;
175+
if (!reflectData || typeof reflectData !== "object") return false;
176+
177+
// TypeScript needs explicit 'in' check or cast before property access on narrowed object type
178+
const endpoints = 'endpoints' in reflectData && Array.isArray(reflectData.endpoints) ? reflectData.endpoints : [];
179+
for (const endpoint of endpoints) {
180+
if (!endpoint || endpoint.configured !== true || !Array.isArray(endpoint.models)) {
181+
continue;
182+
}
183+
if (endpoint.models.includes(normalizedModel)) {
184+
return true;
185+
}
186+
}
187+
return false;
188+
}
189+
190+
/**
191+
* Load saved AWF /reflect data and check whether a model is present.
192+
* @param {string} model
193+
* @param {{
194+
* reflectPath?: string,
195+
* readFileSync?: (path: string, encoding: string) => string,
196+
* logger?: (msg: string) => void,
197+
* }} [options]
198+
* @returns {boolean}
199+
*/
200+
function isModelAvailableInReflectFile(model, options) {
201+
const normalizedModel = typeof model === "string" ? model.trim() : "";
202+
const reflectPath = (options && options.reflectPath) || AWF_REFLECT_OUTPUT_PATH;
203+
const readFile = (options && options.readFileSync) || fs.readFileSync;
204+
const logger = (options && options.logger) || log;
205+
if (!normalizedModel) {
206+
logger("awf-reflect: model availability check skipped (model is empty)");
207+
return false;
208+
}
209+
210+
try {
211+
const raw = readFile(reflectPath, "utf8");
212+
const reflectData = JSON.parse(raw);
213+
return isModelAvailableInReflectData(normalizedModel, reflectData);
214+
} catch (error) {
215+
const err = /** @type {Error} */ error;
216+
logger(`awf-reflect: unable to read model availability from ${reflectPath}: ${err.message}`);
217+
return false;
218+
}
219+
}
220+
153221
/**
154222
* Determines if the collected output contains a "No authentication information found" error.
155223
* This means no auth token (COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN) is available
@@ -470,6 +538,7 @@ async function main() {
470538
let scheduledExit2Retries = 0;
471539
let scheduledExit2RetryAttempted = false;
472540
let useContinueOnRetry = false;
541+
let modelNotSupportedReflectRetryAttempted = false;
473542
// Once set to true, --continue is never re-enabled for the remainder of this run.
474543
// This prevents a broken --continue recovery from resurrecting --continue on the next attempt.
475544
let continueDisabledPermanently = false;
@@ -563,6 +632,19 @@ async function main() {
563632

564633
// Model-not-supported errors are persistent — retrying will not help.
565634
if (isModelNotSupported) {
635+
if (!modelNotSupportedReflectRetryAttempted && attempt < MAX_RETRIES && isDetectionPhase(process.env.GH_AW_PHASE) && process.env.AWF_REFLECT_ENABLED === "1") {
636+
const configuredModel = process.env.COPILOT_MODEL || "";
637+
modelNotSupportedReflectRetryAttempted = true;
638+
log(`attempt ${attempt + 1}: model not supported during detection — refreshing awf-reflect to rule out startup registry race`);
639+
await fetchAWFReflect({ logger: log });
640+
if (isModelAvailableInReflectFile(configuredModel, { logger: log })) {
641+
useContinueOnRetry = false;
642+
continueDisabledPermanently = true;
643+
log(`attempt ${attempt + 1}: refreshed awf-reflect now includes model '${configuredModel}' — retrying once as fresh run`);
644+
continue;
645+
}
646+
log(`attempt ${attempt + 1}: refreshed awf-reflect does not include model '${configuredModel || "(none)"}' — treating as non-retryable`);
647+
}
566648
log(`attempt ${attempt + 1}: model not supported — not retrying (the requested model is unavailable for this subscription tier; specify a supported model in the workflow frontmatter)`);
567649
break;
568650
}
@@ -662,6 +744,9 @@ if (typeof module !== "undefined" && module.exports) {
662744
extractDeniedCommands,
663745
fetchAWFReflect,
664746
fetchModelsFromUrl,
747+
isDetectionPhase,
748+
isModelAvailableInReflectData,
749+
isModelAvailableInReflectFile,
665750
countPermissionDeniedIssues,
666751
detectCopilotErrors,
667752
hasNumerousPermissionDeniedIssues,

actions/setup/js/copilot_harness.test.cjs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ const {
1717
hasNumerousPermissionDeniedIssues,
1818
INFERENCE_ACCESS_ERROR_PATTERN,
1919
AGENTIC_ENGINE_TIMEOUT_PATTERN,
20+
isDetectionPhase,
2021
isAuthenticationFailedError,
22+
isModelAvailableInReflectData,
23+
isModelAvailableInReflectFile,
2124
enrichReflectModels,
2225
extractModelIds,
2326
fetchAWFReflect,
@@ -897,6 +900,45 @@ describe("copilot_harness.cjs", () => {
897900
});
898901
});
899902

903+
describe("detection model availability helpers", () => {
904+
it("identifies detection phase from GH_AW_PHASE", () => {
905+
expect(isDetectionPhase("detection")).toBe(true);
906+
expect(isDetectionPhase("DETECTION")).toBe(true);
907+
expect(isDetectionPhase("agent")).toBe(false);
908+
expect(isDetectionPhase("")).toBe(false);
909+
});
910+
911+
it("checks model availability from reflect endpoint payload", () => {
912+
const reflectData = {
913+
endpoints: [
914+
{ provider: "copilot", configured: true, models: ["claude-sonnet-4.6", "gpt-5.4"] },
915+
{ provider: "openai", configured: false, models: ["gpt-4.1"] },
916+
],
917+
};
918+
expect(isModelAvailableInReflectData("claude-sonnet-4.6", reflectData)).toBe(true);
919+
expect(isModelAvailableInReflectData("gpt-4.1", reflectData)).toBe(false);
920+
expect(isModelAvailableInReflectData("missing-model", reflectData)).toBe(false);
921+
});
922+
923+
it("reads reflect file and checks model availability", () => {
924+
const reflectFile = path.join(os.tmpdir(), `awf-reflect-${Date.now()}.json`);
925+
try {
926+
fs.writeFileSync(
927+
reflectFile,
928+
JSON.stringify({
929+
endpoints: [{ provider: "copilot", configured: true, models: ["claude-sonnet-4.6"] }],
930+
}),
931+
"utf8"
932+
);
933+
const logs = [];
934+
expect(isModelAvailableInReflectFile("claude-sonnet-4.6", { reflectPath: reflectFile, logger: msg => logs.push(msg) })).toBe(true);
935+
expect(isModelAvailableInReflectFile("gpt-4.1", { reflectPath: reflectFile, logger: msg => logs.push(msg) })).toBe(false);
936+
} finally {
937+
fs.unlinkSync(reflectFile);
938+
}
939+
});
940+
});
941+
900942
describe("enrichReflectModels", () => {
901943
afterEach(() => {
902944
vi.unstubAllGlobals();

actions/setup/js/pi_provider.cjs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,10 +344,7 @@ function piProviderExtension(pi) {
344344
log(
345345
`provider_error provider=${message.provider || "(unknown provider)"} model=${message.model || "(unknown model)"} api=${request.api} status=${status} method=${request.method} url=${request.url} response_headers=${responseHeaders} error=${JSON.stringify(message.errorMessage)}`
346346
);
347-
emitInfrastructureIncompleteIfNoSafeOutputs(
348-
`Pi provider request failed before safe outputs were emitted: ${message.errorMessage}`,
349-
log
350-
);
347+
emitInfrastructureIncompleteIfNoSafeOutputs(`Pi provider request failed before safe outputs were emitted: ${message.errorMessage}`, log);
351348
});
352349

353350
pi.on("agent_start", async () => {

0 commit comments

Comments
 (0)