fix(approvals): centralize native request binding

This commit is contained in:
Peter Steinberger
2026-03-31 15:20:07 +01:00
parent 2523e25c93
commit 584db0aff2
10 changed files with 458 additions and 127 deletions

View File

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

View File

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

View File

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