fix: repair stale auth shadows and status plugin failures

This commit is contained in:
Peter Steinberger
2026-05-15 06:34:41 +01:00
parent 4d28450312
commit c462e68df7
11 changed files with 776 additions and 19 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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<PluginManifestRecord["channelConfigs"]>[string];
export type ReadOnlyChannelPluginLoadFailure = {
channelId: string;
pluginId: string;
message: string;
source?: string;
};
function addChannelPlugins(
byId: Map<string, ChannelPlugin>,
@@ -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<string, ChannelPlugin>();
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,
};
}

View File

@@ -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) => ({

View File

@@ -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 };
}

View File

@@ -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 = [

View File

@@ -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;
}

View File

@@ -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>): 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<void> {
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();
});
});

View File

@@ -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<boolean> {
try {
await fs.lstat(targetPath);
return true;
} catch {
return false;
}
}
async function collectStateAgentDirs(env: NodeJS.ProcessEnv): Promise<string[]> {
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<string[]> {
const dirs = new Set<string>();
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<StaleOAuthProfileShadow[]> {
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<string>,
): 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<string, StaleOAuthProfileShadow[]>();
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,
};

View File

@@ -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<string>(),
}));
@@ -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([]);

View File

@@ -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,