diff --git a/CHANGELOG.md b/CHANGELOG.md index 362062f2573..50f8bc26a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ Docs: https://docs.openclaw.ai - Telegram: apply method-aware Bot API request timeouts to direct message/action clients so `openclaw message delete --channel telegram` no longer waits on grammY's 500-second default when the API request wedges. Fixes #81908. Thanks @DashLabsDev. - Cron: treat attempt dispatch and assembled context as execution-start milestones so isolated agent jobs that have reached backend dispatch are governed by their configured job timeout instead of the 60s pre-execution watchdog. Fixes #81368. (#81871) Thanks @alexph-dev. +- Doctor/auth: warn about stale per-agent OAuth auth profile shadows and let `openclaw doctor --fix` remove the local shadow so agents inherit the fresher main-agent credential. +- Status/channels: show configured channels whose plugin setup failed to load as `plugin load failed: dependency tree corrupted; run openclaw doctor --fix` instead of silently dropping them from `openclaw status`. - Discord/channels: make `openclaw channels list --all` prefer reachable Gateway runtime account status and mark configured-but-unavailable credentials, avoiding false `not configured` output when Discord is running from service-only env. Fixes #79343. Thanks @EricY019. - WhatsApp: mark text slash commands as command turns so authorized group command replies stay visible under message-tool-only group reply mode. (#81972) Thanks @barbarhan. - Installer: handle noninteractive git installs from moving refs without tag-fetch conflicts, while keeping immutable refs on frozen lockfile installs. (#81875) Thanks @keshavbotagent. diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 016146f4204..c0b06740a0a 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -12,6 +12,7 @@ import { import { listPluginLoaderModuleCandidateUrls, listReadOnlyChannelPluginsForConfig, + resolveReadOnlyChannelPluginsForConfig, } from "./read-only.js"; const moduleLoaderParams = vi.hoisted( @@ -92,6 +93,12 @@ vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => function loadOpenClawPlugins(params: LoaderParams) { const onlyPluginIds = new Set(params.onlyPluginIds ?? []); + const diagnostics: Array<{ + level: "error"; + pluginId: string; + source: string; + message: string; + }> = []; const channelSetups = listCandidatePluginDirs(params).flatMap((pluginDir) => { const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); const packagePath = path.join(pluginDir, "package.json"); @@ -111,12 +118,26 @@ vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => if (typeof setupEntry !== "string") { return []; } - const setupModule = require(path.join(pluginDir, setupEntry)); - const entry = setupModule.default ?? setupModule; + const setupPath = path.join(pluginDir, setupEntry); + let setupModule: unknown; + try { + setupModule = require(setupPath); + } catch (error) { + diagnostics.push({ + level: "error", + pluginId: manifest.id, + source: setupPath, + message: `failed to load setup entry: ${String(error)}`, + }); + return []; + } + const entry = ((setupModule as { default?: unknown }).default ?? setupModule) as { + plugin?: unknown; + }; const plugin = entry.plugin; return plugin ? [{ pluginId: manifest.id, plugin }] : []; }); - return { channelSetups }; + return { channelSetups, diagnostics }; } return { @@ -1125,4 +1146,44 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(fs.existsSync(setupMarker)).toBe(true); expect(fs.existsSync(fullMarker)).toBe(false); }); + + it("reports setup-entry load failures for configured channel plugins", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + }); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `throw new Error("Cannot find module 'ansi-escapes'");`, + "utf-8", + ); + + const result = resolveReadOnlyChannelPluginsForConfig( + { + channels: { + "external-chat": { token: "configured" }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + env: { ...process.env }, + includeSetupFallbackPlugins: true, + }, + ); + + expect(pluginIds(result.plugins)).not.toContain("external-chat"); + expect(result.missingConfiguredChannelIds).toContain("external-chat"); + expect(result.loadFailures).toEqual([ + expect.objectContaining({ + channelId: "external-chat", + pluginId: "external-chat-plugin", + message: expect.stringContaining("Cannot find module"), + }), + ]); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(fullMarker)).toBe(false); + }); }); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 3c9437b4b4c..426012404b5 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -16,6 +16,7 @@ import { resolveSetupChannelRegistration, } from "../../plugins/loader-channel-setup.js"; import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import type { PluginDiagnostic } from "../../plugins/manifest-types.js"; import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { getCachedPluginModuleLoader, @@ -61,6 +62,7 @@ type PluginLoaderModule = { pluginId: string; plugin: ChannelPlugin; }>; + diagnostics?: readonly PluginDiagnostic[]; }; }; @@ -131,8 +133,15 @@ type ReadOnlyChannelPluginResolution = { plugins: ChannelPlugin[]; configuredChannelIds: string[]; missingConfiguredChannelIds: string[]; + loadFailures: ReadOnlyChannelPluginLoadFailure[]; }; type ManifestChannelConfigRecord = NonNullable[string]; +export type ReadOnlyChannelPluginLoadFailure = { + channelId: string; + pluginId: string; + message: string; + source?: string; +}; function addChannelPlugins( byId: Map, @@ -378,9 +387,9 @@ export { resolveReadOnlyChannelCommandDefaults }; function loadSetupChannelPluginFromManifestRecord(params: { record: PluginManifestRecord; channelId: string; -}): ChannelPlugin | undefined { +}): { plugin?: ChannelPlugin; failure?: ReadOnlyChannelPluginLoadFailure } { if (!params.record.setupSource || !params.record.channels.includes(params.channelId)) { - return undefined; + return {}; } try { const moduleLoader = getCachedPluginModuleLoader({ @@ -393,8 +402,18 @@ function loadSetupChannelPluginFromManifestRecord(params: { cacheScopeKey: "read-only-setup-entry", }); const registration = resolveSetupChannelRegistration(moduleLoader(params.record.setupSource)); + if (registration.loadError) { + return { + failure: { + channelId: params.channelId, + pluginId: params.record.id, + source: params.record.setupSource, + message: `failed to load setup entry: ${formatErrorMessage(registration.loadError)}`, + }, + }; + } if (!registration.plugin) { - return undefined; + return {}; } if ( !channelPluginIdBelongsToManifest({ @@ -403,16 +422,57 @@ function loadSetupChannelPluginFromManifestRecord(params: { manifestChannels: params.record.channels, }) ) { - return undefined; + return {}; } - return cloneChannelPluginForChannelId(registration.plugin, params.channelId); + return { plugin: cloneChannelPluginForChannelId(registration.plugin, params.channelId) }; } catch (error) { const detail = formatErrorMessage(error); log.warn(`[channels] failed to load channel setup ${params.record.id}: ${detail}`); - return undefined; + return { + failure: { + channelId: params.channelId, + pluginId: params.record.id, + source: params.record.setupSource, + message: `failed to load setup entry: ${detail}`, + }, + }; } } +function collectChannelPluginLoadFailuresFromDiagnostics(params: { + diagnostics: readonly PluginDiagnostic[] | undefined; + records: readonly PluginManifestRecord[]; + channelIds: readonly string[]; +}): ReadOnlyChannelPluginLoadFailure[] { + if (!params.diagnostics?.length || params.channelIds.length === 0) { + return []; + } + const configuredChannelIds = new Set(params.channelIds); + const recordsByPluginId = new Map(params.records.map((record) => [record.id, record] as const)); + const failures: ReadOnlyChannelPluginLoadFailure[] = []; + for (const diagnostic of params.diagnostics) { + if (diagnostic.level !== "error" || !diagnostic.pluginId) { + continue; + } + const record = recordsByPluginId.get(diagnostic.pluginId); + if (!record) { + continue; + } + for (const channelId of record.channels) { + if (!configuredChannelIds.has(channelId)) { + continue; + } + failures.push({ + channelId, + pluginId: record.id, + source: diagnostic.source, + message: diagnostic.message, + }); + } + } + return failures; +} + function rebindChannelPluginConfig( config: ChannelPlugin["config"], sourceChannelId: string, @@ -730,6 +790,7 @@ export function resolveReadOnlyChannelPluginsForConfig( ), ].filter(isSafeManifestChannelId); const byId = new Map(); + const loadFailures: ReadOnlyChannelPluginLoadFailure[] = []; addChannelPlugins(byId, listChannelPlugins()); @@ -738,16 +799,22 @@ export function resolveReadOnlyChannelPluginsForConfig( if (byId.has(channelId)) { continue; } + const setupResults = bundledManifestRecords + .filter((record) => record.channels.includes(channelId)) + .map((record) => + loadSetupChannelPluginFromManifestRecord({ + record, + channelId, + }), + ); + loadFailures.push( + ...setupResults + .map((result) => result.failure) + .filter((failure): failure is ReadOnlyChannelPluginLoadFailure => Boolean(failure)), + ); const bundledSetupPlugin = - bundledManifestRecords - .filter((record) => record.channels.includes(channelId)) - .map((record) => - loadSetupChannelPluginFromManifestRecord({ - record, - channelId, - }), - ) - .find((plugin) => plugin) ?? getBundledChannelSetupPlugin(channelId, env); + setupResults.map((result) => result.plugin).find((plugin) => plugin) ?? + getBundledChannelSetupPlugin(channelId, env); addChannelPlugins(byId, [bundledSetupPlugin]); } } @@ -803,6 +870,13 @@ export function resolveReadOnlyChannelPluginsForConfig( requireSetupEntryForSetupOnlyChannelPlugins: true, onlyPluginIds: externalPluginIds, }); + loadFailures.push( + ...collectChannelPluginLoadFailuresFromDiagnostics({ + diagnostics: registry.diagnostics, + records: externalManifestRecords, + channelIds: missingConfiguredChannelIds, + }), + ); addSetupChannelPlugins(byId, registry.channelSetups, { ownedChannelIdsByPluginId, ownedMissingChannelIdsByPluginId, @@ -822,5 +896,6 @@ export function resolveReadOnlyChannelPluginsForConfig( plugins, configuredChannelIds, missingConfiguredChannelIds: configuredChannelIds.filter((channelId) => !byId.has(channelId)), + loadFailures, }; } diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index 0672d785bb5..084a1357ff6 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ maybeRepairManagedNpmOpenClawPeerLinks: vi.fn(), maybeRepairStaleManagedNpmBundledPlugins: vi.fn(), maybeRepairStalePluginConfig: vi.fn(), + repairStaleOAuthProfileShadows: vi.fn(), repairMissingConfiguredPluginInstalls: vi.fn(), resolveAuthProfileOrder: vi.fn(), resolveProfileUnusableUntilForDisplay: vi.fn(), @@ -118,6 +119,10 @@ vi.mock("./shared/stale-plugin-config.js", () => ({ maybeRepairStalePluginConfig: mocks.maybeRepairStalePluginConfig, })); +vi.mock("./shared/stale-oauth-profile-shadows.js", () => ({ + repairStaleOAuthProfileShadows: mocks.repairStaleOAuthProfileShadows, +})); + vi.mock("./shared/invalid-plugin-config.js", () => ({ maybeRepairInvalidPluginConfig: (cfg: OpenClawConfig) => ({ config: cfg, @@ -193,6 +198,10 @@ describe("doctor repair sequencing", () => { changes: [], warnings: [], }); + mocks.repairStaleOAuthProfileShadows.mockResolvedValue({ + changes: [], + warnings: [], + }); mocks.resolveAuthProfileOrder.mockReturnValue([]); mocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(null); mocks.maybeRepairStalePluginConfig.mockImplementation((cfg: OpenClawConfig) => ({ diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 26787665e1a..21fb9ec8ea7 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -22,6 +22,7 @@ import { maybeRepairLegacyToolsBySenderKeys } from "./shared/legacy-tools-by-sen import { repairMissingConfiguredPluginInstalls } from "./shared/missing-configured-plugin-install.js"; import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.js"; import { cleanupLegacyPluginDependencyState } from "./shared/plugin-dependency-cleanup.js"; +import { repairStaleOAuthProfileShadows } from "./shared/stale-oauth-profile-shadows.js"; import { maybeRepairStalePluginConfig } from "./shared/stale-plugin-config.js"; import { isUpdatePackageSwapInProgress } from "./shared/update-phase.js"; @@ -123,6 +124,16 @@ export async function runDoctorRepairSequence(params: { if (pluginDependencyCleanup.warnings.length > 0) { warningNotes.push(sanitizeLines(pluginDependencyCleanup.warnings)); } + const staleOAuthShadowRepair = await repairStaleOAuthProfileShadows({ + cfg: state.candidate, + env, + }); + if (staleOAuthShadowRepair.changes.length > 0) { + changeNotes.push(sanitizeLines(staleOAuthShadowRepair.changes)); + } + if (staleOAuthShadowRepair.warnings.length > 0) { + warningNotes.push(sanitizeLines(staleOAuthShadowRepair.warnings)); + } return { state, changeNotes, warningNotes }; } diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 6d26f1d78b3..bbe7ae1d0e9 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -22,6 +22,10 @@ const manifestState = vi.hoisted( }, ); +const staleOAuthShadowState = vi.hoisted(() => ({ + warnings: [] as string[], +})); + vi.mock("../channel-capabilities.js", () => { const fallback = { dmAllowFromMode: "topOnly", @@ -156,6 +160,13 @@ vi.mock("./bundled-plugin-load-paths.js", () => ({ ), })); +vi.mock("./stale-oauth-profile-shadows.js", () => ({ + scanStaleOAuthProfileShadows: () => + staleOAuthShadowState.warnings.map((warning, index) => ({ profileId: String(index), warning })), + collectStaleOAuthProfileShadowWarnings: ({ hits }: { hits: Array<{ warning: string }> }) => + hits.map((hit) => hit.warning), +})); + function manifest(id: string): TestManifestRecord { return { id, @@ -199,6 +210,7 @@ describe("doctor preview warnings", () => { beforeEach(() => { manifestState.plugins = [manifest("discord")]; manifestState.diagnostics = []; + staleOAuthShadowState.warnings = []; }); afterEach(() => { @@ -308,6 +320,19 @@ describe("doctor preview warnings", () => { expect(warning).toContain('Run "openclaw doctor --fix"'); }); + it("includes stale OAuth profile shadow warnings", async () => { + staleOAuthShadowState.warnings = [ + '- ~/.openclaw/agents/telegram/agent/auth-profiles.json has stale OAuth auth profile openai-codex:default. Run "openclaw doctor --fix".', + ]; + + const warnings = await collectDoctorPreviewWarnings({ + cfg: {}, + doctorFixCommand: "openclaw doctor --fix", + }); + + expectSingleWarningContaining(warnings, "stale OAuth auth profile openai-codex:default"); + }); + it("warns but skips auto-removal when plugin discovery has errors", async () => { manifestState.plugins = []; manifestState.diagnostics = [ diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index deae3175bd4..8b6cfafad05 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -525,5 +525,20 @@ export async function collectDoctorPreviewWarnings(params: { } } + const { collectStaleOAuthProfileShadowWarnings, scanStaleOAuthProfileShadows } = + await import("./stale-oauth-profile-shadows.js"); + const staleOAuthProfileShadows = await scanStaleOAuthProfileShadows({ + cfg: params.cfg, + env, + }); + if (staleOAuthProfileShadows.length > 0) { + warnings.push( + collectStaleOAuthProfileShadowWarnings({ + hits: staleOAuthProfileShadows, + doctorFixCommand: params.doctorFixCommand, + }).join("\n"), + ); + } + return warnings; } diff --git a/src/commands/doctor/shared/stale-oauth-profile-shadows.test.ts b/src/commands/doctor/shared/stale-oauth-profile-shadows.test.ts new file mode 100644 index 00000000000..7cb047543db --- /dev/null +++ b/src/commands/doctor/shared/stale-oauth-profile-shadows.test.ts @@ -0,0 +1,283 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveAuthStorePath } from "../../../agents/auth-profiles/paths.js"; +import { loadPersistedAuthProfileStore } from "../../../agents/auth-profiles/persisted.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + saveAuthProfileStore, +} from "../../../agents/auth-profiles/store.js"; +import type { AuthProfileStore, OAuthCredential } from "../../../agents/auth-profiles/types.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { captureEnv } from "../../../test-utils/env.js"; +import { + collectStaleOAuthProfileShadowWarnings, + repairStaleOAuthProfileShadows, + scanStaleOAuthProfileShadows, +} from "./stale-oauth-profile-shadows.js"; + +function oauthCredential(overrides: Partial): OAuthCredential { + return { + type: "oauth", + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: Date.now() + 60 * 60 * 1000, + ...overrides, + }; +} + +function storeWith(profileId: string, credential: OAuthCredential): AuthProfileStore { + return { + version: 1, + profiles: { [profileId]: credential }, + }; +} + +async function writeRawAuthStore(agentDir: string, store: AuthProfileStore): Promise { + const authPath = resolveAuthStorePath(agentDir); + await fs.mkdir(path.dirname(authPath), { recursive: true }); + await fs.writeFile(authPath, `${JSON.stringify(store, null, 2)}\n`, "utf8"); +} + +describe("stale OAuth profile shadow doctor repair", () => { + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_HOME"]); + let tempRoot = ""; + let stateDir = ""; + + beforeEach(async () => { + clearRuntimeAuthProfileStoreSnapshots(); + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-stale-oauth-shadow-")); + stateDir = path.join(tempRoot, "state"); + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_HOME = stateDir; + }); + + afterEach(async () => { + clearRuntimeAuthProfileStoreSnapshots(); + envSnapshot.restore(); + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + it("warns about stale local OAuth shadows without modifying the child store", async () => { + const profileId = "anthropic:default"; + const now = Date.now(); + const childAgentDir = path.join(stateDir, "agents", "telegram", "agent"); + await writeRawAuthStore( + childAgentDir, + storeWith( + profileId, + oauthCredential({ + access: "child-access", + refresh: "child-refresh", + expires: now - 60_000, + accountId: "acct-shared", + }), + ), + ); + saveAuthProfileStore( + storeWith( + profileId, + oauthCredential({ + access: "main-access", + refresh: "main-refresh", + expires: now + 60 * 60 * 1000, + accountId: "acct-shared", + }), + ), + ); + + const hits = await scanStaleOAuthProfileShadows({ + cfg: {} satisfies OpenClawConfig, + now, + }); + const warnings = collectStaleOAuthProfileShadowWarnings({ + hits, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(hits).toHaveLength(1); + expect(warnings[0]).toContain("stale OAuth auth profile anthropic:default"); + expect(warnings[0]).toContain("openclaw doctor --fix"); + expect(loadPersistedAuthProfileStore(childAgentDir)?.profiles[profileId]).toBeDefined(); + }); + + it("uses the injected env for the main auth store", async () => { + const profileId = "anthropic:default"; + const now = Date.now(); + const injectedStateDir = path.join(tempRoot, "injected-state"); + const injectedEnv = { + ...process.env, + OPENCLAW_STATE_DIR: injectedStateDir, + OPENCLAW_HOME: injectedStateDir, + }; + saveAuthProfileStore( + storeWith( + profileId, + oauthCredential({ + expires: now + 60 * 60 * 1000, + accountId: "acct-process-env", + }), + ), + undefined, + ); + await writeRawAuthStore( + path.join(injectedStateDir, "agents", "main", "agent"), + storeWith( + profileId, + oauthCredential({ + access: "main-access", + refresh: "main-refresh", + expires: now + 60 * 60 * 1000, + accountId: "acct-injected-env", + }), + ), + ); + const childAgentDir = path.join(injectedStateDir, "agents", "telegram", "agent"); + await writeRawAuthStore( + childAgentDir, + storeWith( + profileId, + oauthCredential({ + access: "child-access", + refresh: "child-refresh", + expires: now - 60_000, + accountId: "acct-injected-env", + }), + ), + ); + + const hits = await scanStaleOAuthProfileShadows({ + cfg: {} satisfies OpenClawConfig, + env: injectedEnv, + now, + }); + + expect(hits).toEqual([ + expect.objectContaining({ + authPath: resolveAuthStorePath(childAgentDir), + profileId, + }), + ]); + }); + + it("removes stale child OAuth shadows and local cooldown state", async () => { + const profileId = "anthropic:default"; + const now = Date.now(); + const childAgentDir = path.join(stateDir, "agents", "telegram", "agent"); + saveAuthProfileStore( + storeWith( + profileId, + oauthCredential({ + access: "main-access", + refresh: "main-refresh", + expires: now + 60 * 60 * 1000, + accountId: "acct-shared", + }), + ), + undefined, + ); + await writeRawAuthStore(childAgentDir, { + ...storeWith( + profileId, + oauthCredential({ + access: "child-access", + refresh: "child-refresh", + expires: now - 60_000, + accountId: "acct-shared", + }), + ), + order: { anthropic: [profileId] }, + lastGood: { anthropic: profileId }, + usageStats: { + [profileId]: { + cooldownReason: "auth", + failureCounts: { auth: 2 }, + }, + }, + }); + + const result = await repairStaleOAuthProfileShadows({ + cfg: { agents: { list: [{ id: "telegram" }] } } satisfies OpenClawConfig, + now, + }); + + expect(result.warnings).toEqual([]); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toContain( + "Removed stale OAuth auth profile shadow anthropic:default", + ); + const childStore = loadPersistedAuthProfileStore(childAgentDir); + expect(childStore?.profiles[profileId]).toBeUndefined(); + expect(childStore?.usageStats?.[profileId]).toBeUndefined(); + expect(childStore?.order?.anthropic).toBeUndefined(); + expect(childStore?.lastGood?.anthropic).toBeUndefined(); + }); + + it("does not remove a child OAuth profile for a different account", async () => { + const profileId = "anthropic:default"; + const now = Date.now(); + const childAgentDir = path.join(stateDir, "agents", "telegram", "agent"); + await writeRawAuthStore( + childAgentDir, + storeWith( + profileId, + oauthCredential({ + expires: now - 60_000, + accountId: "acct-child", + }), + ), + ); + saveAuthProfileStore( + storeWith( + profileId, + oauthCredential({ + expires: now + 60 * 60 * 1000, + accountId: "acct-main", + }), + ), + ); + + const result = await repairStaleOAuthProfileShadows({ + cfg: {} satisfies OpenClawConfig, + now, + }); + + expect(result.changes).toEqual([]); + expect(loadPersistedAuthProfileStore(childAgentDir)?.profiles[profileId]).toBeDefined(); + }); + + it("keeps a newer child OAuth profile", async () => { + const profileId = "anthropic:default"; + const now = Date.now(); + const childAgentDir = path.join(stateDir, "agents", "telegram", "agent"); + await writeRawAuthStore( + childAgentDir, + storeWith( + profileId, + oauthCredential({ + expires: now + 60 * 60 * 1000, + accountId: "acct-shared", + }), + ), + ); + saveAuthProfileStore( + storeWith( + profileId, + oauthCredential({ + expires: now + 30 * 60 * 1000, + accountId: "acct-shared", + }), + ), + ); + + const result = await repairStaleOAuthProfileShadows({ + cfg: {} satisfies OpenClawConfig, + now, + }); + + expect(result.changes).toEqual([]); + expect(loadPersistedAuthProfileStore(childAgentDir)?.profiles[profileId]).toBeDefined(); + }); +}); diff --git a/src/commands/doctor/shared/stale-oauth-profile-shadows.ts b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts new file mode 100644 index 00000000000..e15578dfefb --- /dev/null +++ b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts @@ -0,0 +1,204 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + resolveAgentDir, + resolveDefaultAgentDir, + listAgentEntries, +} from "../../../agents/agent-scope.js"; +import { + areOAuthCredentialsEquivalent, + hasUsableOAuthCredential, + isSafeToAdoptMainStoreOAuthIdentity, +} from "../../../agents/auth-profiles/oauth-shared.js"; +import { resolveAuthStorePath } from "../../../agents/auth-profiles/paths.js"; +import { loadPersistedAuthProfileStore } from "../../../agents/auth-profiles/persisted.js"; +import { saveAuthProfileStore } from "../../../agents/auth-profiles/store.js"; +import type { AuthProfileStore, OAuthCredential } from "../../../agents/auth-profiles/types.js"; +import { resolveStateDir } from "../../../config/paths.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { shortenHomePath } from "../../../utils.js"; + +type StaleOAuthProfileShadow = { + agentDir: string; + authPath: string; + profileId: string; +}; + +async function pathExists(targetPath: string): Promise { + try { + await fs.lstat(targetPath); + return true; + } catch { + return false; + } +} + +async function collectStateAgentDirs(env: NodeJS.ProcessEnv): Promise { + const agentsRoot = path.join(resolveStateDir(env), "agents"); + const entries = await fs.readdir(agentsRoot, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => path.join(agentsRoot, entry.name, "agent")); +} + +async function collectCandidateAgentDirs( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): Promise { + const dirs = new Set(); + for (const entry of listAgentEntries(cfg)) { + const id = entry.id?.trim(); + if (id) { + dirs.add(path.resolve(resolveAgentDir(cfg, id, env))); + } + } + for (const agentDir of await collectStateAgentDirs(env)) { + dirs.add(path.resolve(agentDir)); + } + return [...dirs].toSorted((left, right) => left.localeCompare(right)); +} + +function shouldRemoveLocalOAuthShadow(params: { + local: OAuthCredential; + main: OAuthCredential | undefined; + now: number; +}): boolean { + const { local, main, now } = params; + if (!main || main.type !== "oauth" || local.provider !== main.provider) { + return false; + } + if (!isSafeToAdoptMainStoreOAuthIdentity(local, main)) { + return false; + } + if (areOAuthCredentialsEquivalent(local, main)) { + return true; + } + if (!hasUsableOAuthCredential(main, now)) { + return false; + } + if (!hasUsableOAuthCredential(local, now)) { + return true; + } + const localExpires = Number.isFinite(local.expires) ? local.expires : 0; + const mainExpires = Number.isFinite(main.expires) ? main.expires : 0; + return mainExpires >= localExpires; +} + +export async function scanStaleOAuthProfileShadows(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + now?: number; +}): Promise { + const env = params.env ?? process.env; + const now = params.now ?? Date.now(); + const mainAgentDir = resolveDefaultAgentDir({}, env); + const mainAuthPath = path.resolve(resolveAuthStorePath(mainAgentDir)); + const mainStore = loadPersistedAuthProfileStore(mainAgentDir); + if (!mainStore) { + return []; + } + const hits: StaleOAuthProfileShadow[] = []; + for (const agentDir of await collectCandidateAgentDirs(params.cfg, env)) { + const authPath = path.resolve(resolveAuthStorePath(agentDir)); + if (authPath === mainAuthPath || !(await pathExists(authPath))) { + continue; + } + const localStore = loadPersistedAuthProfileStore(agentDir); + if (!localStore) { + continue; + } + for (const [profileId, local] of Object.entries(localStore.profiles)) { + if (local.type !== "oauth") { + continue; + } + const main = mainStore.profiles[profileId]; + if ( + shouldRemoveLocalOAuthShadow({ + local, + main: main?.type === "oauth" ? main : undefined, + now, + }) + ) { + hits.push({ agentDir, authPath, profileId }); + } + } + } + return hits; +} + +function removeProfilesFromStore( + store: AuthProfileStore, + profileIds: Set, +): AuthProfileStore { + const profiles = { ...store.profiles }; + const usageStats = store.usageStats ? { ...store.usageStats } : undefined; + for (const profileId of profileIds) { + delete profiles[profileId]; + if (usageStats) { + delete usageStats[profileId]; + } + } + return { + ...store, + profiles, + ...(usageStats && Object.keys(usageStats).length > 0 + ? { usageStats } + : { usageStats: undefined }), + }; +} + +function formatProfileList(profileIds: string[]): string { + return profileIds.length === 1 ? profileIds[0] : `${profileIds.length} profiles`; +} + +export function collectStaleOAuthProfileShadowWarnings(params: { + hits: StaleOAuthProfileShadow[]; + doctorFixCommand: string; +}): string[] { + return params.hits.map( + (hit) => + `- ${shortenHomePath(hit.authPath)} has stale OAuth auth profile ${hit.profileId}; it shadows the fresher main-agent credential. Run "${params.doctorFixCommand}" to remove the local shadow and inherit main auth.`, + ); +} + +export async function repairStaleOAuthProfileShadows(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + now?: number; +}): Promise<{ changes: string[]; warnings: string[] }> { + const hits = await scanStaleOAuthProfileShadows(params); + const changes: string[] = []; + const warnings: string[] = []; + const byAgentDir = new Map(); + for (const hit of hits) { + const existing = byAgentDir.get(hit.agentDir) ?? []; + existing.push(hit); + byAgentDir.set(hit.agentDir, existing); + } + for (const [agentDir, agentHits] of byAgentDir) { + const store = loadPersistedAuthProfileStore(agentDir); + if (!store) { + continue; + } + const profileIds = new Set(agentHits.map((hit) => hit.profileId)); + try { + saveAuthProfileStore(removeProfilesFromStore(store, profileIds), agentDir); + changes.push( + `Removed stale OAuth auth profile shadow ${formatProfileList( + [...profileIds].toSorted(), + )} from ${shortenHomePath(resolveAuthStorePath(agentDir))}; this agent now inherits main auth.`, + ); + } catch (error) { + warnings.push( + `Failed to remove stale OAuth auth profile shadow from ${shortenHomePath( + resolveAuthStorePath(agentDir), + )}: ${String(error)}`, + ); + } + } + return { changes, warnings }; +} + +export const __testing = { + shouldRemoveLocalOAuthShadow, +}; diff --git a/src/commands/status-all/channels.test.ts b/src/commands/status-all/channels.test.ts index 7239a119b78..72d35657301 100644 --- a/src/commands/status-all/channels.test.ts +++ b/src/commands/status-all/channels.test.ts @@ -4,6 +4,11 @@ import { buildChannelsTable } from "./channels.js"; const mocks = vi.hoisted(() => ({ resolveInspectedChannelAccount: vi.fn(), listReadOnlyChannelPluginsForConfig: vi.fn(), + readOnlyChannelLoadFailures: [] as Array<{ + channelId: string; + pluginId: string; + message: string; + }>, missingOfficialExternalChannels: new Set(), })); @@ -23,7 +28,10 @@ vi.mock("../../channels/plugins/read-only.js", () => ({ resolveReadOnlyChannelPluginsForConfig: () => ({ plugins: mocks.listReadOnlyChannelPluginsForConfig(), configuredChannelIds: [], - missingConfiguredChannelIds: [], + missingConfiguredChannelIds: mocks.readOnlyChannelLoadFailures.map( + (failure) => failure.channelId, + ), + loadFailures: mocks.readOnlyChannelLoadFailures, }), })); @@ -46,6 +54,7 @@ vi.mock("../../plugins/official-external-plugin-repair-hints.js", () => ({ describe("buildChannelsTable", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.readOnlyChannelLoadFailures = []; mocks.missingOfficialExternalChannels.clear(); mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([discordPlugin]); mocks.resolveInspectedChannelAccount.mockResolvedValue({ @@ -115,6 +124,33 @@ describe("buildChannelsTable", () => { expect(mocks.resolveInspectedChannelAccount).not.toHaveBeenCalled(); }); + it("shows plugin load failures for configured channels whose setup registration fails", async () => { + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + mocks.readOnlyChannelLoadFailures = [ + { + channelId: "telegram", + pluginId: "telegram", + message: 'failed to load setup entry: Cannot find module "ansi-escapes"', + }, + ]; + + const table = await buildChannelsTable({ channels: { telegram: { botToken: "123:abc" } } }); + + expect(table).toStrictEqual({ + rows: [ + { + id: "telegram", + label: "telegram", + enabled: true, + state: "warn", + detail: "plugin load failed: dependency tree corrupted; run openclaw doctor --fix", + }, + ], + details: [], + }); + expect(mocks.resolveInspectedChannelAccount).not.toHaveBeenCalled(); + }); + it("does not show install repair rows when an external channel owner is policy-blocked", async () => { mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index 3abccf6abe1..5056713e47d 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -194,6 +194,19 @@ function collectMissingPaths(accounts: ChannelAccountRow[]): string[] { return missing; } +function isLikelyDependencyTreeCorruption(message: string): boolean { + return /(?:cannot find (?:module|package)|module_not_found|err_module_not_found|enoent|enotempty|missing package|failed to resolve)/iu.test( + message, + ); +} + +function formatLoadFailureDetail(message: string): string { + const reason = isLikelyDependencyTreeCorruption(message) + ? "dependency tree corrupted" + : "registration failed"; + return `plugin load failed: ${reason}; run openclaw doctor --fix`; +} + // `status --all` channels table. // Keep this generic: channel-specific rules belong in the channel plugin. export async function buildChannelsTable( @@ -433,6 +446,29 @@ export async function buildChannelsTable( } const visibleChannelIds = new Set(rows.map((row) => row.id)); + const loadFailuresByChannel = new Map( + readOnlyPlugins.loadFailures.map((failure) => [failure.channelId, failure] as const), + ); + for (const channelId of readOnlyPlugins.missingConfiguredChannelIds.toSorted((left, right) => + left.localeCompare(right), + )) { + if (visibleChannelIds.has(channelId)) { + continue; + } + const failure = loadFailuresByChannel.get(channelId); + if (!failure) { + continue; + } + rows.push({ + id: channelId, + label: channelId, + enabled: true, + state: "warn", + detail: formatLoadFailureDetail(failure.message), + }); + visibleChannelIds.add(channelId); + } + const missingCandidateChannelIds = [ ...new Set([ ...readOnlyPlugins.missingConfiguredChannelIds,