Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion setup/js/cancel_work_queue_closed_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const { actorFromContext } = require("./work_queue_policy.cjs");
const { newRequest, generateRequestOperations } = require("./work_queue_replay.cjs");
const { publishWorkQueueRequest } = require("./work_queue_store.cjs");
const { readCheckedQueue } = require("./work_queue_checked_transport.cjs");
const { expectedGitHubHost } = require("./work_queue_host.cjs");

const REASON = "related_issue_closed";

Expand Down Expand Up @@ -74,7 +75,7 @@ async function main(options = {}) {
const number = Number(nativeId(eventIssue.number));
const resource = {
kind: "issue",
host: "github.com",
host: expectedGitHubHost(),
repository,
repository_id: repoId,
resource_id: nativeId(eventIssue.id),
Expand Down
18 changes: 17 additions & 1 deletion setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,21 @@ function removeClaudeRoutingOverrides(args) {
return result;
}

/**
* Append --settings from GH_AW_CLAUDE_SETTINGS when the caller passed no --settings.
* The external threat-detect binary builds its own Claude Code arguments, so the
* compiler hands the Copilot modelOverrides map to Claude Code through this variable.
* @param {string[]} args
* @param {NodeJS.ProcessEnv} [env]
* @returns {string[]}
*/
function applyClaudeSettingsEnv(args, env = process.env) {
const settings = (env.GH_AW_CLAUDE_SETTINGS || "").trim();
if (!settings) return args;
if (args.some(arg => arg === "--settings" || arg.startsWith("--settings="))) return args;
return [...args, "--settings", settings];
}

/**
* Main entry point: run claude with retry logic for transient API failures.
*/
Expand All @@ -485,7 +500,7 @@ async function main() {
const resolved = resolveClaudePromptFileArgs(args);
prompt = resolved.prompt;
const capabilities = claudeBareCapabilities(claudeRepositoryEditPolicy(stripContinueArgs(resolved.args), process.env));
initialArgs = capabilities.args;
initialArgs = applyClaudeSettingsEnv(capabilities.args, process.env);
pluginDir = capabilities.pluginDir;
} catch (err) {
const e = /** @type {Error} */ err;
Expand Down Expand Up @@ -844,6 +859,7 @@ if (typeof module !== "undefined" && module.exports) {
resolveClaudeModelRouting,
buildClaudeChildEnv,
removeClaudeRoutingOverrides,
applyClaudeSettingsEnv,
};
}

Expand Down
123 changes: 108 additions & 15 deletions setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ const COPILOT_ROUTING_POLICY = getAWFModelRoutingPolicy("copilot");
const { loadModelsJson } = require("./model_costs.cjs");
const { applyCopilotWireAPI: configureCopilotWireAPI, warnCopilotSubagentEndpoints, recordModelEndpointMismatch, getSupportedEndpoints, MODEL_ENDPOINT_FIX } = require("./copilot_wire_api.cjs");
const { resolveConfiguredCopilotModel, ModelAliasResolutionError } = require("./resolve_model_alias.cjs");
const { createCopilotModelObserver, recordCopilotModelSelection } = require("./copilot_model_selection.cjs");
const MODEL_NOT_SUPPORTED_PATTERN = /The requested model is not supported|No model available\b[^\n]*policy enablement/i;
const { parseAICreditsErrorInfoFromAuditLog, parseMaxAICreditsFromAuditLog, parseMaxAICreditsExceededFromAuditLog, parseAPIProxyGuardRejectionFromEventLog, formatAPIProxyGuardRejection } = require("./ai_credits_context.cjs");

const AWF_CONFIG_PATH = process.env.GH_AW_AWF_CONFIG_PATH || "/tmp/gh-aw/awf-config.json";
Expand Down Expand Up @@ -147,18 +149,6 @@ const HTTP_400_RESPONSE_ERROR_PATTERN =
// This is a persistent policy configuration error — retrying will not help.
const MCP_POLICY_BLOCKED_PATTERN = /MCP servers were blocked by policy:/;

// Pattern to detect "model not supported" errors (e.g. a model unavailable to the
// current token or repository context).
// Also matches the Copilot SDK driver's policy-enablement error, which is emitted when a model
// (commonly the one requested by a subagent / `task` dispatch) is disabled by the org/repo
// Copilot policy:
// "[copilot-sdk-driver] [sdk-driver] error: Execution failed: Error: No model available.
// Check policy enablement under GitHub Settings > Copilot"
// The alternative is anchored to the "policy enablement" phrase so that the generic
// "No model available" wording alone does not produce false positives.
// Retrying the same session with --continue will not change model availability.
const MODEL_NOT_SUPPORTED_PATTERN = /The requested model is not supported|No model available\b[^\n]*policy enablement/i;

// Pattern to detect missing authentication credentials.
// On a --continue attempt this may indicate that the Copilot CLI's on-disk session
// credential (written by a mid-stream interrupted run) is incomplete or invalid. In that
Expand Down Expand Up @@ -345,6 +335,79 @@ function isModelNotSupportedError(output) {
return MODEL_NOT_SUPPORTED_PATTERN.test(output);
}

/**
* Resolve native command-line model overrides before alias and endpoint inference.
* @param {string[]} args
* @param {string} defaultModel
*/
function resolveCopilotInvocationModel(args, defaultModel) {
let model = defaultModel;
let hasOverride = false;
for (let i = 0; i < args.length && args[i] !== "--"; i++) {
if (args[i] === "--prompt" || args[i] === "-p") {
i++;
continue;
}
if (args[i] !== "--model" && !args[i].startsWith("--model=")) continue;
const value = args[i] === "--model" ? args[++i] : args[i].slice("--model=".length);
if (!value?.trim() || value.startsWith("-")) throw new Error("Copilot --model requires a non-empty model value");
if (hasOverride && model !== value.trim()) throw new Error("Conflicting Copilot --model arguments; specify one invocation model");
model = value.trim();
hasOverride = true;
}
return { model, hasOverride };
}

/** @param {string[]} args @param {string} resolvedModel */
function applyCopilotInvocationModel(args, resolvedModel) {
const result = [];
let options = true;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--") options = false;
if (options && (args[i] === "--prompt" || args[i] === "-p")) {
result.push(args[i], args[++i]);
continue;
}
if (options && args[i] === "--model") {
result.push("--model", resolvedModel);
i++;
} else result.push(options && args[i].startsWith("--model=") ? `--model=${resolvedModel}` : args[i]);
}
return result;
}

/** Bind native observation and retries to an exact CLI session, never shared-HOME recency.
* The pinned CLI's --session-id resumes existing UUIDs and creates only missing UUIDs.
* @param {string[]} args
* @param {string | undefined} continuedSessionId
*/
function prepareCopilotModelSession(args, continuedSessionId) {
let sessionId = "";
let selector = false;
for (let i = 0; i < args.length && args[i] !== "--"; i++) {
if (args[i] === "--prompt" || args[i] === "-p") {
i++;
continue;
}
if (args[i] === "--continue" || args[i] === "--connect" || args[i].startsWith("--connect=")) {
throw new Error("Copilot model verification requires an exact --session-id UUID instead of --continue or --connect");
}
if (["--session-id", "--resume", "-r"].includes(args[i]) || /^(?:--session-id|--resume)=/.test(args[i])) {
if (selector) throw new Error("Conflicting Copilot session selectors; specify one exact --session-id UUID");
selector = true;
sessionId = args[i].includes("=") ? args[i].slice(args[i].indexOf("=") + 1) : args[++i];
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(sessionId || "")) {
throw new Error("Copilot model verification requires an exact session UUID; resume names and ID prefixes are ambiguous");
}
}
}
if (selector) return { args, sessionId };
sessionId = continuedSessionId || require("crypto").randomUUID();
const delimiter = args.indexOf("--");
const position = delimiter < 0 ? args.length : delimiter;
return { args: [...args.slice(0, position), "--session-id", sessionId, ...args.slice(position)], sessionId };
}

