mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 20:24:46 +00:00
fix: repair stale auth shadows and status plugin failures
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
283
src/commands/doctor/shared/stale-oauth-profile-shadows.test.ts
Normal file
283
src/commands/doctor/shared/stale-oauth-profile-shadows.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
204
src/commands/doctor/shared/stale-oauth-profile-shadows.ts
Normal file
204
src/commands/doctor/shared/stale-oauth-profile-shadows.ts
Normal 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,
|
||||
};
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user