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
9 changes: 7 additions & 2 deletions .github/workflows/cli-consistency-checker.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 1 addition & 11 deletions actions/setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const {
} = require("./awf_reflect.cjs");
const { emitMissingToolPermissionIssue, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs");
const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs");
const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal } = require("./harness_retry_guard.cjs");
const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal, isAuthenticationFailedError } = require("./harness_retry_guard.cjs");
const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs");

// Pattern to detect Anthropic API overload errors (HTTP 529).
Expand All @@ -65,7 +65,6 @@ const OVERLOADED_ERROR_PATTERN = /overloaded_error|"overloaded"/i;
// - embedded stream-json result fields (e.g. "api_error_status":429)
// - human-readable message text ("rate limit")
const RATE_LIMIT_ERROR_PATTERN = /rate_limit_error|429 Too Many Requests|"api_error_status"\s*:\s*429|request rejected \(429\)|rate limit/i;
const AUTHENTICATION_FAILED_PATTERN = /Authentication failed(?:\s*\(Request ID:[^)]+\))?/i;

// Pattern to detect a clean max-turns exit from Claude Code.
// Claude Code emits a JSON result object with "subtype":"error_max_turns" when the
Expand Down Expand Up @@ -149,15 +148,6 @@ function isRateLimitError(output) {
return RATE_LIMIT_ERROR_PATTERN.test(output);
}

/**
* Determines if the collected output contains an authentication failed error.
* @param {string} output - Collected stdout+stderr from the process
* @returns {boolean}
*/
function isAuthenticationFailedError(output) {
return AUTHENTICATION_FAILED_PATTERN.test(output);
}

/**
* Determines if the collected output signals a clean max-turns exit.
* When Claude Code hits its turn limit it emits a result object with
Expand Down
17 changes: 17 additions & 0 deletions actions/setup/js/claude_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,23 @@ describe("claude_harness.cjs", () => {
expect(isAuthenticationFailedError("Authentication failed (Request ID: C818:3ED713:19D401B:1C446B7:69D653CA)")).toBe(true);
});

it('returns true for Claude Code stream-JSON "error":"authentication_failed" field', () => {
const jsonLine = JSON.stringify({
type: "assistant",
error: "authentication_failed",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test does not isolate the "error" field branch: the JSON payload also embeds "Not logged in" in the text field, so this test passes even if the "error":"authentication_failed" regex arm were deleted.

💡 Suggested fix

Use a neutral string for the text field so only the error key triggers the match:

it('returns true for Claude Code stream-JSON "error":"authentication_failed" field', () => {
  const jsonLine = JSON.stringify({
    type: "assistant",
    error: "authentication_failed",
    // Neutral text — must NOT independently satisfy the pattern
    message: { content: [{ type: "text", text: "Please authenticate to continue." }] },
  });
  expect(isAuthenticationFailedError(jsonLine)).toBe(true);
});

Without this fix there is zero actual coverage for the "error"\s*:\s*"authentication_failed" branch — it can be deleted and all tests still pass.

message: { content: [{ type: "text", text: "Not logged in · Please run /login" }] },
});
expect(isAuthenticationFailedError(jsonLine)).toBe(true);
});

it('returns true for Claude Code "Not logged in" message', () => {
expect(isAuthenticationFailedError("Not logged in · Please run /login")).toBe(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test on line 203 exercises a JSON-serialised line containing "error":"authentication_failed" — great. However, it does not test the negative/boundary case: a JSON line where "error" has a different value (e.g. "rate_limit_error") should not match this pattern.

💡 Suggested negative test
it("does not confuse a different JSON error field with auth failure", () => {
  const jsonLine = JSON.stringify({ type: "error", error: "rate_limit_error" });
  expect(isAuthenticationFailedError(jsonLine)).toBe(false);
});

This guards against regex drift that might accidentally widen the authentication_failed branch to other JSON error values.

@copilot please address this.

});

it('returns true for "not logged in" (case-insensitive)', () => {
expect(isAuthenticationFailedError("NOT LOGGED IN")).toBe(true);
});

describe("isInvalidModelError", () => {
it("returns true for model-not-supported errors", () => {
expect(isInvalidModelError("Execution failed: CAPIError: 400 The requested model is not supported.")).toBe(true);
Expand Down
12 changes: 1 addition & 11 deletions actions/setup/js/codex_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const {
} = require("./awf_reflect.cjs");
const { emitMissingToolPermissionIssue, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs");
const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs");
const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal } = require("./harness_retry_guard.cjs");
const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal, isAuthenticationFailedError } = require("./harness_retry_guard.cjs");
const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs");
const { resolveRetryConfig } = require("./harness_retry_config.cjs");

Expand All @@ -73,7 +73,6 @@ const TOKEN_PER_MIN_RATE_LIMIT_PATTERN = /Rate limit reached for [^\s]+(?: in or
// The backreference \1 requires the two numeric parts of "N/N" to be identical —
// "5/5" matches (exhausted) but "1/5", "3/5", "4/5" do not (still retrying).
const RECONNECT_EXHAUSTED_PATTERN = /Reconnecting\.\.\.\s+(\d+)\/\1\b/;
const AUTHENTICATION_FAILED_PATTERN = /Authentication failed(?:\s*\(Request ID:[^)]+\))?/i;

// Pattern to detect a missing API key at startup — Codex emits this before making any API
// calls when neither CODEX_API_KEY nor OPENAI_API_KEY is available in the environment.
Expand Down Expand Up @@ -115,15 +114,6 @@ function isTokenPerMinuteRateLimitError(output) {
return TOKEN_PER_MIN_RATE_LIMIT_PATTERN.test(output);
}

/**
* Determines if the collected output contains an authentication failed error.
* @param {string} output - Collected stdout+stderr from the process
* @returns {boolean}
*/
function isAuthenticationFailedError(output) {
return AUTHENTICATION_FAILED_PATTERN.test(output);
}

