From 86fa9340ae428096171694257dcced1618c7cdd2 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Mon, 26 Jan 2026 16:40:13 -0500 Subject: [PATCH 1/3] fix: reset chat state on webchat reconnect after gateway restart When the gateway restarts, the WebSocket disconnects and any in-flight chat.final events are lost. On reconnect, chatRunId/chatStream were still set from the orphaned run, making the UI think a run was still in progress and not updating properly. Fix: Reset chatRunId, chatStream, chatStreamStartedAt, and tool stream state in the onHello callback when the WebSocket reconnects. Fixes issue where users had to refresh the page after gateway restart to see completed messages. --- ui/src/ui/app-gateway.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index d9a267a98..0df25bbdf 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -127,6 +127,12 @@ export function connectGateway(host: GatewayHost) { host.lastError = null; host.hello = hello; applySnapshot(host, hello); + // Reset orphaned chat run state from before disconnect. + // Any in-flight run's final event was lost during the disconnect window. + host.chatRunId = null; + (host as unknown as { chatStream: string | null }).chatStream = null; + (host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null; + resetToolStream(host as unknown as Parameters[0]); void loadAssistantIdentity(host as unknown as ClawdbotApp); void loadAgents(host as unknown as ClawdbotApp); void loadNodes(host as unknown as ClawdbotApp, { quiet: true }); From 4b6347459bb268bf81bf07761a627988a41c2361 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Mon, 26 Jan 2026 20:03:25 -0500 Subject: [PATCH 2/3] fix: fallback to main agent OAuth credentials when secondary agent refresh fails When a secondary agent's OAuth token expires and refresh fails, the agent would error out even if the main agent had fresh, valid credentials for the same profile. This fix adds a fallback mechanism that: 1. Detects when OAuth refresh fails for a secondary agent (agentDir is set) 2. Checks if the main agent has fresh credentials for the same profileId 3. If so, copies those credentials to the secondary agent and uses them 4. Logs the inheritance for debugging This prevents the situation where users have to manually copy auth-profiles.json between agent directories when tokens expire at different times. Fixes: Secondary agents failing with 'OAuth token refresh failed' while main agent continues to work fine. --- .../oauth.fallback-to-main-agent.test.ts | 93 +++++++++++++++++++ src/agents/auth-profiles/oauth.ts | 28 +++++- 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts new file mode 100644 index 000000000..f00046338 --- /dev/null +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -0,0 +1,93 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveApiKeyForProfile } from "./oauth.js"; +import type { AuthProfileStore } from "./types.js"; + +describe("resolveApiKeyForProfile", () => { + let tmpDir: string; + let mainAgentDir: string; + let secondaryAgentDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "oauth-test-")); + mainAgentDir = path.join(tmpDir, "agents", "main", "agent"); + secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent"); + await fs.promises.mkdir(mainAgentDir, { recursive: true }); + await fs.promises.mkdir(secondaryAgentDir, { recursive: true }); + + // Set env to use our temp dir + process.env.CLAWDBOT_STATE_DIR = tmpDir; + }); + + afterEach(async () => { + delete process.env.CLAWDBOT_STATE_DIR; + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => { + const profileId = "anthropic:claude-cli"; + const now = Date.now(); + const expiredTime = now - 60 * 60 * 1000; // 1 hour ago + const freshTime = now + 60 * 60 * 1000; // 1 hour from now + + // Write expired credentials for secondary agent + const secondaryStore: AuthProfileStore = { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "anthropic", + access: "expired-access-token", + refresh: "expired-refresh-token", + expires: expiredTime, + }, + }, + }; + await fs.promises.writeFile( + path.join(secondaryAgentDir, "auth-profiles.json"), + JSON.stringify(secondaryStore), + ); + + // Write fresh credentials for main agent + const mainStore: AuthProfileStore = { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "anthropic", + access: "fresh-access-token", + refresh: "fresh-refresh-token", + expires: freshTime, + }, + }, + }; + await fs.promises.writeFile( + path.join(mainAgentDir, "auth-profiles.json"), + JSON.stringify(mainStore), + ); + + // The secondary agent should fall back to main agent's credentials + // when its own token refresh fails + const result = await resolveApiKeyForProfile({ + store: secondaryStore, + profileId, + agentDir: secondaryAgentDir, + }); + + expect(result).not.toBeNull(); + expect(result?.apiKey).toBe("fresh-access-token"); + expect(result?.provider).toBe("anthropic"); + + // Verify the credentials were copied to the secondary agent + const updatedSecondaryStore = JSON.parse( + await fs.promises.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"), + ) as AuthProfileStore; + expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({ + access: "fresh-access-token", + expires: freshTime, + }); + }); +}); diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 4138cda94..d7b3360de 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -4,7 +4,7 @@ import lockfile from "proper-lockfile"; import type { ClawdbotConfig } from "../../config/config.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; -import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js"; +import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js"; import { formatAuthDoctorHint } from "./doctor.js"; import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; @@ -196,6 +196,32 @@ export async function resolveApiKeyForProfile(params: { // keep original error } } + + // Fallback: if this is a secondary agent, try using the main agent's credentials + if (params.agentDir) { + try { + const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir) + const mainCred = mainStore.profiles[profileId]; + if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) { + // Main agent has fresh credentials - copy them to this agent and use them + refreshedStore.profiles[profileId] = { ...mainCred }; + saveAuthProfileStore(refreshedStore, params.agentDir); + log.info("inherited fresh OAuth credentials from main agent", { + profileId, + agentDir: params.agentDir, + expires: new Date(mainCred.expires).toISOString(), + }); + return { + apiKey: buildOAuthApiKey(mainCred.provider, mainCred), + provider: mainCred.provider, + email: mainCred.email, + }; + } + } catch { + // keep original error if main agent fallback also fails + } + } + const message = error instanceof Error ? error.message : String(error); const hint = formatAuthDoctorHint({ cfg, From 357ff6edb268dc8cceab8dedd27bb239b6797650 Mon Sep 17 00:00:00 2001 From: Shakker Nerd Date: Tue, 27 Jan 2026 02:37:52 +0000 Subject: [PATCH 3/3] feat: Add test case for OAuth fallback failure when both secondary and main agent credentials are expired and migrate fs operations to promises API. --- .../oauth.fallback-to-main-agent.test.ts | 107 +++++++++++++++--- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts index f00046338..d37d1a8c3 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -1,30 +1,44 @@ -import fs from "node:fs"; +import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveApiKeyForProfile } from "./oauth.js"; +import { ensureAuthProfileStore } from "./store.js"; import type { AuthProfileStore } from "./types.js"; -describe("resolveApiKeyForProfile", () => { +describe("resolveApiKeyForProfile fallback to main agent", () => { + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; let tmpDir: string; let mainAgentDir: string; let secondaryAgentDir: string; beforeEach(async () => { - tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "oauth-test-")); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-fallback-test-")); mainAgentDir = path.join(tmpDir, "agents", "main", "agent"); secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent"); - await fs.promises.mkdir(mainAgentDir, { recursive: true }); - await fs.promises.mkdir(secondaryAgentDir, { recursive: true }); + await fs.mkdir(mainAgentDir, { recursive: true }); + await fs.mkdir(secondaryAgentDir, { recursive: true }); - // Set env to use our temp dir + // Set environment variables so resolveClawdbotAgentDir() returns mainAgentDir process.env.CLAWDBOT_STATE_DIR = tmpDir; + process.env.CLAWDBOT_AGENT_DIR = mainAgentDir; + process.env.PI_CODING_AGENT_DIR = mainAgentDir; }); afterEach(async () => { - delete process.env.CLAWDBOT_STATE_DIR; - await fs.promises.rm(tmpDir, { recursive: true, force: true }); - vi.restoreAllMocks(); + vi.unstubAllGlobals(); + + // Restore original environment + if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previousStateDir; + if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR; + else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; + if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; + + await fs.rm(tmpDir, { recursive: true, force: true }); }); it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => { @@ -46,7 +60,7 @@ describe("resolveApiKeyForProfile", () => { }, }, }; - await fs.promises.writeFile( + await fs.writeFile( path.join(secondaryAgentDir, "auth-profiles.json"), JSON.stringify(secondaryStore), ); @@ -64,15 +78,27 @@ describe("resolveApiKeyForProfile", () => { }, }, }; - await fs.promises.writeFile( - path.join(mainAgentDir, "auth-profiles.json"), - JSON.stringify(mainStore), - ); + await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore)); - // The secondary agent should fall back to main agent's credentials - // when its own token refresh fails + // Mock fetch to simulate OAuth refresh failure + const fetchSpy = vi.fn(async () => { + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchSpy); + + // Load the secondary agent's store (will merge with main agent's store) + const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir); + + // Call resolveApiKeyForProfile with the secondary agent's expired credentials + // This should: + // 1. Try to refresh the expired token (fails due to mocked fetch) + // 2. Fall back to main agent's fresh credentials + // 3. Copy those credentials to the secondary agent const result = await resolveApiKeyForProfile({ - store: secondaryStore, + store: loadedSecondaryStore, profileId, agentDir: secondaryAgentDir, }); @@ -83,11 +109,56 @@ describe("resolveApiKeyForProfile", () => { // Verify the credentials were copied to the secondary agent const updatedSecondaryStore = JSON.parse( - await fs.promises.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"), + await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"), ) as AuthProfileStore; expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({ access: "fresh-access-token", expires: freshTime, }); }); + + it("throws error when both secondary and main agent credentials are expired", async () => { + const profileId = "anthropic:claude-cli"; + const now = Date.now(); + const expiredTime = now - 60 * 60 * 1000; // 1 hour ago + + // Write expired credentials for both agents + const expiredStore: AuthProfileStore = { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "anthropic", + access: "expired-access-token", + refresh: "expired-refresh-token", + expires: expiredTime, + }, + }, + }; + await fs.writeFile( + path.join(secondaryAgentDir, "auth-profiles.json"), + JSON.stringify(expiredStore), + ); + await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(expiredStore)); + + // Mock fetch to simulate OAuth refresh failure + const fetchSpy = vi.fn(async () => { + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir); + + // Should throw because both agents have expired credentials + await expect( + resolveApiKeyForProfile({ + store: loadedSecondaryStore, + profileId, + agentDir: secondaryAgentDir, + }), + ).rejects.toThrow(/OAuth token refresh failed/); + }); });