/**
* Determine whether the current run phase is threat detection.
* @param {string | undefined | null} phase
Expand Down Expand Up @@ -445,7 +508,11 @@ async function applyCopilotModelAliasResolution(options) {
if (!(retryErr instanceof ModelAliasResolutionError)) {
throw retryErr;
}
logger(`copilot model alias resolution failed: model-catalog retrieval prevented alias resolution for '${configuredModel}' after a bounded refresh — refusing to start Copilot with an unresolved alias`);
const failure = err.reason === "model_unavailable" ? err : retryErr;
logger(`copilot model alias resolution failed: ${failure.message} after a bounded refresh — refusing to start Copilot with an unresolved alias`);
if (failure.reason === "model_unavailable") {
recordCopilotModelSelection({ status: "unavailable", configured_model: configuredModel, resolved_model: "", effective_model: "" });
}
process.exit(1);
return configuredModel; // unreachable, keeps TypeScript control-flow analysis happy
}
Expand Down Expand Up @@ -1214,6 +1281,8 @@ async function main() {
// Skip when AWF_REFLECT_ENABLED is not "1" (e.g. sandbox.agent: false — no api-proxy running).
/** @type {any} */
let awfReflectData = null;
const invocationModel = copilotSDKMode ? null : resolveCopilotInvocationModel(resolvedArgs, process.env.COPILOT_MODEL || "");
if (invocationModel?.hasOverride) process.env.COPILOT_MODEL = invocationModel.model;
const configuredCopilotModel = process.env.COPILOT_MODEL || "";
const configuredWireAPI = process.env.COPILOT_PROVIDER_WIRE_API;
const modelRoutingRequired = process.env.GH_AW_MODEL_ROUTING === "1";
Expand Down Expand Up @@ -1252,6 +1321,7 @@ async function main() {
return refreshed.ok && refreshed.reflectData ? refreshed.reflectData : null;
},
});
if (invocationModel?.hasOverride) resolvedArgs = applyCopilotInvocationModel(resolvedArgs, process.env.COPILOT_MODEL || "");
}
if (!copilotSDKMode) {
try {
Expand Down Expand Up @@ -1384,6 +1454,8 @@ async function main() {
let scheduledExit2Retries = 0;
let scheduledExit2RetryAttempted = false;
let useContinueOnRetry = false;
/** @type {string | undefined} */
let nativeModelSessionId;
let modelNotSupportedReflectRetryAttempted = false;
// Once set to true, --continue is never re-enabled for the remainder of this run.
// This prevents a broken --continue recovery from resurrecting --continue on the next attempt.
Expand Down Expand Up @@ -1446,11 +1518,20 @@ async function main() {
softTimeoutGuard,
getRetryMode: () => (!copilotSDKMode && useContinueOnRetry ? "--continue" : "fresh run"),
runAttempt: async attempt => {
// Add --continue flag on CLI retries so the copilot session continues from where it left off
const currentArgs = !copilotSDKMode && attempt > 0 && useContinueOnRetry ? [...resolvedArgs, "--continue"] : resolvedArgs;
// Verified native retries resume the exact UUID; other CLI paths retain --continue.
let currentArgs = !copilotSDKMode && attempt > 0 && useContinueOnRetry ? [...resolvedArgs, "--continue"] : resolvedArgs;
const verifyModel = !copilotSDKMode && (!process.env.GH_AW_PHASE || process.env.GH_AW_PHASE === "agent") && Boolean(process.env.COPILOT_MODEL);
let sessionId;
if (verifyModel) {
const session = prepareCopilotModelSession(resolvedArgs, attempt > 0 && useContinueOnRetry ? nativeModelSessionId : undefined);
currentArgs = session.args;
sessionId = session.sessionId;
nativeModelSessionId = sessionId;
}

// Redact --prompt / -p value from logs to avoid leaking prompt content
const safeArgs = currentArgs.map((arg, i) => (currentArgs[i - 1] === "--prompt" || currentArgs[i - 1] === "-p" ? "<redacted>" : arg));
const modelObserver = !verifyModel ? { check: () => "" } : createCopilotModelObserver({ configuredModel: configuredCopilotModel, resolvedModel: process.env.COPILOT_MODEL || "", sessionId, logger: log });
// Driver mode: run copilot_sdk_driver.cjs as a normal subprocess. The harness has
// already started the sidecar; the driver only opens an SDK client connection.
const result = await runProcess({
Expand All @@ -1461,13 +1542,20 @@ async function main() {
logArgs: safeArgs,
env: childEnv,
stdin: promptStdin,
runtimeGuard: { shouldTerminate: () => ({ terminate: Boolean(modelObserver.check()), reason: "copilot_model_verification" }) },
postResultWatchdog: safeOutputsPath
? {
shouldArm: () => hasTerminalSafeOutput(safeOutputsPath),
inactivityTimeoutMs: POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS,
}
: undefined,
});
const modelSubstitution = modelObserver.check(true);
if (modelSubstitution) {
result.exitCode = 1;
result.runtimeGuardReason = "copilot_model_verification";
result.output += `\n${modelSubstitution}`;
}
lastHasOutput = result.hasOutput;
const attemptDetections = detectCopilotErrors(result.output);
detectedCopilotErrors.inferenceAccessError ||= attemptDetections.inferenceAccessError;
Expand All @@ -1478,6 +1566,7 @@ async function main() {
return result;
},
handleFailure: async ({ attempt, result }) => {
if (result.runtimeGuardReason === "copilot_model_verification") return { action: "stop" };
// Determine whether to retry.
// Retry whenever the session was partially executed (hasOutput).
// - CLI mode: retry with --continue so the Copilot CLI can continue from on-disk state.
Expand Down Expand Up @@ -1890,6 +1979,10 @@ if (typeof module !== "undefined" && module.exports) {
hasNoopInSafeOutputs,
hasExpectedSafeOutputs,
isDetectionPhase,
isModelNotSupportedError,
resolveCopilotInvocationModel,
applyCopilotInvocationModel,
prepareCopilotModelSession,
computeStartupRetryEligible,
isHTTP400ResponseError,
isToolCallIdSchemaError,
Expand Down
128 changes: 128 additions & 0 deletions setup/js/copilot_model_selection.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// @ts-check
"use strict";

const fs = require("fs");
const path = require("path");
const os = require("os");
const { validateModelIdentifier } = require("./model_attribution.cjs");
const { normalizeForCopilotCLI, splitModelIdentifier } = require("./resolve_model_alias.cjs");
const { copilotSessionContext } = require("./copilot_session.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");

function modelID(value) {
return validateModelIdentifier(normalizeForCopilotCLI(splitModelIdentifier(String(value || "")).base));
}

function recordCopilotModelSelection(selection, env = process.env) {
if (env.GH_AW_PHASE && env.GH_AW_PHASE !== "agent") return;
const file = path.join(env.GH_AW_TMP_DIR || "/tmp/gh-aw", "aw_info.json");
try {
const info = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
info.requested_model ||= selection.configured_model;
info.model = selection.effective_model;
info.model_selection = selection;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(info, null, 2));
} catch (error) {
throw new Error(`Cannot record Copilot model selection: ${getErrorMessage(error)}`, { cause: error });
}
}

/**
* Observe only the invocation's exact session and newly appended records, not other sessions or subagents.
* Native CLI startup fallback is not an authorized AWF fallback.
* @param {{ configuredModel: string, resolvedModel: string, sessionId?: string, env?: NodeJS.ProcessEnv, sessionDirs?: string[], logger?: (message: string) => void }} options
*/
function createCopilotModelObserver({ configuredModel, resolvedModel, sessionId, env = process.env, sessionDirs, logger = () => {} }) {
const root = env.GH_AW_TMP_DIR || "/tmp/gh-aw";
const directories = sessionDirs || [
path.join(env.COPILOT_HOME || path.join(env.HOME || os.homedir(), ".copilot"), "session-state"),
path.join(root, "sandbox/agent/session-state"),
path.join(root, "sandbox/agent/logs/copilot-session-state"),
];
/** @type {Map<string, number>} */
const offsets = new Map();
/** @type {Map<string, string>} */
const partial = new Map();
let diagnostic = "";
let observedModel = "";
const expected = modelID(resolvedModel);
if (!expected || (env.GH_AW_PHASE && env.GH_AW_PHASE !== "agent")) return { check: () => "" };
if (!sessionId || !/^[a-z0-9_-]+$/i.test(sessionId)) throw new Error("Copilot model verification requires an exact invocation session ID");
const ownedSessionId = sessionId;

function eventFiles() {
const files = new Set();
for (const directory of directories) {
const file = path.join(directory, ownedSessionId, "events.jsonl");
try {
fs.statSync(file);
} catch (error) {
if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
throw error;
}
files.add(file);
}
return files;
}

try {
for (const file of eventFiles()) offsets.set(file, fs.statSync(file).size);
} catch (error) {
throw new Error(`Cannot initialize Copilot model verification: ${getErrorMessage(error)}`, { cause: error });
}

function check(final = false) {
try {
if (diagnostic) return diagnostic;
for (const file of eventFiles()) {
const size = fs.statSync(file).size;
let offset = offsets.get(file) || 0;
if (size < offset) {
offset = 0;
partial.delete(file);
}
const content = Buffer.alloc(size - offset);
const fd = fs.openSync(file, "r");
let bytesRead;
try {
bytesRead = fs.readSync(fd, content, 0, content.length, offset);
Comment on lines +86 to +89
} finally {
fs.closeSync(fd);
}
const text = (partial.get(file) || "") + content.subarray(0, bytesRead).toString("utf8");
offsets.set(file, offset + bytesRead);
const lines = text.split("\n");
partial.set(file, final ? "" : lines.pop() || "");
for (const line of lines) {
let event;
try {
event = JSON.parse(line);
} catch {
continue; // Native logs can contain incomplete or non-JSON records.
}
const context = copilotSessionContext(event);
if (context.agentId !== undefined || context.parentToolUseId || (context.sessionId !== undefined && context.sessionId !== ownedSessionId)) continue;
const model = modelID(event.type === "session.model_change" && event.data?.source === "startup" ? event.data.newModel : event.type === "assistant.message" ? event.data?.model : "");
if (!model || model === observedModel) continue;
observedModel = model;
const substituted = expected.toLowerCase() !== "auto" && model.toLowerCase() !== expected.toLowerCase();
recordCopilotModelSelection({ status: substituted ? "substituted" : "observed", configured_model: configuredModel, resolved_model: resolvedModel, effective_model: model }, env);
if (substituted) {
diagnostic = `Model '${expected}' is not available as configured: Copilot substituted '${model}'. Refusing silent model substitution. Select a model offered to this workflow's agentic-workflows integration; do not change the integration ID to bypass model access policy.`;
logger(diagnostic);
return diagnostic;
}
}
}
return "";
} catch (error) {
diagnostic = `Copilot model verification failed: ${getErrorMessage(error)}. Refusing to report an unverified run as successful.`;
logger(diagnostic);
return diagnostic;
}
}
return { check };
}

module.exports = { createCopilotModelObserver, recordCopilotModelSelection };
Loading
Loading