fix(agents): bypass stale auth for plugin harnesses

Explicit non-Codex plugin harness runtimes now bypass stale OpenClaw provider auth cooldowns before harness startup, while Codex/OpenClaw and missing-harness gates remain fail-closed. Fixes #85105.
This commit is contained in:
Peter Steinberger
2026-06-01 23:22:54 -04:00
committed by GitHub
parent 68b4dd1816
commit b7d363cadf
2 changed files with 84 additions and 22 deletions

View File

@@ -21,6 +21,8 @@ import type { EmbeddedAgentRunResult } from "./embedded-agent-runner/types.js";
import { FailoverError } from "./failover-error.js";
import { resetFallbackSkipCacheForTest } from "./fallback-skip-cache.js";
import { MissingAgentHarnessError } from "./harness/errors.js";
import { clearAgentHarnesses, registerAgentHarness } from "./harness/registry.js";
import type { AgentHarness } from "./harness/types.js";
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
import {
FallbackSummaryError,
@@ -191,6 +193,7 @@ afterAll(() => {
function resetModelFallbackTestState(): void {
resetFallbackSkipCacheForTest();
clearAgentHarnesses();
authRuntimeMock.clear();
authRuntimeMock.runtime.ensureAuthProfileStore.mockClear();
authRuntimeMock.runtime.loadAuthProfileStoreForRuntime.mockClear();
@@ -971,6 +974,68 @@ describe("runWithModelFallback", () => {
expect(run).not.toHaveBeenCalled();
});
it("lets external plugin harnesses bypass stale provider auth cooldowns", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-6",
fallbacks: ["openai/gpt-5.5"],
},
models: {
"anthropic/*": { agentRuntime: { id: "claude-tmux" } },
},
},
},
});
registerAgentHarness(
{
id: "claude-tmux",
label: "Claude tmux",
supports: ({ provider }) =>
provider === "anthropic" ? { supported: true } : { supported: false },
runAttempt: vi.fn<AgentHarness["runAttempt"]>(async () => {
throw new Error("fallback test should not invoke the harness runtime");
}),
},
{ ownerPluginId: "claude-tmux-test" },
);
const tempDir = await makeAuthTempDir();
setAuthRuntimeStore(tempDir, {
version: AUTH_STORE_VERSION,
profiles: {
"anthropic:default": { type: "api_key", provider: "anthropic", key: "test-key" },
"openai:default": { type: "api_key", provider: "openai", key: "test-key" },
},
usageStats: {
"anthropic:default": {
disabledUntil: Date.now() + 60_000,
disabledReason: "billing",
failureCounts: { rate_limit: 4 },
},
},
});
const run = vi.fn().mockImplementation(async (provider: string) => {
if (provider === "anthropic") {
return "external cli ok";
}
throw new Error(`unexpected provider: ${provider}`);
});
const result = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-sonnet-4-6",
agentDir: tempDir,
run,
});
expect(result.result).toBe("external cli ok");
expect(run).toHaveBeenCalledTimes(1);
expect(run.mock.calls[0]).toEqual(["anthropic", "claude-sonnet-4-6"]);
expect(result.attempts).toStrictEqual([]);
});
it("lets configured CLI runtimes reach the run callback", async () => {
const cfg = makeCfg({
agents: {
@@ -1781,9 +1846,9 @@ describe("runWithModelFallback", () => {
{ provider: "openai", model: "gpt-4.1-mini" },
{ provider: "tui-pty-mock", model: "gpt-5.5" },
]);
expect(providerModelNormalizationMock.normalizeProviderModelIdWithRuntime).not.toHaveBeenCalledWith(
expect.objectContaining({ provider: "tui-pty-mock" }),
);
expect(
providerModelNormalizationMock.normalizeProviderModelIdWithRuntime,
).not.toHaveBeenCalledWith(expect.objectContaining({ provider: "tui-pty-mock" }));
});
it("keeps configured fallbacks before configured primary for duplicate provider model ids", () => {

View File

@@ -439,18 +439,18 @@ function isCliAgentRuntime(runtime: string | undefined, cfg: OpenClawConfig | un
return isCliRuntimeAlias(normalized) || isCliProvider(normalized, cfg);
}
async function assertModelFallbackCandidateHarnessAvailable(
async function resolveModelFallbackCandidateHarnessAuthPrecheck(
params: ModelFallbackRuntimeContext & ModelCandidate,
): Promise<void> {
): Promise<{ skipsProviderAuthCooldown: boolean }> {
if (!params.cfg) {
return;
return { skipsProviderAuthCooldown: false };
}
const agentHarnessRuntimeOverride = params.resolveAgentHarnessRuntimeOverride?.(
params.provider,
params.model,
);
if (isCliProvider(params.provider, params.cfg)) {
return;
return { skipsProviderAuthCooldown: false };
}
const agentRuntimeOverride = normalizeOptionalAgentRuntimeId(agentHarnessRuntimeOverride);
const harnessPolicy = resolveAgentHarnessPolicy({
@@ -469,28 +469,25 @@ async function assertModelFallbackCandidateHarnessAvailable(
? "model"
: harnessPolicy.runtimeSource;
if (isCliAgentRuntime(agentRuntime, params.cfg)) {
return;
return { skipsProviderAuthCooldown: false };
}
if (
agentRuntime === "auto" ||
agentRuntime === "openclaw" ||
(agentRuntime === "codex" && agentRuntimeSource === "implicit")
) {
return;
if (agentRuntime === "openclaw") {
return { skipsProviderAuthCooldown: false };
}
if (agentRuntime === "auto" || (agentRuntime === "codex" && agentRuntimeSource === "implicit")) {
return { skipsProviderAuthCooldown: false };
}
await params.prepareAgentHarnessRuntime?.({
provider: params.provider,
model: params.model,
agentHarnessRuntimeOverride,
});
if (
agentRuntime !== "auto" &&
agentRuntime !== "openclaw" &&
!(agentRuntime === "codex" && agentRuntimeSource === "implicit") &&
!getRegisteredAgentHarness(agentRuntime)
) {
if (!getRegisteredAgentHarness(agentRuntime)) {
throw new MissingAgentHarnessError(agentRuntime);
}
// Explicit non-Codex plugin harnesses own transport/auth; stale OpenClaw
// provider cooldowns must not block the harness before it starts.
return { skipsProviderAuthCooldown: agentRuntime !== "codex" };
}
function resolveCandidateAttemptError(
@@ -1275,7 +1272,7 @@ export async function runWithModelFallback<T>(
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i];
await assertModelFallbackCandidateHarnessAvailable({
const candidateHarnessAuth = await resolveModelFallbackCandidateHarnessAuthPrecheck({
cfg: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
@@ -1342,7 +1339,7 @@ export async function runWithModelFallback<T>(
let runOptions: ModelFallbackRunOptions | undefined;
let attemptedDuringCooldown = false;
let transientProbeProviderForAttempt: string | null = null;
if (authRuntime && authStore) {
if (authRuntime && authStore && !candidateHarnessAuth.skipsProviderAuthCooldown) {
const profileIds = authRuntime.resolveAuthProfileOrder({
cfg: params.cfg,
store: authStore,