mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 09:33:06 +00:00
fix(approvals): centralize native request binding
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user