/**
* Determines if the collected output indicates a missing API key at startup.
* Codex exits before producing any agent output in this case, so retrying is futile.
Expand Down
12 changes: 8 additions & 4 deletions actions/setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const {
} = require("./awf_reflect.cjs");
const { runSafeOutputsCLI, buildMissingToolAlternatives, emitMissingToolPermissionIssue, emitInfrastructureIncomplete, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs");
const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs");
const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal } = require("./harness_retry_guard.cjs");
const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, emitSoftTimeoutSignal, isAuthenticationFailedError: isCommonAuthenticationFailedError } = require("./harness_retry_guard.cjs");
const { isCAPIQuotaExceededError } = require("./detect_agent_errors.cjs");
const { loadModelsJson } = require("./model_costs.cjs");
const { resolveConfiguredCopilotModel } = require("./resolve_model_alias.cjs");
Expand Down Expand Up @@ -113,14 +113,16 @@ const NO_AUTH_INFO_PATTERN = /No authentication information found|Session was no
// After a first-attempt auth failure, retrying is futile because the entrypoint unsets
// COPILOT_GITHUB_TOKEN between attempts.
//
// Also matches the Copilot CAPI 400 response emitted when the supplied token is a
// This pattern covers the Copilot CAPI 400 response emitted when the supplied token is a
// Personal Access Token (classic or fine-grained):
// "400 400 checking third-party user token: bad request: Personal Access Tokens
// are not supported for this endpoint"
// PAT rejection is a persistent credential-type problem — retrying with the same
// token always produces the same 400. Treating it as an auth failure short-circuits
// the retry loop instead of burning all 4 attempts.
const AUTHENTICATION_FAILED_PATTERN = /Authentication failed(?:\s*\(Request ID:[^)]+\))?|checking third-party user token:[^\n]*Personal Access Tokens are not supported/i;
// Common auth failure signals ("Authentication failed", "authentication_failed", "not logged in")
// are handled by isCommonAuthenticationFailedError from harness_retry_guard.cjs.
const COPILOT_PAT_AUTH_FAILED_PATTERN = /checking third-party user token:[^\n]*Personal Access Tokens are not supported/i;
// Pattern: Copilot CLI inference access denied
const INFERENCE_ACCESS_ERROR_PATTERN = /Access denied by policy settings|invalid access to inference/;
// Pattern: Agentic engine process killed by signal (timeout)
Expand Down Expand Up @@ -368,11 +370,13 @@ function isNoAuthInfoError(output) {

/**
* Determines if the collected output contains an authentication failed error.
* Covers both the common signals (from harness_retry_guard) and the Copilot-specific
* PAT rejection error.
* @param {string} output - Collected stdout+stderr from the process
* @returns {boolean}
*/
function isAuthenticationFailedError(output) {
return AUTHENTICATION_FAILED_PATTERN.test(output);
return isCommonAuthenticationFailedError(output) || COPILOT_PAT_AUTH_FAILED_PATTERN.test(output);
}

