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
48 changes: 48 additions & 0 deletions .github/actions/apply-claude-managed-settings/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: apply-claude-managed-settings
description: >-
Write a root-owned /etc/claude-code/managed-settings.json that pins
`apiKeyHelper` and `env.ANTHROPIC_BASE_URL` and disables bypassPermissions
mode, so head code writing ~/.claude/settings.json cannot repoint the
lane's credential (for example through ANTHROPIC_BASE_URL or a helper that
outranks CLAUDE_CODE_OAUTH_TOKEN). Needs sudo: run it before
runner isolation removes sudo and before any head checkout. run-claude-cli
refuses to run without the file.

inputs:
settings:
description: >-
The managed settings JSON. It must be an object that sets
`apiKeyHelper`, `env.ANTHROPIC_BASE_URL` to `https://api.anthropic.com`
and `permissions.disableBypassPermissionsMode` to `disable`;
run-claude-cli refuses anything else.
required: false
default: >-
{"apiKeyHelper": "", "allowManagedHooksOnly": true,
"env": {"ANTHROPIC_BASE_URL": "https://api.anthropic.com"},
"permissions": {"disableBypassPermissionsMode": "disable"}}

runs:
using: composite
steps:
- name: Write the managed settings
shell: bash
env:
SETTINGS: ${{ inputs.settings }}
run: |
node -e '
const s = JSON.parse(process.env.SETTINGS);
if (typeof s !== "object" || s === null || Array.isArray(s) ||
!Object.hasOwn(s, "apiKeyHelper") ||
s.env?.ANTHROPIC_BASE_URL !== "https://api.anthropic.com" ||
s.permissions?.disableBypassPermissionsMode !== "disable") {
throw new Error("settings must set apiKeyHelper, env.ANTHROPIC_BASE_URL https://api.anthropic.com and permissions.disableBypassPermissionsMode disable");
}
'
if ! sudo -n true 2>/dev/null; then
echo "::error title=apply-claude-managed-settings::sudo is unavailable: run apply-claude-managed-settings before runner isolation"
exit 1
fi
sudo install -d -o root -g root -m 755 /etc/claude-code
printf '%s\n' "$SETTINGS" | sudo tee /etc/claude-code/managed-settings.json >/dev/null
sudo chown root:root /etc/claude-code/managed-settings.json
sudo chmod 644 /etc/claude-code/managed-settings.json
97 changes: 94 additions & 3 deletions .github/actions/report-lane-outcome/classify.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Project the last claude-code-action SDK result message into safe, structured
// metadata and classify a genuine infrastructure failure into exactly one coarse
// class: auth | rate-limit | overloaded | max-turns | timeout |
// install-failed | other. Reads
// install-failed | other, plus credit-stop for a caller that opts in (see
// isCreditStop). Reads
// the action's execution_file output (which may be unset, missing, or
// unparsable) and returns the review detail plus failure class.
//
Expand Down Expand Up @@ -103,9 +104,87 @@ function readMessages(executionFilePath) {
}
}

// credit-stop: an API-key credential whose Console organization has no credit
// left or has hit a spend limit, judged only where no model output exists, so
// the error text read below is the CLI's own. Opt-in through
// `detectCreditStop`; existing callers never see this class.
//
// Proof that the text is the CLI's: the run made no tool call and every
// assistant message carries the SDK's `error` field (a synthesized API-error
// message), and the result reports 0 output tokens. Then a credit-stop
// status (400 spend limit, 402 billing, 429 tier spend cap) or SDK error
// category must be present, the exit code non-zero, and the text must match
// one documented credit-stop phrase. A real workspace spend-limit stop matches
// (the observed test in classify.test.cjs); a zero-balance stop is not yet
// observed. This function is the one place to change.
const CREDIT_STOP_STATUSES = new Set([400, 402, 429]);
const CREDIT_STOP_ERRORS = new Set([
"billing_error",
"rate_limit",
"invalid_request",
]);
const CREDIT_STOP_PHRASES = [
/credit balance is too low/iu,
/you have reached your specified (?:workspace )?api usage limits/iu,
/you have reached your api usage limits/iu,
/enforced_spend_limit_reached/u,
];
const REGAIN_ACCESS =
/regain access on (\d{4}-\d{2}-\d{2}) at (\d{2}):(\d{2}) UTC/u;

function creditStopText(messages) {
const result = messages.findLast((message) => message?.type === "result");
if (result?.is_error !== true) return null;
const assistants = messages.filter((m) => m?.type === "assistant");
const content = assistants.flatMap((m) =>
Array.isArray(m.message?.content) ? m.message.content : [],
);
if (content.some((block) => block?.type === "tool_use")) return null;
if (assistants.some((m) => typeof m.error !== "string")) return null;
const outputTokens = result.usage?.output_tokens;
if (outputTokens !== undefined && outputTokens !== 0) return null;
const status = result.api_error_status;
const statusMatches =
typeof status === "number" && CREDIT_STOP_STATUSES.has(status);
const errorMatches = assistants.some((m) => CREDIT_STOP_ERRORS.has(m.error));
if (!statusMatches && !errorMatches) return null;
const text = [
typeof result.result === "string" ? result.result : "",
...(Array.isArray(result.errors) ? result.errors : []).map(toStringLikeJq),
...content
.filter((block) => block?.type === "text")
.map((block) => String(block.text ?? "")),
].join("\n");
return CREDIT_STOP_PHRASES.some((phrase) => phrase.test(text)) ? text : null;
}

function isCreditStop(messages, { exitCode = null } = {}) {
if (!Array.isArray(messages)) return false;
if (typeof exitCode === "number" && exitCode === 0) return false;
return creditStopText(messages) !== null;
}

// The time a tier spend cap lifts, from the documented "You will regain
// access on YYYY-MM-DD at HH:MM UTC." sentence of a credit stop; else null.
function creditStopResetsAt(messages) {
const text = Array.isArray(messages) ? creditStopText(messages) : null;
const match = text?.match(REGAIN_ACCESS);
if (!match) return null;
const date = new Date(`${match[1]}T${match[2]}:${match[3]}:00Z`);
return Number.isNaN(date.getTime())
? null
: date.toISOString().replace(/\.000Z$/u, "Z");
}

function classifyExecutionFile(
executionFilePath,
{ timedOut = false, maxTurns = "", installFailed = false } = {},
{
timedOut = false,
maxTurns = "",
installFailed = false,
detectCreditStop = false,
exitCode = null,
} = {},
) {
const fallbackClass = timedOut ? "timeout" : "other";
if (!executionFilePath || !fs.existsSync(executionFilePath)) {
Expand All @@ -131,6 +210,11 @@ function classifyExecutionFile(
let errorType = null;
if (timedOut) {
failureClass = "timeout";
} else if (
detectCreditStop &&
isCreditStop(readMessages(executionFilePath), { exitCode })
) {
failureClass = "credit-stop";
} else if (typeof status === "number") {
failureClass = classFromStatus(status);
} else if (
Expand Down Expand Up @@ -243,4 +327,11 @@ function recordTokens(
return tokens.join(" ");
}

module.exports = { classifyExecutionFile, readRunRecord, recordTokens };
module.exports = {
classifyExecutionFile,
creditStopResetsAt,
isCreditStop,
readMessages,
readRunRecord,
recordTokens,
};
Loading
Loading