refactor(auth): dedupe profile upserts and approval resolvers (#120831)

* refactor(auth): dedupe profile upserts and approval resolvers

* test(auth): mock canonical locked upsert

* test(auth): mock locked upsert during onboarding
This commit is contained in:
Peter Steinberger
2026-08-08 21:14:16 -07:00
committed by GitHub
parent 7a819da6fc
commit e1ec95dcbf
53 changed files with 283 additions and 624 deletions
+1 -10
View File
@@ -22,9 +22,9 @@ import {
type OpenClawConfig as ProviderAuthConfig,
type ProviderAuthResult,
suggestOAuthProfileIdForLegacyDefault,
upsertAuthProfileWithLock,
validateAnthropicSetupToken,
} from "openclaw/plugin-sdk/provider-auth";
import { upsertAuthProfileWithLockOrThrow } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import {
buildManifestModelProviderConfig,
@@ -90,7 +90,6 @@ function classifyAnthropicFailoverDescriptor(value: string | undefined) {
return undefined;
}
}
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-opus-5";
const ANTHROPIC_OPUS_48_MODEL_ID = "claude-opus-4-8";
const ANTHROPIC_OPUS_48_DOT_MODEL_ID = "claude-opus-4.8";
@@ -196,14 +195,6 @@ const CLAUDE_CLI_CANONICAL_ALLOWLIST_REFS = CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS.ma
: ref,
);
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
function normalizeAnthropicSetupTokenInput(value: string): string {
return value.replaceAll(/\s+/g, "").trim();
}
+1 -12
View File
@@ -11,9 +11,9 @@ import {
listProfilesForProvider,
normalizeApiKeyInput,
normalizeOptionalSecretInput,
upsertAuthProfileWithLock,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth";
import { upsertAuthProfileWithLockOrThrow } from "openclaw/plugin-sdk/provider-auth-api-key";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { buildCloudflareAiGatewayCatalogProvider } from "./catalog-provider.js";
import { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "./models.js";
@@ -23,17 +23,6 @@ import { wrapCloudflareAiGatewayProviderStream } from "./stream-wrappers.js";
const PROVIDER_ID = "cloudflare-ai-gateway";
const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY";
const PROFILE_ID = "cloudflare-ai-gateway:default";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
function readRequiredTextInput(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -271,8 +271,9 @@ describe("discord exec approval monitor helpers", () => {
approvalId: "abc",
approvalKind,
decision: "allow-once",
channel: "discord",
senderId: "default",
gatewayUrl: "ws://127.0.0.1:18789",
clientDisplayName: "Discord approval (default)",
});
},
);
@@ -204,8 +204,9 @@ export function createDiscordExecApprovalButtonContext(params: {
approvalId,
approvalKind,
decision,
channel: "discord",
senderId: params.accountId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Discord approval (${params.accountId})`,
});
return { ok: true, resolution };
} catch (err) {
+1 -12
View File
@@ -12,8 +12,8 @@ import {
applyAuthProfileConfig,
ensureAuthProfileStore,
normalizeGithubCopilotDomain,
upsertAuthProfileWithLock,
} from "openclaw/plugin-sdk/provider-auth";
import { upsertAuthProfileWithLockOrThrow } from "openclaw/plugin-sdk/provider-auth-api-key";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -63,8 +63,6 @@ type DeviceTokenResponse =
const GITHUB_DEVICE_ACCESS_DENIED = Symbol("github-device-access-denied");
const GITHUB_DEVICE_EXPIRED = Symbol("github-device-expired");
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
class GitHubDeviceFlowError extends Error {
readonly kind: symbol;
constructor(kind: symbol, message: string) {
@@ -74,15 +72,6 @@ class GitHubDeviceFlowError extends Error {
}
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
function isGitHubDeviceAccessDeniedError(err: unknown): boolean {
return err instanceof GitHubDeviceFlowError && err.kind === GITHUB_DEVICE_ACCESS_DENIED;
}
@@ -164,8 +164,8 @@ describe("maybeHandleGoogleChatApprovalCardClick", () => {
approvalId: "approval-1",
approvalKind: "exec",
decision: "allow-once",
channel: "googlechat",
senderId: "users/123",
clientDisplayName: "Google Chat approval (users/123)",
});
expect(updateGoogleChatMessage).toHaveBeenCalledWith({
account: target.account,
@@ -86,8 +86,8 @@ export async function maybeHandleGoogleChatApprovalCardClick(params: {
approvalId: consumed.approvalId,
approvalKind: consumed.approvalKind,
decision: consumed.decision,
channel: "googlechat",
senderId: actor,
clientDisplayName: `Google Chat approval (${actor?.trim() || "unknown"})`,
});
await updateGoogleChatMessage({
account: params.target.account,
@@ -0,0 +1,5 @@
import type { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
export type IMessageApprovalGatewayRuntime = NonNullable<
Parameters<typeof resolveApprovalOverGateway>[0]["gatewayRuntime"]
>;
@@ -14,8 +14,10 @@ const resolverMocks = vi.hoisted(() => ({
isApprovalNotFoundError: vi.fn(() => false),
}));
vi.mock("./approval-resolver.js", () => ({
resolveIMessageApproval: resolverMocks.resolveIMessageApproval,
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: resolverMocks.resolveIMessageApproval,
}));
vi.mock("openclaw/plugin-sdk/error-runtime", () => ({
isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError,
}));
+8 -4
View File
@@ -9,11 +9,12 @@ import {
} from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
import { createPluginStateErrorReporter } from "openclaw/plugin-sdk/plugin-state-runtime";
import { getIMessageApprovalApprovers, imessageApprovalAuth } from "./approval-auth.js";
import type { IMessageApprovalGatewayRuntime } from "./approval-resolver.js";
import type { IMessageApprovalGatewayRuntime } from "./approval-gateway-types.js";
import {
buildIMessageApprovalConversationKeyForInbound,
enumerateApprovalTargetKeys,
@@ -52,7 +53,9 @@ type IMessageApprovalPollTarget = {
type IMessageApprovalPollTombstone = { approvalId: string };
const loadApprovalResolver = createLazyRuntimeModule(() => import("./approval-resolver.js"));
const loadApprovalResolver = createLazyRuntimeModule(
() => import("openclaw/plugin-sdk/approval-gateway-runtime"),
);
const reportPersistentError = createPluginStateErrorReporter(
getOptionalIMessageRuntime,
@@ -531,13 +534,14 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
return true;
}
const { isApprovalNotFoundError, resolveIMessageApproval } = await loadApprovalResolver();
const { resolveApprovalOverGateway } = await loadApprovalResolver();
try {
const result = await resolveIMessageApproval({
const result = await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: target.approvalId,
approvalKind: target.approvalKind,
decision,
channel: "imessage",
senderId: event.actorHandle,
gatewayUrl: params.gatewayUrl,
...(params.gatewayRuntime ? { gatewayRuntime: params.gatewayRuntime } : {}),
@@ -4,6 +4,7 @@ import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import type { IMessageApprovalGatewayRuntime } from "./approval-gateway-types.js";
import {
extractIMessageApprovalPromptBinding,
handleIMessageApprovalReaction,
@@ -12,7 +13,6 @@ import {
type PendingIMessageApprovalReactionPollTarget,
type IMessageApprovalConversationKey,
} from "./approval-reactions.js";
import type { IMessageApprovalGatewayRuntime } from "./approval-resolver.js";
import type { IMessageRpcClient } from "./client.js";
import type { IMessagePayload } from "./monitor/types.js";
@@ -37,8 +37,10 @@ function registerIMessageApprovalReactionTarget(
});
}
vi.mock("./approval-resolver.js", () => ({
resolveIMessageApproval: resolverMocks.resolveIMessageApproval,
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: resolverMocks.resolveIMessageApproval,
}));
vi.mock("openclaw/plugin-sdk/error-runtime", () => ({
isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError,
}));
@@ -771,6 +773,7 @@ describe("iMessage approval reactions", () => {
expect.objectContaining({
approvalId: "exec-self",
decision: "allow-once",
channel: "imessage",
senderId: "+15551230000",
gatewayRuntime,
}),
@@ -853,6 +856,7 @@ describe("iMessage approval reactions", () => {
approvalId: "exec-service-prefix",
approvalKind: "exec",
decision: "allow-once",
channel: "imessage",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -896,6 +900,7 @@ describe("iMessage approval reactions", () => {
approvalId: "exec-prefixed",
approvalKind: "exec",
decision: "allow-once",
channel: "imessage",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -953,6 +958,7 @@ describe("iMessage approval reactions", () => {
approvalId: "exec-dm",
approvalKind: "exec",
decision: "allow-once",
channel: "imessage",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -1020,6 +1026,7 @@ describe("iMessage approval reactions", () => {
approvalId: "plugin:abc",
approvalKind: "plugin",
decision: "allow-once",
channel: "imessage",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -1059,6 +1066,7 @@ describe("iMessage approval reactions", () => {
approvalId: "exec-group",
approvalKind: "exec",
decision: "deny",
channel: "imessage",
senderId: "+15551239999",
gatewayUrl: undefined,
});
@@ -19,6 +19,7 @@ import {
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OutboundDeliveryResult } from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
asDateTimestampMs,
@@ -28,7 +29,7 @@ import {
import { createPluginStateErrorReporter } from "openclaw/plugin-sdk/plugin-state-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { getIMessageApprovalApprovers, imessageApprovalAuth } from "./approval-auth.js";
import type { IMessageApprovalGatewayRuntime } from "./approval-resolver.js";
import type { IMessageApprovalGatewayRuntime } from "./approval-gateway-types.js";
import {
buildIMessageApprovalConversationKeyForInbound,
buildIMessageApprovalConversationKeyForTarget,
@@ -78,7 +79,9 @@ export type PendingIMessageApprovalReactionPollTarget = {
expiresAtMs: number;
};
const resolverRuntimeLoader = createLazyRuntimeModule(() => import("./approval-resolver.js"));
const resolverRuntimeLoader = createLazyRuntimeModule(
() => import("openclaw/plugin-sdk/approval-gateway-runtime"),
);
const pendingReactionPollTargets = new Map<string, PendingIMessageApprovalReactionPollTarget>();
const loadApprovalResolver = resolverRuntimeLoader;
@@ -684,13 +687,14 @@ export async function handleIMessageApprovalReaction(params: {
return { handled: true, stopPolling: false };
}
const { isApprovalNotFoundError, resolveIMessageApproval } = await loadApprovalResolver();
const { resolveApprovalOverGateway } = await loadApprovalResolver();
try {
const result = await resolveIMessageApproval({
const result = await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: target.approvalId,
approvalKind: target.approvalKind,
decision: target.decision,
channel: "imessage",
senderId: event.actorHandle,
gatewayUrl: params.gatewayUrl,
...(params.gatewayRuntime ? { gatewayRuntime: params.gatewayRuntime } : {}),
@@ -1,49 +0,0 @@
// Imessage tests cover the unified operator approval resolver.
import { beforeEach, describe, expect, it, vi } from "vitest";
const approvalGatewayRuntimeHoisted = vi.hoisted(() => ({
resolveApprovalOverGatewaySpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: (...args: unknown[]) =>
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy(...args),
}));
describe("resolveIMessageApproval", () => {
beforeEach(() => {
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy.mockReset();
});
it("returns canonical first-answer state with explicit ownership", async () => {
const result = {
applied: false,
approval: { status: "denied", decision: "deny", reason: "user" },
};
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy.mockResolvedValue(result);
const { resolveIMessageApproval } = await import("./approval-resolver.js");
const gatewayRuntime = { request: vi.fn() } as never;
await expect(
resolveIMessageApproval({
cfg: {} as never,
approvalId: "plugin:looks-like-a-plugin-but-is-exec",
approvalKind: "exec",
decision: "allow-once",
senderId: "+15551230000",
gatewayRuntime,
}),
).resolves.toBe(result);
expect(approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "plugin:looks-like-a-plugin-but-is-exec",
approvalKind: "exec",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
gatewayRuntime,
clientDisplayName: "iMessage approval (+15551230000)",
});
});
});
@@ -1,43 +0,0 @@
// Imessage plugin module implements approval resolver behavior.
import {
resolveApprovalOverGateway,
type ApprovalResolveResult,
} from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
export { isApprovalNotFoundError };
export type IMessageApprovalGatewayRuntime = {
request: (
method: "approval.resolve",
params: {
id: string;
kind: "exec" | "plugin" | "system-agent";
decision: ExecApprovalReplyDecision;
},
options?: { clientDisplayName?: string },
) => Promise<ApprovalResolveResult>;
};
export async function resolveIMessageApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
approvalKind: "exec" | "plugin";
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
gatewayRuntime?: IMessageApprovalGatewayRuntime;
}): Promise<ApprovalResolveResult> {
return await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
approvalKind: params.approvalKind,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
...(params.gatewayRuntime ? { gatewayRuntime: params.gatewayRuntime } : {}),
clientDisplayName: `iMessage approval (${params.senderId?.trim() || "unknown"})`,
});
}
@@ -51,10 +51,10 @@ import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-util
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
import { resolveIMessageAccount } from "../accounts.js";
import { iMessageApprovalControlBindings } from "../approval-control-binding-window.js";
import type { IMessageApprovalGatewayRuntime } from "../approval-gateway-types.js";
import { maybeResolveIMessageApprovalPollVote } from "../approval-polls.js";
import { pollPendingIMessageApprovalReactions } from "../approval-reaction-poller.js";
import { maybeResolveIMessageApprovalReaction } from "../approval-reactions.js";
import type { IMessageApprovalGatewayRuntime } from "../approval-resolver.js";
import { buildIMessageApprovalConversationKeyForInbound } from "../approval-target-keys.js";
import { markIMessageChatRead, sendIMessageTyping } from "../chat.js";
import { resolveIMessageHomeDir } from "../cli-path.js";
@@ -1,80 +0,0 @@
// Matrix tests cover exec approval resolver plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const approvalRuntimeHoisted = vi.hoisted(() => ({
resolveApprovalOverGatewaySpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: (...args: unknown[]) =>
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy(...args),
}));
describe("resolveMatrixApproval", () => {
beforeEach(() => {
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy.mockReset();
});
it("submits exec approval resolutions through the shared gateway resolver", async () => {
const result = {
applied: false,
approval: { status: "denied", decision: "deny" },
};
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy.mockResolvedValue(result);
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await expect(
resolveMatrixApproval({
cfg: {} as never,
approvalId: "req-123",
approvalKind: "exec",
decision: "allow-once",
senderId: "@owner:example.org",
}),
).resolves.toBe(result);
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "req-123",
approvalKind: "exec",
decision: "allow-once",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});
it("passes plugin approval ids through unchanged", async () => {
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await resolveMatrixApproval({
cfg: {} as never,
approvalId: "plugin:req-123",
approvalKind: "plugin",
decision: "deny",
senderId: "@owner:example.org",
});
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "plugin:req-123",
approvalKind: "plugin",
decision: "deny",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});
it("recognizes structured approval-not-found errors", async () => {
const { isApprovalNotFoundError } = await import("./exec-approval-resolver.js");
const err = new Error("approval not found");
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).gatewayCode =
"INVALID_REQUEST";
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).details = {
reason: "APPROVAL_NOT_FOUND",
};
expect(isApprovalNotFoundError(err)).toBe(true);
});
});
@@ -1,29 +0,0 @@
// Matrix plugin module implements exec approval resolver behavior.
import {
resolveApprovalOverGateway,
type ApprovalResolveResult,
} from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
export { isApprovalNotFoundError };
export async function resolveMatrixApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
approvalKind: "exec" | "plugin";
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
}): Promise<ApprovalResolveResult> {
return await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
approvalKind: params.approvalKind,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Matrix approval (${params.senderId?.trim() || "unknown"})`,
});
}
@@ -43,10 +43,12 @@ type MatrixReactionClient = MatrixReactionParams["client"];
type MatrixReactionCore = MatrixReactionParams["core"];
type MatrixReactionEvent = MatrixReactionParams["event"];
vi.mock("../../exec-approval-resolver.js", () => ({
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: (...args: unknown[]) => resolveMatrixApproval(...args),
}));
vi.mock("openclaw/plugin-sdk/error-runtime", () => ({
isApprovalNotFoundError: (err: unknown) =>
err instanceof Error && /unknown or expired approval id/i.test(err.message),
resolveMatrixApproval: (...args: unknown[]) => resolveMatrixApproval(...args),
}));
vi.mock("../send.js", () => ({
@@ -190,6 +192,7 @@ describe("matrix approval reactions", () => {
approvalId: "req-123",
approvalKind: "exec",
decision: "allow-once",
channel: "matrix",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
@@ -259,6 +262,7 @@ describe("matrix approval reactions", () => {
approvalId: "req-123",
approvalKind: "exec",
decision: "deny",
channel: "matrix",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
@@ -286,6 +290,7 @@ describe("matrix approval reactions", () => {
approvalId: "req-123",
approvalKind: "exec",
decision: "allow-once",
channel: "matrix",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
@@ -321,6 +326,7 @@ describe("matrix approval reactions", () => {
approvalId: "plugin:req-123",
approvalKind: "plugin",
decision: "allow-once",
channel: "matrix",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
@@ -1,5 +1,6 @@
// Matrix plugin module implements reaction events behavior.
import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { getSessionBindingService } from "openclaw/plugin-sdk/session-binding-runtime";
@@ -21,7 +22,7 @@ const loadApprovalReactionAuth = createLazyRuntimeModule(
);
const loadExecApprovalResolver = createLazyRuntimeModule(
() => import("../../exec-approval-resolver.js"),
() => import("openclaw/plugin-sdk/approval-gateway-runtime"),
);
const loadMatrixSend = createLazyRuntimeModule(() => import("../send.js"));
@@ -120,13 +121,14 @@ async function maybeResolveMatrixApprovalReaction(params: {
) {
return false;
}
const { isApprovalNotFoundError, resolveMatrixApproval } = await loadExecApprovalResolver();
const { resolveApprovalOverGateway } = await loadExecApprovalResolver();
try {
const result = await resolveMatrixApproval({
const result = await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.target.approvalId,
approvalKind: params.target.approvalKind,
decision: params.target.decision,
channel: "matrix",
senderId: params.senderId,
});
// Retire every delivered anchor; losing surfaces also need the canonical
+1 -12
View File
@@ -12,7 +12,7 @@ import {
normalizeOptionalSecretInput,
type OpenClawConfig,
type SecretInput,
upsertAuthProfileWithLock,
upsertAuthProfileWithLockOrThrow,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth-api-key";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-onboard";
@@ -33,17 +33,6 @@ type PixVerseAuthResult = {
notes: string[];
};
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
function normalizePixVerseRegion(value: unknown): PixVerseApiRegion | undefined {
const region = normalizeOptionalString(value)?.toLowerCase();
switch (region) {
@@ -20,8 +20,10 @@ const resolverMocks = vi.hoisted(() => ({
isApprovalNotFoundError: vi.fn(() => false),
}));
vi.mock("./approval-resolver.js", () => ({
resolveSignalApproval: resolverMocks.resolveSignalApproval,
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: resolverMocks.resolveSignalApproval,
}));
vi.mock("openclaw/plugin-sdk/error-runtime", () => ({
isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError,
}));
@@ -758,6 +760,7 @@ describe("Signal approval reactions", () => {
approvalId: "plugin:abc",
approvalKind: "plugin",
decision: "allow-once",
channel: "signal",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -818,6 +821,7 @@ describe("Signal approval reactions", () => {
approvalId: "exec-default-to",
approvalKind: "exec",
decision: "allow-once",
channel: "signal",
senderId: "+15551230000",
gatewayUrl: undefined,
});
+7 -3
View File
@@ -17,6 +17,7 @@ import {
type ExecApprovalReplyDecision,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { createPluginStateErrorReporter } from "openclaw/plugin-sdk/plugin-state-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
@@ -82,7 +83,9 @@ type SignalApprovalDeliveryResult = {
meta?: Record<string, unknown>;
};
const resolverRuntimeLoader = createLazyRuntimeModule(() => import("./approval-resolver.js"));
const resolverRuntimeLoader = createLazyRuntimeModule(
() => import("openclaw/plugin-sdk/approval-gateway-runtime"),
);
const reportPersistentApprovalReactionError = createPluginStateErrorReporter(
getOptionalSignalRuntime,
@@ -907,13 +910,14 @@ export async function maybeResolveSignalApprovalReaction(params: {
return true;
}
const { isApprovalNotFoundError, resolveSignalApproval } = await loadApprovalResolver();
const { resolveApprovalOverGateway } = await loadApprovalResolver();
try {
const result = await resolveSignalApproval({
const result = await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: target.approvalId,
approvalKind: target.approvalKind,
decision: target.decision,
channel: "signal",
senderId: actorId,
gatewayUrl: params.gatewayUrl,
});
@@ -1,29 +0,0 @@
// Signal plugin module implements approval resolver behavior.
import {
resolveApprovalOverGateway,
type ApprovalResolveResult,
} from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
export { isApprovalNotFoundError };
export async function resolveSignalApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
approvalKind: "exec" | "plugin";
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
}): Promise<ApprovalResolveResult> {
return await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
approvalKind: params.approvalKind,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Signal approval (${params.senderId?.trim() || "unknown"})`,
});
}
@@ -643,8 +643,8 @@ async function handleSlackApprovalInteraction(params: {
approvalId: params.approval.approvalId,
approvalKind: params.approval.approvalKind,
decision: params.approval.decision,
channel: "slack",
senderId: params.parsed.userId,
clientDisplayName: `Slack approval (${params.parsed.userId.trim() || "unknown"})`,
});
const terminalLabel = resolveSlackApprovalTerminalLabel(result.approval);
const prefix = result.applied ? "Resolved" : "Already resolved";
@@ -736,9 +736,9 @@ async function handleSlackLegacyApprovalInteraction(params: {
cfg: params.ctx.cfg,
approvalId: parsedApproval.approvalId,
decision: parsedApproval.decision,
channel: "slack",
senderId: params.parsed.userId,
resolveMethod,
clientDisplayName: `Slack approval (${params.parsed.userId.trim() || "unknown"})`,
});
try {
await updateSlackInteractionMessage({
@@ -1490,7 +1490,7 @@ describe("registerSlackInteractionEvents", () => {
approvalKind: "exec",
decision: "allow-once",
senderId: "U123",
clientDisplayName: "Slack approval (U123)",
channel: "slack",
});
expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
@@ -1618,7 +1618,7 @@ describe("registerSlackInteractionEvents", () => {
approvalKind: "exec",
decision: "allow-once",
senderId: "U123",
clientDisplayName: "Slack approval (U123)",
channel: "slack",
});
expectRecordFields(chatUpdateCall(app), {
channel: "C1",
@@ -1821,7 +1821,7 @@ describe("registerSlackInteractionEvents", () => {
approvalKind: "plugin",
decision: "allow-always",
senderId: "U123OWNER",
clientDisplayName: "Slack approval (U123OWNER)",
channel: "slack",
});
expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
@@ -1900,7 +1900,7 @@ describe("registerSlackInteractionEvents", () => {
decision: "allow-once",
senderId: "U123OWNER",
resolveMethod: "plugin",
clientDisplayName: "Slack approval (U123OWNER)",
channel: "slack",
});
expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
@@ -1973,7 +1973,7 @@ describe("registerSlackInteractionEvents", () => {
approvalId: "req-legacy",
decision: "allow-once",
senderId: "U123OWNER",
clientDisplayName: "Slack approval (U123OWNER)",
channel: "slack",
};
expect(resolveApprovalOverGatewayMock).toHaveBeenNthCalledWith(1, {
...expectedCommon,
@@ -2052,7 +2052,7 @@ describe("registerSlackInteractionEvents", () => {
decision: "allow-always",
senderId: "U999EXEC",
resolveMethod: "exec",
clientDisplayName: "Slack approval (U999EXEC)",
channel: "slack",
});
expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
+30 -8
View File
@@ -1,10 +1,16 @@
// Telegram plugin module implements bot deps behavior.
import {
resolveApprovalOverGateway,
type ApprovalResolveResult,
} from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
import {
createChannelMessageReplyPipeline,
deliverInboundReplyWithMessageSendContext,
} from "openclaw/plugin-sdk/channel-outbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import {
recordInboundSession,
@@ -28,14 +34,30 @@ import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { syncTelegramMenuCommands } from "./bot-native-command-menu.js";
import { deliverReplies, emitTelegramMessageSentHooks } from "./bot/delivery.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import {
resolveTelegramApproval,
resolveTelegramLegacyApproval,
} from "./exec-approval-resolver.js";
import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js";
import { editMessageTelegram } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
type ResolveTelegramApproval = (params: {
cfg: OpenClawConfig;
approvalId: string;
approvalKind: "exec" | "plugin";
decision: ExecApprovalReplyDecision;
channel: "telegram";
senderId?: string | null;
gatewayUrl?: string;
}) => Promise<ApprovalResolveResult>;
type ResolveTelegramLegacyApproval = (params: {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalReplyDecision;
channel: "telegram";
senderId?: string | null;
gatewayUrl?: string;
resolveMethod: "exec" | "plugin";
}) => Promise<void>;
export type TelegramBotDeps = {
getRuntimeConfig: typeof getRuntimeConfig;
resolveStorePath: typeof resolveStorePath;
@@ -57,8 +79,8 @@ export type TelegramBotDeps = {
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
syncTelegramMenuCommands?: typeof syncTelegramMenuCommands;
wasSentByBot: typeof wasSentByBot;
resolveApproval?: typeof resolveTelegramApproval;
resolveLegacyApproval?: typeof resolveTelegramLegacyApproval;
resolveApproval?: ResolveTelegramApproval;
resolveLegacyApproval?: ResolveTelegramLegacyApproval;
createTelegramDraftStream?: typeof createTelegramDraftStream;
deliverReplies?: typeof deliverReplies;
deliverInboundReplyWithMessageSendContext?: typeof deliverInboundReplyWithMessageSendContext;
@@ -130,10 +152,10 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
return wasSentByBot;
},
get resolveApproval() {
return resolveTelegramApproval;
return resolveApprovalOverGateway;
},
get resolveLegacyApproval() {
return resolveTelegramLegacyApproval;
return resolveApprovalOverGateway;
},
get createTelegramDraftStream() {
return createTelegramDraftStream;
@@ -1,3 +1,4 @@
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
@@ -14,10 +15,6 @@ import {
TelegramRetryableCallbackError,
} from "./bot-handlers.callback-errors.runtime.js";
import type { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
import {
resolveTelegramApproval,
resolveTelegramLegacyApproval,
} from "./exec-approval-resolver.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalAuthorizedSender,
@@ -90,11 +87,12 @@ export function createTelegramCallbackApprovalRuntime(params: {
};
const resolveCanonicalApproval = async (approvalCallback: TelegramApprovalCallback) =>
await (telegramDeps.resolveApproval ?? resolveTelegramApproval)({
await (telegramDeps.resolveApproval ?? resolveApprovalOverGateway)({
cfg: runtimeCfg,
approvalId: approvalCallback.approvalId,
approvalKind: approvalCallback.approvalKind,
decision: approvalCallback.decision,
channel: "telegram",
senderId,
});
@@ -178,7 +176,7 @@ export function createTelegramCallbackApprovalRuntime(params: {
return;
}
const resolveLegacy = telegramDeps.resolveLegacyApproval ?? resolveTelegramLegacyApproval;
const resolveLegacy = telegramDeps.resolveLegacyApproval ?? resolveApprovalOverGateway;
for (const approvalKind of approvalKinds) {
const canonicalCallback: TelegramApprovalCallback = {
type: "approval",
@@ -191,9 +189,10 @@ export function createTelegramCallbackApprovalRuntime(params: {
await resolveLegacy({
cfg: runtimeCfg,
approvalId: approvalCallback.approvalId,
approvalKind,
decision: approvalCallback.decision,
channel: "telegram",
senderId,
resolveMethod: approvalKind,
});
await terminalizeApprovalMessage(
buildTelegramLegacyApprovalTerminalText({
@@ -22,7 +22,11 @@ type ReadSessionUpdatedAtFn =
type SessionEntry = import("openclaw/plugin-sdk/session-store-runtime").SessionEntry;
type SessionStore = Record<string, SessionEntry>;
type LoadSessionStoreFn = (storePath?: string, opts?: unknown) => SessionStore;
type ResolveTelegramApprovalForTest = NonNullable<TelegramBotDeps["resolveApproval"]>;
type ResolveTelegramApprovalForTest = (
params:
| Parameters<NonNullable<TelegramBotDeps["resolveApproval"]>>[0]
| Parameters<NonNullable<TelegramBotDeps["resolveLegacyApproval"]>>[0],
) => ReturnType<NonNullable<TelegramBotDeps["resolveApproval"]>>;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
+9 -9
View File
@@ -1803,7 +1803,7 @@ describe("createTelegramBot", () => {
expect(execApprovals.approvers).toEqual(["9"]);
expect(execApprovals.target).toBe("dm");
expect(approvalCall.approvalId).toBe("138e9b8c");
expect(approvalCall.approvalKind).toBe("exec");
expect(approvalCall.resolveMethod).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(replySpy).not.toHaveBeenCalled();
@@ -2025,7 +2025,7 @@ describe("createTelegramBot", () => {
assertDistinctResult: () => {
expect(execApprovalCall(0)).toMatchObject({
approvalId: "stale-legacy-id",
approvalKind: "exec",
resolveMethod: "exec",
});
expect(execApprovalCall(1)).toMatchObject({
approvalId: "stale-legacy-id",
@@ -2149,12 +2149,12 @@ describe("createTelegramBot", () => {
expect(execApprovals.approvers).toEqual(["9"]);
expect(execApprovals.target).toBe("dm");
expect(approvalCall.approvalId).toBe("opaque-plugin-approval-id");
expect(approvalCall.approvalKind).toBe("exec");
expect(approvalCall.resolveMethod).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(execApprovalCall(1)).toMatchObject({
approvalId: "opaque-plugin-approval-id",
approvalKind: "plugin",
resolveMethod: "plugin",
decision: "allow-once",
senderId: "9",
});
@@ -2268,7 +2268,7 @@ describe("createTelegramBot", () => {
expect(execApprovals.enabled).toBe(true);
expect(execApprovals.mode).toBe("targets");
expect(approvalCall.approvalId).toBe("plugin:misleading-exec-id");
expect(approvalCall.approvalKind).toBe("exec");
expect(approvalCall.resolveMethod).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(1);
@@ -2313,7 +2313,7 @@ describe("createTelegramBot", () => {
expect(execApprovals.enabled).toBe(true);
expect(execApprovals.mode).toBe("targets");
expect(approvalCall.approvalId).toBe("138e9b8c");
expect(approvalCall.approvalKind).toBe("exec");
expect(approvalCall.resolveMethod).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(1);
@@ -2343,11 +2343,11 @@ describe("createTelegramBot", () => {
const approvalCall = execApprovalCall();
expect(approvalCall.approvalId).toBe("138e9b8c");
expect(approvalCall.approvalKind).toBe("exec");
expect(approvalCall.resolveMethod).toBe("exec");
expect(approvalCall.decision).toBe("allow-once");
expect(approvalCall.senderId).toBe("9");
expect(resolveExecApprovalSpy).toHaveBeenCalledTimes(2);
expect(execApprovalCall(1).approvalKind).toBe("plugin");
expect(execApprovalCall(1).resolveMethod).toBe("plugin");
expect(editMessageTextSpy).toHaveBeenCalledWith(
1234,
26,
@@ -2391,7 +2391,7 @@ describe("createTelegramBot", () => {
expect(execApprovalCall()).toMatchObject({
approvalId: "plugin:138e9b8c",
approvalKind: "exec",
resolveMethod: "exec",
decision: "allow-once",
senderId: "9",
});
@@ -1,77 +0,0 @@
// Telegram tests cover the unified operator approval resolver.
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const approvalGatewayRuntimeHoisted = vi.hoisted(() => ({
resolveApprovalOverGatewaySpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: (...args: unknown[]) =>
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy(...args),
}));
describe("resolveTelegramApproval", () => {
beforeEach(() => {
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy.mockReset();
});
it.each([
["exec", "plugin:id-that-still-belongs-to-exec", "allow-once"],
["plugin", "plain-plugin-id", "allow-always"],
] as const)(
"passes explicit %s ownership without inferring it from %s",
async (approvalKind, approvalId, decision) => {
const result = {
applied: false,
approval: { status: "denied", decision: "deny" },
};
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy.mockResolvedValue(result);
const { resolveTelegramApproval } = await import("./exec-approval-resolver.js");
await expect(
resolveTelegramApproval({
cfg: {} as never,
gatewayUrl: undefined,
approvalId,
approvalKind,
decision: decision as ExecApprovalReplyDecision,
senderId: "9",
}),
).resolves.toBe(result);
expect(approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId,
approvalKind,
decision,
senderId: "9",
gatewayUrl: undefined,
clientDisplayName: "Telegram approval (9)",
});
},
);
it("keeps command/value compatibility on an explicit legacy adapter", async () => {
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy.mockResolvedValue(undefined);
const { resolveTelegramLegacyApproval } = await import("./exec-approval-resolver.js");
await resolveTelegramLegacyApproval({
cfg: {} as never,
approvalId: "legacy-plugin-id",
approvalKind: "plugin",
decision: "deny",
senderId: "9",
});
expect(approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "legacy-plugin-id",
decision: "deny",
senderId: "9",
gatewayUrl: undefined,
resolveMethod: "plugin",
clientDisplayName: "Telegram approval (9)",
});
});
});
@@ -1,49 +0,0 @@
// Telegram plugin module resolves typed operator approvals through the Gateway.
import {
resolveApprovalOverGateway,
type ApprovalResolveResult,
} from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
type ResolveTelegramApprovalParams = {
cfg: OpenClawConfig;
approvalId: string;
approvalKind: "exec" | "plugin";
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
};
type ResolveTelegramLegacyApprovalParams = Omit<ResolveTelegramApprovalParams, "approvalKind"> & {
approvalKind: "exec" | "plugin";
};
export async function resolveTelegramApproval(
params: ResolveTelegramApprovalParams,
): Promise<ApprovalResolveResult> {
return await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
approvalKind: params.approvalKind,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Telegram approval (${params.senderId?.trim() || "unknown"})`,
});
}
/** Compatibility resolver for command/value buttons that predate typed approval actions. */
export async function resolveTelegramLegacyApproval(
params: ResolveTelegramLegacyApprovalParams,
): Promise<void> {
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
resolveMethod: params.approvalKind,
clientDisplayName: `Telegram approval (${params.senderId?.trim() || "unknown"})`,
});
}
@@ -17,8 +17,10 @@ const resolverMocks = vi.hoisted(() => ({
isApprovalNotFoundError: vi.fn(() => false),
}));
vi.mock("./approval-resolver.js", () => ({
resolveWhatsAppApproval: resolverMocks.resolveWhatsAppApproval,
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: resolverMocks.resolveWhatsAppApproval,
}));
vi.mock("openclaw/plugin-sdk/error-runtime", () => ({
isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError,
}));
@@ -175,6 +177,7 @@ describe("WhatsApp approval reactions", () => {
approvalId: "plugin:abc",
approvalKind: "plugin",
decision: "allow-once",
channel: "whatsapp",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -251,6 +254,7 @@ describe("WhatsApp approval reactions", () => {
approvalId: "exec-self",
approvalKind: "exec",
decision: "allow-once",
channel: "whatsapp",
senderId: "+15551230001",
gatewayUrl: undefined,
});
@@ -315,6 +319,7 @@ describe("WhatsApp approval reactions", () => {
approvalId: "exec-direct",
approvalKind: "exec",
decision: "allow-once",
channel: "whatsapp",
senderId: testCase.actorId,
gatewayUrl: undefined,
});
@@ -15,6 +15,7 @@ import {
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OutboundDeliveryResult } from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { createPluginStateErrorReporter } from "openclaw/plugin-sdk/plugin-state-runtime";
@@ -55,7 +56,9 @@ type ResolvedWhatsAppApprovalReactionTarget = WhatsAppApprovalReactionResolution
remoteJid: string;
};
const resolverRuntimeLoader = createLazyRuntimeModule(() => import("./approval-resolver.js"));
const resolverRuntimeLoader = createLazyRuntimeModule(
() => import("openclaw/plugin-sdk/approval-gateway-runtime"),
);
const reportPersistentApprovalReactionError = createPluginStateErrorReporter(
getOptionalWhatsAppRuntime,
@@ -508,13 +511,14 @@ export async function maybeResolveWhatsAppApprovalReaction(params: {
return true;
}
const { isApprovalNotFoundError, resolveWhatsAppApproval } = await loadApprovalResolver();
const { resolveApprovalOverGateway } = await loadApprovalResolver();
try {
const result = await resolveWhatsAppApproval({
const result = await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: target.approvalId,
approvalKind: target.approvalKind,
decision: target.decision,
channel: "whatsapp",
senderId: actorId,
gatewayUrl: params.gatewayUrl,
});
@@ -1,46 +0,0 @@
// WhatsApp tests cover canonical multi-surface approval resolver outcomes.
import { beforeEach, describe, expect, it, vi } from "vitest";
const approvalGatewayRuntimeHoisted = vi.hoisted(() => ({
resolveApprovalOverGatewaySpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: (...args: unknown[]) =>
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy(...args),
}));
describe("resolveWhatsAppApproval", () => {
beforeEach(() => {
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy.mockReset();
});
it("returns the canonical first-answer result without inferring ownership from the id", async () => {
const result = {
applied: false,
approval: { status: "denied", decision: "deny" },
};
approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy.mockResolvedValue(result);
const { resolveWhatsAppApproval } = await import("./approval-resolver.js");
await expect(
resolveWhatsAppApproval({
cfg: {} as never,
approvalId: "plugin:looks-plugin-but-is-exec",
approvalKind: "exec",
decision: "allow-once",
senderId: "+15551230000",
}),
).resolves.toBe(result);
expect(approvalGatewayRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {},
approvalId: "plugin:looks-plugin-but-is-exec",
approvalKind: "exec",
decision: "allow-once",
senderId: "+15551230000",
gatewayUrl: undefined,
clientDisplayName: "WhatsApp approval (+15551230000)",
});
});
});
@@ -1,29 +0,0 @@
// Whatsapp plugin module implements approval resolver behavior.
import {
resolveApprovalOverGateway,
type ApprovalResolveResult,
} from "openclaw/plugin-sdk/approval-gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
export { isApprovalNotFoundError };
export async function resolveWhatsAppApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
approvalKind: "exec" | "plugin";
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
}): Promise<ApprovalResolveResult> {
return await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
approvalKind: params.approvalKind,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `WhatsApp approval (${params.senderId?.trim() || "unknown"})`,
});
}
+1 -12
View File
@@ -16,7 +16,7 @@ import {
normalizeApiKeyInput,
normalizeOptionalSecretInput,
type SecretInput,
upsertAuthProfileWithLock,
upsertAuthProfileWithLockOrThrow,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
@@ -42,8 +42,6 @@ import { buildXiaomiSpeechProvider } from "./speech-provider.js";
import { createMiMoThinkingWrapper } from "./stream.js";
import { resolveMiMoThinkingProfile } from "./thinking.js";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
const PAYG_FLAG_NAME = "--xiaomi-api-key";
const PAYG_OPTION_KEY = "xiaomiApiKey";
const PAYG_ENV_VAR = "XIAOMI_API_KEY";
@@ -120,15 +118,6 @@ async function resolveXiaomiCatalog(params: {
};
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
function buildXiaomiKeyMismatchMessage(params: {
actualKey: string;
expectedKind: "payg" | "token-plan";
+1 -12
View File
@@ -16,7 +16,7 @@ import {
normalizeApiKeyInput,
normalizeOptionalSecretInput,
type SecretInput,
upsertAuthProfileWithLock,
upsertAuthProfileWithLockOrThrow,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth-api-key";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
@@ -41,8 +41,6 @@ import { isGlm52ModelId, resolveThinkingProfile } from "./provider-policy-api.js
const PROVIDER_ID = "zai";
const GLM5_TEMPLATE_MODEL_ID = "glm-4.7";
const PROFILE_ID = "zai:default";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
function resolveDeprecatedPiAgentAuthPath(env: NodeJS.ProcessEnv): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
return path.join(home, ".pi", "agent", "auth.json");
@@ -71,15 +69,6 @@ function resolveDeprecatedPiAgentAccessToken(
return undefined;
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
function resolveGlm5ForwardCompatModel(ctx: ProviderResolveDynamicModelContext) {
return resolveFamilyForwardCompatModel({
providerId: PROVIDER_ID,
+1
View File
@@ -43,6 +43,7 @@ export {
setAuthProfileOrder,
upsertAuthProfile,
upsertAuthProfileWithLock,
upsertAuthProfileWithLockOrThrow,
} from "./auth-profiles/profiles.js";
export {
repairOAuthProfileIdMismatch,
+1 -1
View File
@@ -24,7 +24,7 @@ export {
listProfilesForProvider,
resolveSubscriptionAuthModeForProfiles,
} from "./profile-list.js";
export { upsertAuthProfileWithLock } from "./upsert-with-lock.js";
export { upsertAuthProfileWithLock, upsertAuthProfileWithLockOrThrow } from "./upsert-with-lock.js";
const authProfileProfilesLog = createSubsystemLogger("agent/embedded");
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => ({
updateAuthProfileStoreWithLock: vi.fn(),
}));
vi.mock("./credential-normalize.js", () => ({
normalizeAuthProfileCredential: (credential: unknown) => credential,
}));
vi.mock("./store.js", () => ({
updateAuthProfileStoreWithLock: hoisted.updateAuthProfileStoreWithLock,
}));
import { upsertAuthProfileWithLockOrThrow } from "./upsert-with-lock.js";
describe("upsertAuthProfileWithLockOrThrow", () => {
beforeEach(() => {
hoisted.updateAuthProfileStoreWithLock.mockReset();
});
it("resolves after the locked store update succeeds", async () => {
hoisted.updateAuthProfileStoreWithLock.mockResolvedValue({ version: 1, profiles: {} });
await expect(
upsertAuthProfileWithLockOrThrow({
profileId: "test:default",
credential: { type: "token", provider: "test", token: "secret" },
}),
).resolves.toBeUndefined();
});
it("fails with the canonical retry guidance when the locked update fails", async () => {
hoisted.updateAuthProfileStoreWithLock.mockResolvedValue(null);
await expect(
upsertAuthProfileWithLockOrThrow({
profileId: "test:default",
credential: { type: "token", provider: "test", token: "secret" },
}),
).rejects.toThrow(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
});
});
@@ -28,3 +28,15 @@ export async function upsertAuthProfileWithLock(params: {
},
});
}
/** Upserts an auth profile under the store lock, failing when the store cannot be written. */
export async function upsertAuthProfileWithLockOrThrow(
params: Parameters<typeof upsertAuthProfileWithLock>[0],
): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
@@ -47,6 +47,7 @@ const upsertAuthProfile = vi.hoisted(() => vi.fn(() => ({ version: 1, profiles:
vi.mock("../agents/auth-profiles.js", () => ({
upsertAuthProfile,
upsertAuthProfileWithLock: upsertAuthProfile,
upsertAuthProfileWithLockOrThrow: upsertAuthProfile,
}));
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "default"));
+7
View File
@@ -201,6 +201,13 @@ vi.mock("../agents/auth-profiles.js", () => ({
seedTestAuthProfile(params);
return { version: 1, profiles: readTestAuthProfileStore(params.agentDir).profiles };
},
upsertAuthProfileWithLockOrThrow: async (params: {
profileId: string;
credential: StoredAuthProfile;
agentDir?: string;
}) => {
seedTestAuthProfile(params);
},
}));
function normalizeText(value: unknown): string {
+1
View File
@@ -75,6 +75,7 @@ vi.mock("../../agents/auth-profiles/profiles.js", () => ({
removeProviderAuthProfilesWithLock: mocks.removeProviderAuthProfilesWithLock,
upsertAuthProfile: mocks.upsertAuthProfile,
upsertAuthProfileWithLock: mocks.upsertAuthProfileWithLock,
upsertAuthProfileWithLockOrThrow: mocks.upsertAuthProfileWithLock,
}));
vi.mock("../../agents/auth-profiles/store.js", () => ({
+1 -12
View File
@@ -28,7 +28,7 @@ import {
import {
listProfilesForProvider,
promoteAuthProfileInOrder,
upsertAuthProfileWithLock,
upsertAuthProfileWithLockOrThrow,
} from "../../agents/auth-profiles/profiles.js";
import { loadAuthProfileStoreForRuntime } from "../../agents/auth-profiles/store.js";
import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
@@ -71,8 +71,6 @@ import { repairCopilotRuntimePluginInstallForModelSelection } from "../copilot-r
import { refreshRunningGatewayAuthState } from "./auth-refresh.js";
import { loadValidConfigOrThrow, resolveKnownAgentId, updateConfig } from "./shared.js";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
function resolveManualTokenExpiryMs(expiresIn: string | undefined): number | undefined {
const normalizedExpiresIn = normalizeStringifiedOptionalString(expiresIn);
if (!normalizedExpiresIn) {
@@ -779,15 +777,6 @@ export async function modelsAuthPasteApiKeyCommand(
runtime.log(`Auth profile: ${profileId} (${provider}/api_key)`);
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
/** Interactive helper for adding token auth profiles, with provider/method prompts. */
export async function modelsAuthAddCommand(opts: { agent?: string }, runtime: RuntimeEnv) {
const { config, agentDir, workspaceDir, providers } = await resolveModelsAuthContext({
+7
View File
@@ -76,6 +76,13 @@ vi.mock("../agents/auth-profiles/profiles.js", async () => {
upsert(params);
return { version: 1, profiles: {} };
},
upsertAuthProfileWithLockOrThrow: async (params: {
profileId: string;
credential: unknown;
agentDir?: string;
}) => {
upsert(params);
},
};
});
@@ -78,6 +78,37 @@ describe("resolveApprovalOverGateway", () => {
expect(result).toEqual({ applied: true, approval: recordedApproval });
});
it.each([
["signal", "Signal"],
["whatsapp", "WhatsApp"],
["matrix", "Matrix"],
["imessage", "iMessage"],
["telegram", "Telegram"],
["discord", "Discord"],
["googlechat", "Google Chat"],
["slack", "Slack"],
] as const)(
"derives the %s approval client label from channel metadata",
async (channel, label) => {
await resolveApprovalOverGateway({
cfg: {} as never,
approvalId: "approval-1",
approvalKind: "exec",
decision: "deny",
channel,
senderId: "owner",
});
const [gatewayClientOptions] = requireFirstMockCall(
hoisted.withOperatorApprovalsGatewayClient,
"gateway client",
);
expect(gatewayClientOptions).toMatchObject({
clientDisplayName: `${label} approval (owner)`,
});
},
);
it("uses explicit plugin kind without inspecting the approval id", async () => {
await resolveApprovalOverGateway({
cfg: {} as never,
+18 -8
View File
@@ -6,6 +6,7 @@ import type {
ApprovalResolveResult,
} from "../../packages/gateway-protocol/src/index.js";
import { isWellFormedApprovalId } from "../../packages/gateway-protocol/src/schema/approvals.js";
import { findChatChannelMeta } from "../channels/chat-meta.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { withOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
import { isApprovalNotFoundError } from "./approval-errors.js";
@@ -16,21 +17,24 @@ type ResolveApprovalOverGatewayBaseParams = {
cfg: OpenClawConfig;
approvalId: string;
decision: ApprovalDecision;
channel?: string;
senderId?: string | null;
gatewayUrl?: string;
clientDisplayName?: string;
};
type ApprovalGatewayRuntime = {
request: (
method: "approval.resolve",
params: ApprovalResolveParams,
options?: { clientDisplayName?: string },
) => Promise<ApprovalResolveResult>;
};
type CanonicalResolveApprovalOverGatewayParams = ResolveApprovalOverGatewayBaseParams & {
/** Explicit owner required by the canonical approval resolver. */
approvalKind: ApprovalKind;
gatewayRuntime?: {
request: (
method: "approval.resolve",
params: ApprovalResolveParams,
options?: { clientDisplayName?: string },
) => Promise<ApprovalResolveResult>;
};
gatewayRuntime?: ApprovalGatewayRuntime;
allowPluginFallback?: never;
resolveMethod?: never;
};
@@ -102,8 +106,14 @@ export async function resolveApprovalOverGateway(
if (typeof approvalId !== "string" || !isWellFormedApprovalId(approvalId)) {
throw new Error("approval resolution requires an approval id");
}
const senderId = params.senderId?.trim() || "unknown";
const channel = params.channel?.trim();
// Channel manifests own operator-facing labels; using their generated metadata
// keeps approval clients aligned without importing plugin runtime or hardcoding ids.
const channelLabel = channel ? (findChatChannelMeta(channel)?.label ?? channel) : undefined;
const clientDisplayName =
params.clientDisplayName ?? `Approval (${params.senderId?.trim() || "unknown"})`;
params.clientDisplayName ??
(channelLabel ? `${channelLabel} approval (${senderId})` : `Approval (${senderId})`);
const canonicalGatewayRuntime = (params as CanonicalResolveApprovalOverGatewayParams)
.gatewayRuntime;
+5 -1
View File
@@ -4,7 +4,11 @@
export type { OpenClawConfig } from "../config/config.js";
export type { SecretInput } from "../config/types.secrets.js";
export { upsertAuthProfile, upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js";
export {
upsertAuthProfile,
upsertAuthProfileWithLock,
upsertAuthProfileWithLockOrThrow,
} from "../agents/auth-profiles/profiles.js";
export {
formatApiKeyPreview,
normalizeApiKeyInput,
+1 -12
View File
@@ -1,7 +1,7 @@
/** Builds API-key provider auth methods that write profiles and config updates. */
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js";
import { upsertAuthProfileWithLockOrThrow } from "../agents/auth-profiles/profiles.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SecretInput } from "../config/types.secrets.js";
@@ -39,8 +39,6 @@ type ProviderApiKeyAuthMethodOptions = {
applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig;
};
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
const loadProviderApiKeyAuthRuntime = createLazyRuntimeSurface(
() => import("./provider-api-key-auth.runtime.js"),
({ providerApiKeyAuthRuntime }) => providerApiKeyAuthRuntime,
@@ -66,15 +64,6 @@ function resolveProfileIds(params: {
return [resolveProfileId(params)];
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
async function applyApiKeyConfig(params: {
ctx: ProviderAuthMethodNonInteractiveContext;
providerId: string;
+1 -11
View File
@@ -5,7 +5,7 @@ import {
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js";
import { upsertAuthProfileWithLockOrThrow } from "../agents/auth-profiles.js";
import { formatLiteralProviderPrefixedModelRef } from "../agents/model-ref-shared.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { normalizeAgentModelRefForConfig } from "../config/model-input.js";
@@ -31,8 +31,6 @@ import type {
ProviderPlugin,
} from "./types.js";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
type ApplyProviderAuthChoiceParams = {
authChoice: string;
config: OpenClawConfig;
@@ -643,11 +641,3 @@ export async function applyAuthChoiceLoadedPluginProvider(
...(prepared.retrySelection ? { retrySelection: true } : {}),
};
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
+5 -12
View File
@@ -4,7 +4,11 @@ import path from "node:path";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js";
import { buildAuthProfileId } from "../agents/auth-profiles/identity.js";
import { upsertAuthProfile, upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js";
import {
upsertAuthProfile,
upsertAuthProfileWithLock,
upsertAuthProfileWithLockOrThrow,
} from "../agents/auth-profiles/profiles.js";
import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -22,8 +26,6 @@ import { isValidSecretRef } from "../secrets/ref-contract.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import type { SecretInputMode } from "./provider-auth-types.js";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
const resolveAuthAgentDir = (agentDir?: string, config?: OpenClawConfig) =>
agentDir ?? resolveDefaultAgentDir(config ?? {});
@@ -140,15 +142,6 @@ export function upsertApiKeyProfile(params: {
return profileId;
}
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
const updated = await upsertAuthProfileWithLock(params);
if (!updated) {
throw new Error(
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
);
}
}
export function applyAuthProfileConfig(
cfg: OpenClawConfig,
params: {