/**
Expand Down
19 changes: 19 additions & 0 deletions actions/setup/js/harness_retry_guard.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ const GOAL_ALREADY_ACTIVE_PATTERNS = [/\bthis thread already has a goal\b[\s\S]*
// ("Maximum LLM invocations exceeded").
const MAX_RUNS_EXCEEDED_PATTERNS = [/\bmax_runs_exceeded\b/i, /Maximum LLM invocations exceeded/i];

// Common authentication failure patterns shared across all harnesses.
// Matches:
// - "Authentication failed (Request ID: ...)" — Anthropic/OpenAI direct auth error
// - `"error":"authentication_failed"` — Claude Code stream-JSON error field (e.g. 401 via AWF proxy)
// - "not logged in" — Claude Code message when no credentials are available
const AUTHENTICATION_FAILED_PATTERNS = [/Authentication failed(?:\s*\(Request ID:[^)]+\))?/i, /"error"\s*:\s*"authentication_failed"/i, /not logged in/i];

/**
* @param {unknown} output
* @returns {boolean}
Expand All @@ -30,6 +37,16 @@ function isMaxRunsExceededError(output) {
return MAX_RUNS_EXCEEDED_PATTERNS.some(pattern => pattern.test(safeOutput));
}

/**
* Determines if the collected output contains an authentication failed error.
* @param {unknown} output
* @returns {boolean}
*/
function isAuthenticationFailedError(output) {
const safeOutput = typeof output === "string" ? output : "";
return AUTHENTICATION_FAILED_PATTERNS.some(pattern => pattern.test(safeOutput));
}

/**
* Detect retry guard conditions that should stop harness retries immediately.
* @param {unknown} output
Expand Down Expand Up @@ -81,7 +98,9 @@ if (typeof module !== "undefined" && module.exports) {
AWF_API_PROXY_BLOCKING_REQUESTS_PATTERNS,
GOAL_ALREADY_ACTIVE_PATTERNS,
MAX_RUNS_EXCEEDED_PATTERNS,
AUTHENTICATION_FAILED_PATTERNS,
isMaxRunsExceededError,
isAuthenticationFailedError,
SOFT_TIMEOUT_BUFFER_MS,
buildSoftTimeoutGuard,
emitSoftTimeoutSignal,
Expand Down
31 changes: 30 additions & 1 deletion actions/setup/js/harness_retry_guard.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, isMaxRunsExceededError } = require("./harness_retry_guard.cjs");
const { detectNonRetryableHarnessGuard, buildSoftTimeoutGuard, isMaxRunsExceededError, isAuthenticationFailedError } = require("./harness_retry_guard.cjs");

describe("harness_retry_guard.cjs", () => {
it("detects AI credits exceeded markers", () => {
Expand Down Expand Up @@ -129,6 +129,35 @@ describe("harness_retry_guard.cjs", () => {
it("isMaxRunsExceededError ignores unrelated output", () => {
expect(isMaxRunsExceededError("transient network timeout")).toBe(false);
});

it("isAuthenticationFailedError returns true for Anthropic-direct auth failure with request ID", () => {
expect(isAuthenticationFailedError("Authentication failed (Request ID: C818:3ED713:19D401B:1C446B7:69D653CA)")).toBe(true);
});

it("isAuthenticationFailedError returns true for bare Authentication failed", () => {
expect(isAuthenticationFailedError("Authentication failed")).toBe(true);
});

it('isAuthenticationFailedError returns true for Claude Code stream-JSON "error":"authentication_failed" field', () => {
const jsonLine = JSON.stringify({ type: "result", error: "authentication_failed" });
expect(isAuthenticationFailedError(jsonLine)).toBe(true);
});

it('isAuthenticationFailedError returns true for Claude Code "not logged in" message (case-insensitive)', () => {
expect(isAuthenticationFailedError("Not logged in · Please run /login")).toBe(true);
expect(isAuthenticationFailedError("NOT LOGGED IN")).toBe(true);
});

it("isAuthenticationFailedError returns false for unrelated output", () => {
expect(isAuthenticationFailedError("No authentication information found")).toBe(false);
expect(isAuthenticationFailedError("rate_limit_error")).toBe(false);
expect(isAuthenticationFailedError("")).toBe(false);
});

it("isAuthenticationFailedError returns false for non-string input", () => {
expect(isAuthenticationFailedError(null)).toBe(false);
expect(isAuthenticationFailedError(undefined)).toBe(false);
});
});

describe("buildSoftTimeoutGuard", () => {
Expand Down
Loading