mirror of https://github.com/openclaw/openclaw.git
fix(approvals): centralize native request binding
This commit is contained in:
parent
2523e25c93
commit
584db0aff2
|
|
@ -143,4 +143,29 @@ describe("createDiscordNativeApprovalAdapter", () => {
|
|||
|
||||
expect(target).toEqual({ to: "987654321" });
|
||||
});
|
||||
|
||||
it("rejects origin delivery for requests bound to another Discord account", async () => {
|
||||
const adapter = createDiscordNativeApprovalAdapter();
|
||||
|
||||
const target = await adapter.native?.resolveOriginTarget?.({
|
||||
cfg: {} as never,
|
||||
accountId: "main",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
id: "abc",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Let plugin proceed",
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceTo: "channel:123456789",
|
||||
turnSourceAccountId: "other",
|
||||
sessionKey: "agent:main:missing",
|
||||
},
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { createApproverRestrictedNativeApprovalAdapter, resolveExecApprovalSessionTarget } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import {
|
||||
createApproverRestrictedNativeApprovalAdapter,
|
||||
doesApprovalRequestMatchChannelAccount,
|
||||
resolveApprovalRequestSessionTarget,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalSessionTarget,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
|
||||
import {
|
||||
getDiscordExecApprovalApprovers,
|
||||
|
|
@ -34,29 +37,6 @@ function extractDiscordSessionKind(sessionKey?: string | null): "channel" | "gro
|
|||
return match[1] as "channel" | "group" | "dm";
|
||||
}
|
||||
|
||||
function isExecApprovalRequest(request: ApprovalRequest): request is ExecApprovalRequest {
|
||||
return "command" in request.request;
|
||||
}
|
||||
|
||||
function toExecLikeRequest(request: ApprovalRequest): ExecApprovalRequest {
|
||||
if (isExecApprovalRequest(request)) {
|
||||
return request;
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
request: {
|
||||
command: request.request.title,
|
||||
sessionKey: request.request.sessionKey ?? undefined,
|
||||
turnSourceChannel: request.request.turnSourceChannel ?? undefined,
|
||||
turnSourceTo: request.request.turnSourceTo ?? undefined,
|
||||
turnSourceAccountId: request.request.turnSourceAccountId ?? undefined,
|
||||
turnSourceThreadId: request.request.turnSourceThreadId ?? undefined,
|
||||
},
|
||||
createdAtMs: request.createdAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDiscordOriginChannelId(value?: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
|
|
@ -76,15 +56,7 @@ function resolveRequestSessionTarget(params: {
|
|||
cfg: OpenClawConfig;
|
||||
request: ApprovalRequest;
|
||||
}): ExecApprovalSessionTarget | null {
|
||||
const execLikeRequest = toExecLikeRequest(params.request);
|
||||
return resolveExecApprovalSessionTarget({
|
||||
cfg: params.cfg,
|
||||
request: execLikeRequest,
|
||||
turnSourceChannel: execLikeRequest.request.turnSourceChannel ?? undefined,
|
||||
turnSourceTo: execLikeRequest.request.turnSourceTo ?? undefined,
|
||||
turnSourceAccountId: execLikeRequest.request.turnSourceAccountId ?? undefined,
|
||||
turnSourceThreadId: execLikeRequest.request.turnSourceThreadId ?? undefined,
|
||||
});
|
||||
return resolveApprovalRequestSessionTarget(params);
|
||||
}
|
||||
|
||||
function resolveDiscordOriginTarget(params: {
|
||||
|
|
@ -92,11 +64,21 @@ function resolveDiscordOriginTarget(params: {
|
|||
accountId?: string | null;
|
||||
request: ApprovalRequest;
|
||||
}) {
|
||||
if (
|
||||
!doesApprovalRequestMatchChannelAccount({
|
||||
cfg: params.cfg,
|
||||
request: params.request,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionKind = extractDiscordSessionKind(params.request.request.sessionKey?.trim() || null);
|
||||
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
|
||||
const rawTurnSourceTo = params.request.request.turnSourceTo?.trim() || "";
|
||||
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
|
||||
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
|
||||
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);
|
||||
const turnSourceTarget =
|
||||
turnSourceChannel === "discord" &&
|
||||
|
|
@ -105,26 +87,10 @@ function resolveDiscordOriginTarget(params: {
|
|||
(hasExplicitOriginTarget || sessionKind === "channel" || sessionKind === "group")
|
||||
? {
|
||||
to: turnSourceTo,
|
||||
accountId: turnSourceAccountId || undefined,
|
||||
}
|
||||
: null;
|
||||
if (
|
||||
turnSourceTarget?.accountId &&
|
||||
params.accountId &&
|
||||
normalizeAccountId(turnSourceTarget.accountId) !== normalizeAccountId(params.accountId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionTarget = resolveRequestSessionTarget(params);
|
||||
if (
|
||||
sessionTarget?.channel === "discord" &&
|
||||
sessionTarget.accountId &&
|
||||
params.accountId &&
|
||||
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
turnSourceTarget &&
|
||||
sessionTarget?.channel === "discord" &&
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import fs from "node:fs";
|
||||
import type { ButtonInteraction, ComponentData } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -13,6 +14,8 @@ const { STORE_PATH, mockSessionStoreEntries } = vi.hoisted(() => ({
|
|||
|
||||
const writeStore = (store: Record<string, unknown>) => {
|
||||
mockSessionStoreEntries.value = JSON.parse(JSON.stringify(store)) as Record<string, unknown>;
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
||||
clearSessionStoreCacheForTest();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -524,6 +527,42 @@ describe("DiscordExecApprovalHandler.shouldHandle", () => {
|
|||
expect(matching.shouldHandle(createRequest())).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by discord account from explicit turn-source bindings when the session store misses", () => {
|
||||
const handler = createHandler({ enabled: true, approvers: ["123"] }, "default");
|
||||
const matching = createHandler({ enabled: true, approvers: ["123"] }, "secondary");
|
||||
|
||||
expect(
|
||||
handler.shouldHandle(
|
||||
createRequest({
|
||||
sessionKey: "agent:test-agent:missing",
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceAccountId: "secondary",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matching.shouldHandle(
|
||||
createRequest({
|
||||
sessionKey: "agent:test-agent:missing",
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceAccountId: "secondary",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects requests bound to another channel before account-specific handling", () => {
|
||||
const handler = createHandler({ enabled: true, approvers: ["123"] }, "default");
|
||||
expect(
|
||||
handler.shouldHandle(
|
||||
createRequest({
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceAccountId: "default",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("combines agent and session filters", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ import {
|
|||
} from "@buape/carbon";
|
||||
import { ButtonStyle, Routes } from "discord-api-types/v10";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
createExecApprovalChannelRuntime,
|
||||
doesApprovalRequestMatchChannelAccount,
|
||||
type ExecApprovalChannelRuntime,
|
||||
resolveChannelNativeApprovalDeliveryPlan,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
|
|
@ -29,11 +29,6 @@ import type {
|
|||
PluginApprovalRequest,
|
||||
PluginApprovalResolved,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import {
|
||||
normalizeAccountId,
|
||||
normalizeMessageChannel,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
|
@ -195,61 +190,6 @@ class ExecApprovalActionRow extends Row<Button> {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveExecApprovalAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
}): string | null {
|
||||
const sessionKey = params.request.request.sessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
const channel = normalizeMessageChannel(entry?.origin?.provider ?? entry?.lastChannel);
|
||||
if (channel && channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const accountId = entry?.origin?.accountId ?? entry?.lastAccountId;
|
||||
return accountId?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePluginApprovalAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: PluginApprovalRequest;
|
||||
}): string | null {
|
||||
const fromSession = resolveExecApprovalAccountId({
|
||||
cfg: params.cfg,
|
||||
request: {
|
||||
id: params.request.id,
|
||||
request: {
|
||||
command: params.request.request.title,
|
||||
sessionKey: params.request.request.sessionKey ?? undefined,
|
||||
},
|
||||
createdAtMs: params.request.createdAtMs,
|
||||
expiresAtMs: params.request.expiresAtMs,
|
||||
},
|
||||
});
|
||||
if (fromSession) {
|
||||
return fromSession;
|
||||
}
|
||||
return params.request.request.turnSourceAccountId?.trim() || null;
|
||||
}
|
||||
|
||||
function resolveApprovalAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ApprovalRequest;
|
||||
}): string | null {
|
||||
return isPluginApprovalRequest(params.request)
|
||||
? resolvePluginApprovalAccountId({ cfg: params.cfg, request: params.request })
|
||||
: resolveExecApprovalAccountId({ cfg: params.cfg, request: params.request });
|
||||
}
|
||||
|
||||
function resolveApprovalAgentId(request: ApprovalRequest): string | null {
|
||||
return request.request.agentId?.trim() || null;
|
||||
}
|
||||
|
|
@ -540,15 +480,15 @@ export class DiscordExecApprovalHandler {
|
|||
return false;
|
||||
}
|
||||
|
||||
const requestAccountId = resolveApprovalAccountId({
|
||||
cfg: this.opts.cfg,
|
||||
request,
|
||||
});
|
||||
if (requestAccountId) {
|
||||
const handlerAccountId = normalizeAccountId(this.opts.accountId);
|
||||
if (normalizeAccountId(requestAccountId) !== handlerAccountId) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!doesApprovalRequestMatchChannelAccount({
|
||||
cfg: this.opts.cfg,
|
||||
request,
|
||||
channel: "discord",
|
||||
accountId: this.opts.accountId,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check agent filter
|
||||
|
|
|
|||
|
|
@ -168,6 +168,45 @@ describe("slack native approval adapter", () => {
|
|||
expect(dmTargets).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips native delivery when the request is bound to another Slack account", async () => {
|
||||
const originTarget = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
turnSourceAccountId: "other",
|
||||
sessionKey: "agent:main:missing",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
const dmTargets = await slackNativeApprovalAdapter.native?.resolveApproverDmTargets?.({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceAccountId: "other",
|
||||
sessionKey: "agent:main:missing",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(originTarget).toBeNull();
|
||||
expect(dmTargets).toEqual([]);
|
||||
});
|
||||
|
||||
it("suppresses generic slack fallback only for slack-originated approvals", () => {
|
||||
const shouldSuppress = slackNativeApprovalAdapter.delivery.shouldSuppressForwardingFallback;
|
||||
if (!shouldSuppress) {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ describe("slack exec approvals", () => {
|
|||
});
|
||||
|
||||
it("defaults target to dm", () => {
|
||||
expect(resolveSlackExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["U1"] }) })).toBe("dm");
|
||||
expect(
|
||||
resolveSlackExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["U1"] }) }),
|
||||
).toBe("dm");
|
||||
});
|
||||
|
||||
it("matches slack target recipients from generic approval forwarding targets", () => {
|
||||
|
|
@ -195,4 +197,64 @@ describe("slack exec approvals", () => {
|
|||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects requests bound to another channel or Slack account", () => {
|
||||
const cfg = buildConfig({
|
||||
enabled: true,
|
||||
approvers: ["U123"],
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldHandleSlackExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "work",
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceAccountId: "work",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldHandleSlackExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "work",
|
||||
request: {
|
||||
id: "req-2",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceAccountId: "other",
|
||||
sessionKey: "agent:ops-agent:missing",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldHandleSlackExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "work",
|
||||
request: {
|
||||
id: "req-3",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceAccountId: "work",
|
||||
sessionKey: "agent:ops-agent:missing",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import {
|
||||
doesApprovalRequestMatchChannelAccount,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
|
|
@ -47,6 +45,16 @@ export function shouldHandleSlackExecApprovalRequest(params: {
|
|||
accountId?: string | null;
|
||||
request: ApprovalRequest;
|
||||
}): boolean {
|
||||
if (
|
||||
!doesApprovalRequestMatchChannelAccount({
|
||||
cfg: params.cfg,
|
||||
request: params.request,
|
||||
channel: "slack",
|
||||
accountId: params.accountId,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const config = resolveSlackAccount(params).config.execApprovals;
|
||||
if (!config?.enabled) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import path from "node:path";
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { resolveExecApprovalSessionTarget } from "./exec-approval-session-target.js";
|
||||
import {
|
||||
doesApprovalRequestMatchChannelAccount,
|
||||
resolveApprovalRequestAccountId,
|
||||
resolveExecApprovalSessionTarget,
|
||||
} from "./exec-approval-session-target.js";
|
||||
import type { ExecApprovalRequest } from "./exec-approvals.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
|
@ -175,4 +179,103 @@ describe("exec approval session target", () => {
|
|||
expect(expectResolvedSessionTarget(cfg, request)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers explicit turn-source account bindings when session store is missing", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const request = buildRequest({
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceAccountId: "Work",
|
||||
sessionKey: "agent:main:missing",
|
||||
});
|
||||
|
||||
expect(resolveApprovalRequestAccountId({ cfg, request, channel: "slack" })).toBe("work");
|
||||
expect(
|
||||
doesApprovalRequestMatchChannelAccount({
|
||||
cfg,
|
||||
request,
|
||||
channel: "slack",
|
||||
accountId: "work",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
doesApprovalRequestMatchChannelAccount({
|
||||
cfg,
|
||||
request,
|
||||
channel: "slack",
|
||||
accountId: "other",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects mismatched channel bindings before account checks", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const request = buildRequest({
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceAccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolveApprovalRequestAccountId({ cfg, request, channel: "slack" })).toBeNull();
|
||||
expect(
|
||||
doesApprovalRequestMatchChannelAccount({
|
||||
cfg,
|
||||
request,
|
||||
channel: "slack",
|
||||
accountId: "work",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the session-bound account when no turn-source account is present", () => {
|
||||
const tmpDir = createTempDir();
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const cfg = writeStoreFile(storePath, {
|
||||
"agent:main:main": {
|
||||
sessionId: "main",
|
||||
updatedAt: 1,
|
||||
lastChannel: "slack",
|
||||
lastTo: "user:U1",
|
||||
lastAccountId: "ops",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveApprovalRequestAccountId({ cfg, request: baseRequest, channel: "slack" })).toBe(
|
||||
"ops",
|
||||
);
|
||||
expect(
|
||||
doesApprovalRequestMatchChannelAccount({
|
||||
cfg,
|
||||
request: baseRequest,
|
||||
channel: "slack",
|
||||
accountId: "ops",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects conflicting turn-source and stale session account bindings", () => {
|
||||
const tmpDir = createTempDir();
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const cfg = writeStoreFile(storePath, {
|
||||
"agent:main:main": {
|
||||
sessionId: "main",
|
||||
updatedAt: 1,
|
||||
lastChannel: "slack",
|
||||
lastTo: "user:U1",
|
||||
lastAccountId: "ops",
|
||||
},
|
||||
});
|
||||
const request = buildRequest({
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceAccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolveApprovalRequestAccountId({ cfg, request, channel: "slack" })).toBeNull();
|
||||
expect(
|
||||
doesApprovalRequestMatchChannelAccount({
|
||||
cfg,
|
||||
request,
|
||||
channel: "slack",
|
||||
accountId: "work",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import { normalizeOptionalAccountId } from "../routing/account-id.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import type { ExecApprovalRequest } from "./exec-approvals.js";
|
||||
import { resolveSessionDeliveryTarget } from "./outbound/targets.js";
|
||||
import type { PluginApprovalRequest } from "./plugin-approvals.js";
|
||||
|
||||
export type ExecApprovalSessionTarget = {
|
||||
channel?: string;
|
||||
|
|
@ -11,6 +14,13 @@ export type ExecApprovalSessionTarget = {
|
|||
threadId?: number;
|
||||
};
|
||||
|
||||
type ApprovalRequestSessionBinding = {
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
type ApprovalRequestLike = ExecApprovalRequest | PluginApprovalRequest;
|
||||
|
||||
function normalizeOptionalString(value?: string | null): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
|
|
@ -27,6 +37,33 @@ function normalizeOptionalThreadId(value?: string | number | null): number | und
|
|||
return Number.isFinite(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function isExecApprovalRequest(request: ApprovalRequestLike): request is ExecApprovalRequest {
|
||||
return "command" in request.request;
|
||||
}
|
||||
|
||||
function toExecLikeApprovalRequest(request: ApprovalRequestLike): ExecApprovalRequest {
|
||||
if (isExecApprovalRequest(request)) {
|
||||
return request;
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
request: {
|
||||
command: request.request.title,
|
||||
sessionKey: request.request.sessionKey ?? undefined,
|
||||
turnSourceChannel: request.request.turnSourceChannel ?? undefined,
|
||||
turnSourceTo: request.request.turnSourceTo ?? undefined,
|
||||
turnSourceAccountId: request.request.turnSourceAccountId ?? undefined,
|
||||
turnSourceThreadId: request.request.turnSourceThreadId ?? undefined,
|
||||
},
|
||||
createdAtMs: request.createdAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOptionalChannel(value?: string | null): string | undefined {
|
||||
return normalizeMessageChannel(value);
|
||||
}
|
||||
|
||||
export function resolveExecApprovalSessionTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
|
|
@ -67,3 +104,112 @@ export function resolveExecApprovalSessionTarget(params: {
|
|||
threadId: normalizeOptionalThreadId(target.threadId),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveApprovalRequestSessionBinding(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ApprovalRequestLike;
|
||||
}): ApprovalRequestSessionBinding | null {
|
||||
const sessionKey = normalizeOptionalString(params.request.request.sessionKey);
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main";
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel: normalizeOptionalChannel(entry.origin?.provider ?? entry.lastChannel),
|
||||
accountId: normalizeOptionalAccountId(entry.origin?.accountId ?? entry.lastAccountId),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveApprovalRequestSessionTarget(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ApprovalRequestLike;
|
||||
}): ExecApprovalSessionTarget | null {
|
||||
const execLikeRequest = toExecLikeApprovalRequest(params.request);
|
||||
return resolveExecApprovalSessionTarget({
|
||||
cfg: params.cfg,
|
||||
request: execLikeRequest,
|
||||
turnSourceChannel: execLikeRequest.request.turnSourceChannel ?? undefined,
|
||||
turnSourceTo: execLikeRequest.request.turnSourceTo ?? undefined,
|
||||
turnSourceAccountId: execLikeRequest.request.turnSourceAccountId ?? undefined,
|
||||
turnSourceThreadId: execLikeRequest.request.turnSourceThreadId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveApprovalRequestAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ApprovalRequestLike;
|
||||
channel?: string | null;
|
||||
}): string | null {
|
||||
const expectedChannel = normalizeOptionalChannel(params.channel);
|
||||
const turnSourceChannel = normalizeOptionalChannel(params.request.request.turnSourceChannel);
|
||||
if (expectedChannel && turnSourceChannel && turnSourceChannel !== expectedChannel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionTarget = resolveApprovalRequestSessionTarget(params);
|
||||
const sessionBinding = resolveApprovalRequestSessionBinding(params);
|
||||
const sessionChannel = normalizeOptionalChannel(
|
||||
sessionTarget?.channel ?? sessionBinding?.channel,
|
||||
);
|
||||
if (expectedChannel && sessionChannel && sessionChannel !== expectedChannel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const turnSourceAccountId = normalizeOptionalAccountId(
|
||||
params.request.request.turnSourceAccountId,
|
||||
);
|
||||
const sessionAccountId = normalizeOptionalAccountId(
|
||||
sessionTarget?.accountId ?? sessionBinding?.accountId,
|
||||
);
|
||||
if (turnSourceAccountId && sessionAccountId && turnSourceAccountId !== sessionAccountId) {
|
||||
return null;
|
||||
}
|
||||
return turnSourceAccountId ?? sessionAccountId ?? null;
|
||||
}
|
||||
|
||||
export function doesApprovalRequestMatchChannelAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ApprovalRequestLike;
|
||||
channel: string;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
const expectedChannel = normalizeOptionalChannel(params.channel);
|
||||
if (!expectedChannel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const turnSourceChannel = normalizeOptionalChannel(params.request.request.turnSourceChannel);
|
||||
if (turnSourceChannel && turnSourceChannel !== expectedChannel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionTarget = resolveApprovalRequestSessionTarget(params);
|
||||
const sessionBinding = resolveApprovalRequestSessionBinding(params);
|
||||
const sessionChannel = normalizeOptionalChannel(
|
||||
sessionTarget?.channel ?? sessionBinding?.channel,
|
||||
);
|
||||
if (sessionChannel && sessionChannel !== expectedChannel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const turnSourceAccountId = normalizeOptionalAccountId(
|
||||
params.request.request.turnSourceAccountId,
|
||||
);
|
||||
const sessionAccountId = normalizeOptionalAccountId(
|
||||
sessionTarget?.accountId ?? sessionBinding?.accountId,
|
||||
);
|
||||
if (turnSourceAccountId && sessionAccountId && turnSourceAccountId !== sessionAccountId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedAccountId = normalizeOptionalAccountId(params.accountId);
|
||||
const boundAccountId = turnSourceAccountId ?? sessionAccountId;
|
||||
return !expectedAccountId || !boundAccountId || expectedAccountId === boundAccountId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export {
|
|||
} from "../infra/exec-approval-reply.js";
|
||||
export { resolveExecApprovalCommandDisplay } from "../infra/exec-approval-command-display.js";
|
||||
export {
|
||||
doesApprovalRequestMatchChannelAccount,
|
||||
resolveApprovalRequestAccountId,
|
||||
resolveApprovalRequestSessionTarget,
|
||||
resolveExecApprovalSessionTarget,
|
||||
type ExecApprovalSessionTarget,
|
||||
} from "../infra/exec-approval-session-target.js";
|
||||
|
|
|
|||
Loading…
Reference in New Issue