diff --git a/CHANGELOG.md b/CHANGELOG.md index 07455c62481..59a990684a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenRouter: preserve stored session provider when model IDs are vendor-prefixed (for example, `anthropic/...`) so follow-up turns do not incorrectly route to direct provider APIs. (#22753) Thanks @dndodson. - Providers/OpenRouter: preserve the required `openrouter/` prefix for OpenRouter-native model IDs during model-ref normalization. (#12942) Thanks @omair445. - Providers/OpenRouter: pass through provider routing parameters from model params.provider to OpenRouter request payloads for provider selection controls. (#17148) Thanks @carrotRakko. +- Cron/Auth: propagate auth-profile resolution to isolated cron sessions so provider API keys are resolved the same way as main sessions, fixing 401 errors when using providers configured via auth-profiles. (#20689) Thanks @lailoo. - Telegram/Webhook: keep webhook monitors alive until gateway abort signals fire, preventing false channel exits and immediate webhook auto-restart loops. - Telegram/Polling: retry recoverable setup-time network failures in monitor startup and await runner teardown before retry to avoid overlapping polling sessions. - Telegram/Polling: clear Telegram webhooks (`deleteWebhook`) before starting long-poll `getUpdates`, including retry handling for transient cleanup failures. diff --git a/src/cron/isolated-agent.auth-profile-propagation.test.ts b/src/cron/isolated-agent.auth-profile-propagation.test.ts new file mode 100644 index 00000000000..4e4539f6316 --- /dev/null +++ b/src/cron/isolated-agent.auth-profile-propagation.test.ts @@ -0,0 +1,117 @@ +import "./isolated-agent.mocks.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js"; +import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; + +describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => { + beforeEach(() => { + setupIsolatedAgentTurnMocks({ fast: true }); + }); + + it("passes authProfileId to runEmbeddedPiAgent when auth profiles exist", async () => { + await withTempCronHome(async (home) => { + // 1. Write session store + const sessionsDir = path.join(home, ".openclaw", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const storePath = path.join(sessionsDir, "sessions.json"); + await fs.writeFile( + storePath, + JSON.stringify( + { + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now(), + lastProvider: "webchat", + lastTo: "", + }, + }, + null, + 2, + ), + "utf-8", + ); + + // 2. Write auth-profiles.json in the agent directory + // resolveAgentDir returns /agents/main/agent + // stateDir = /.openclaw + const agentDir = path.join(home, ".openclaw", "agents", "main", "agent"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify({ + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-test-key-12345", + }, + }, + order: { + openrouter: ["openrouter:default"], + }, + }), + "utf-8", + ); + + // 3. Mock runEmbeddedPiAgent to return ok + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "done" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "openrouter", model: "kimi-k2.5" }, + }, + }); + + // 4. Run cron isolated agent turn with openrouter model + const cfg = makeCfg(home, storePath, { + agents: { + defaults: { + model: { primary: "openrouter/moonshotai/kimi-k2.5" }, + workspace: path.join(home, "openclaw"), + }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg, + deps: { + sendMessageSlack: vi.fn(), + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }, + job: makeJob({ kind: "agentTurn", message: "check status", deliver: false }), + message: "check status", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(vi.mocked(runEmbeddedPiAgent)).toHaveBeenCalledTimes(1); + + // 5. Check that authProfileId was passed + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0] as { + authProfileId?: string; + authProfileIdSource?: string; + }; + + console.log(`authProfileId passed to runEmbeddedPiAgent: ${callArgs?.authProfileId}`); + console.log(`authProfileIdSource passed: ${callArgs?.authProfileIdSource}`); + + if (!callArgs?.authProfileId) { + console.log("❌ BUG CONFIRMED: isolated cron session does NOT pass authProfileId"); + console.log(" This causes 401 errors when using providers that require auth profiles"); + } + + // This assertion will FAIL on main — proving the bug + expect(callArgs?.authProfileId).toBe("openrouter:default"); + }); + }); +}); diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts index fad50f77d81..f37b08747d1 100644 --- a/src/cron/isolated-agent/run.skill-filter.test.ts +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -32,14 +32,20 @@ vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }), })); -vi.mock("../../agents/model-selection.js", () => ({ - getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }), - isCliProvider: vi.fn().mockReturnValue(false), - resolveAllowedModelRef: vi.fn().mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }), - resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }), - resolveHooksGmailModel: vi.fn().mockReturnValue(null), - resolveThinkingDefault: vi.fn().mockReturnValue(undefined), -})); +vi.mock("../../agents/model-selection.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }), + isCliProvider: vi.fn().mockReturnValue(false), + resolveAllowedModelRef: vi + .fn() + .mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }), + resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }), + resolveHooksGmailModel: vi.fn().mockReturnValue(null), + resolveThinkingDefault: vi.fn().mockReturnValue(undefined), + }; +}); vi.mock("../../agents/model-fallback.js", () => ({ runWithModelFallback: vi.fn().mockResolvedValue({ diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 8ba4c051b8b..e4e4e798c76 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -5,6 +5,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; +import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import { runCliAgent } from "../../agents/cli-runner.js"; import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; @@ -432,6 +433,21 @@ export async function runCronIsolatedAgentTurn(params: { cronSession.sessionEntry.systemSent = true; await persistSessionEntry(); + // Resolve auth profile for the session, mirroring the inbound auto-reply path + // (get-reply-run.ts). Without this, isolated cron sessions fall back to env-var + // auth which may not match the configured auth-profiles, causing 401 errors. + const authProfileId = await resolveSessionAuthProfileOverride({ + cfg: cfgWithAgentDefaults, + provider, + agentDir, + sessionEntry: cronSession.sessionEntry, + sessionStore: cronSession.store, + sessionKey: agentSessionKey, + storePath: cronSession.storePath, + isNewSession: cronSession.isNewSession, + }); + const authProfileIdSource = cronSession.sessionEntry.authProfileOverrideSource; + let runResult: Awaited>; let fallbackProvider = provider; let fallbackModel = model; @@ -490,6 +506,8 @@ export async function runCronIsolatedAgentTurn(params: { lane: params.lane ?? "cron", provider: providerOverride, model: modelOverride, + authProfileId, + authProfileIdSource, thinkLevel, verboseLevel: resolvedVerboseLevel, timeoutMs,