mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(approvals): bind portable actions to lifecycle
This commit is contained in:
@@ -11,6 +11,7 @@ function encodeDiscordApprovalCustomId(action: DiscordApprovalAction): string {
|
||||
return [
|
||||
`execapproval:kind=${action.approvalKind}`,
|
||||
`id=${encodeURIComponent(action.approvalId)}`,
|
||||
...(action.instanceId ? [`instance=${encodeURIComponent(action.instanceId)}`] : []),
|
||||
`action=${action.decision}`,
|
||||
].join(";");
|
||||
}
|
||||
@@ -27,7 +28,9 @@ function encodeBoundedDiscordApprovalCustomId(action: DiscordApprovalAction): st
|
||||
approvalId: buildApprovalResolutionRef({
|
||||
approvalId: action.approvalId,
|
||||
approvalKind: action.approvalKind,
|
||||
instanceId: action.instanceId,
|
||||
}),
|
||||
instanceId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,10 +51,12 @@ export function buildExecApprovalCustomId(
|
||||
approvalId: string,
|
||||
approvalKind: DiscordApprovalAction["approvalKind"],
|
||||
decision: DiscordApprovalAction["decision"],
|
||||
instanceId?: string,
|
||||
): string {
|
||||
return encodeBoundedDiscordApprovalCustomId({
|
||||
type: "approval",
|
||||
approvalId,
|
||||
...(instanceId ? { instanceId } : {}),
|
||||
approvalKind,
|
||||
decision,
|
||||
});
|
||||
@@ -67,6 +72,7 @@ function decodeCustomIdValue(value: string): string | null {
|
||||
|
||||
export function parseExecApprovalData(data: ComponentData): {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalKind: DiscordApprovalAction["approvalKind"];
|
||||
action: DiscordApprovalAction["decision"];
|
||||
} | null {
|
||||
@@ -78,6 +84,7 @@ export function parseExecApprovalData(data: ComponentData): {
|
||||
const rawId = coerce(data.id);
|
||||
const rawKind = coerce(data.kind);
|
||||
const rawAction = coerce(data.action);
|
||||
const rawInstanceId = coerce(data.instance);
|
||||
if (!rawId || (rawKind !== "exec" && rawKind !== "plugin") || !rawAction) {
|
||||
return null;
|
||||
}
|
||||
@@ -85,11 +92,13 @@ export function parseExecApprovalData(data: ComponentData): {
|
||||
return null;
|
||||
}
|
||||
const approvalId = decodeCustomIdValue(rawId);
|
||||
if (!approvalId) {
|
||||
const instanceId = rawInstanceId ? decodeCustomIdValue(rawInstanceId) : undefined;
|
||||
if (!approvalId || (rawInstanceId && !instanceId)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
approvalId,
|
||||
...(instanceId ? { instanceId } : {}),
|
||||
approvalKind: rawKind,
|
||||
action: rawAction,
|
||||
};
|
||||
|
||||
@@ -128,6 +128,7 @@ class ExecApprovalActionButton extends Button {
|
||||
|
||||
constructor(params: {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalKind: PendingApprovalView["approvalKind"];
|
||||
descriptor: ExecApprovalActionDescriptor;
|
||||
}) {
|
||||
@@ -136,6 +137,7 @@ class ExecApprovalActionButton extends Button {
|
||||
params.approvalId,
|
||||
params.approvalKind,
|
||||
params.descriptor.decision,
|
||||
params.instanceId,
|
||||
);
|
||||
this.label = params.descriptor.label;
|
||||
this.style =
|
||||
@@ -152,6 +154,7 @@ class ExecApprovalActionButton extends Button {
|
||||
class ExecApprovalActionRow extends Row<Button> {
|
||||
constructor(params: {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalKind: PendingApprovalView["approvalKind"];
|
||||
actions: readonly ExecApprovalActionDescriptor[];
|
||||
}) {
|
||||
@@ -160,6 +163,7 @@ class ExecApprovalActionRow extends Row<Button> {
|
||||
(descriptor) =>
|
||||
new ExecApprovalActionButton({
|
||||
approvalId: params.approvalId,
|
||||
instanceId: params.instanceId,
|
||||
approvalKind: params.approvalKind,
|
||||
descriptor,
|
||||
}),
|
||||
@@ -171,6 +175,7 @@ class ExecApprovalActionRow extends Row<Button> {
|
||||
function createApprovalActionRow(view: PendingApprovalView): Row<Button> {
|
||||
return new ExecApprovalActionRow({
|
||||
approvalId: view.approvalId,
|
||||
instanceId: view.instanceId,
|
||||
approvalKind: view.approvalKind,
|
||||
actions: view.actions,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ type ExecApprovalButtonContext = {
|
||||
approvalKind: PendingApprovalView["approvalKind"],
|
||||
decision: ExecApprovalDecision,
|
||||
senderId: string,
|
||||
instanceId?: string,
|
||||
) => Promise<ExecApprovalResolveResult>;
|
||||
};
|
||||
|
||||
@@ -138,12 +139,20 @@ class ExecApprovalButton extends Button {
|
||||
await interaction.acknowledge();
|
||||
} catch {}
|
||||
|
||||
const result = await this.ctx.resolveApproval(
|
||||
parsed.approvalId,
|
||||
parsed.approvalKind,
|
||||
parsed.action,
|
||||
userId,
|
||||
);
|
||||
const result = parsed.instanceId
|
||||
? await this.ctx.resolveApproval(
|
||||
parsed.approvalId,
|
||||
parsed.approvalKind,
|
||||
parsed.action,
|
||||
userId,
|
||||
parsed.instanceId,
|
||||
)
|
||||
: await this.ctx.resolveApproval(
|
||||
parsed.approvalId,
|
||||
parsed.approvalKind,
|
||||
parsed.action,
|
||||
userId,
|
||||
);
|
||||
if (!result.ok) {
|
||||
try {
|
||||
await interaction.followUp({
|
||||
@@ -199,11 +208,12 @@ export function createDiscordExecApprovalButtonContext(params: {
|
||||
accountId: params.accountId,
|
||||
configOverride: params.config,
|
||||
}),
|
||||
resolveApproval: async (approvalId, approvalKind, decision, senderId) => {
|
||||
resolveApproval: async (approvalId, approvalKind, decision, senderId, instanceId) => {
|
||||
try {
|
||||
const resolution = await resolveApprovalOverGateway({
|
||||
cfg: params.cfg,
|
||||
approvalId,
|
||||
instanceId,
|
||||
approvalKind,
|
||||
decision,
|
||||
channel: "discord",
|
||||
|
||||
@@ -258,6 +258,7 @@ describe("buildDiscordInteractiveComponents", () => {
|
||||
action: {
|
||||
type: "approval",
|
||||
approvalId: "opaque:approval;id=7",
|
||||
instanceId: "instance-1",
|
||||
approvalKind: "plugin",
|
||||
decision: "deny",
|
||||
},
|
||||
@@ -278,7 +279,7 @@ describe("buildDiscordInteractiveComponents", () => {
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
internalCustomId:
|
||||
"execapproval:kind=plugin;id=opaque%3Aapproval%3Bid%3D7;action=deny",
|
||||
"execapproval:kind=plugin;id=opaque%3Aapproval%3Bid%3D7;instance=instance-1;action=deny",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -299,6 +300,7 @@ describe("buildDiscordInteractiveComponents", () => {
|
||||
expect(built.entries).toEqual([]);
|
||||
expect(parseExecApprovalData(parseCustomId(customId ?? "").data)).toEqual({
|
||||
approvalId: "opaque:approval;id=7",
|
||||
instanceId: "instance-1",
|
||||
approvalKind: "plugin",
|
||||
action: "deny",
|
||||
});
|
||||
@@ -355,6 +357,7 @@ describe("buildDiscordInteractiveComponents", () => {
|
||||
action: {
|
||||
type: "approval",
|
||||
approvalId: overlongId,
|
||||
instanceId: "instance-1",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
},
|
||||
@@ -368,7 +371,11 @@ describe("buildDiscordInteractiveComponents", () => {
|
||||
firstBlock?.type === "actions" ? firstBlock.buttons?.[0]?.internalCustomId : undefined;
|
||||
expect(customId?.length).toBeLessThanOrEqual(100);
|
||||
expect(parseExecApprovalData(parseCustomId(customId ?? "").data)).toEqual({
|
||||
approvalId: buildApprovalResolutionRef({ approvalId: overlongId, approvalKind: "exec" }),
|
||||
approvalId: buildApprovalResolutionRef({
|
||||
approvalId: overlongId,
|
||||
approvalKind: "exec",
|
||||
instanceId: "instance-1",
|
||||
}),
|
||||
approvalKind: "exec",
|
||||
action: "allow-once",
|
||||
});
|
||||
|
||||
@@ -215,6 +215,7 @@ function buildIMessageExecPendingPayload(params: { request: ExecApprovalRequest;
|
||||
const command = resolveExecApprovalCommandDisplay(params.request.request).commandText;
|
||||
const payload = buildTypedExecApprovalPendingReplyPayload({
|
||||
approvalId: params.request.id,
|
||||
instanceId: params.request.instanceId,
|
||||
approvalSlug: params.request.id.slice(0, 8),
|
||||
approvalCommandId: params.request.id,
|
||||
warningText: params.request.request.warningText ?? undefined,
|
||||
|
||||
@@ -48,6 +48,7 @@ const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type IMessageApprovalReactionResolution = {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
decision: ExecApprovalReplyDecision;
|
||||
};
|
||||
@@ -213,6 +214,7 @@ export function addIMessageApprovalReactionHintToStructuredPayload(params: {
|
||||
...params.payload.channelData,
|
||||
[IMESSAGE_APPROVAL_DELIVERY_BINDING_KEY]: buildApprovalReactionDeliveredBindingMarker({
|
||||
approvalId: metadata.approvalId,
|
||||
instanceId: metadata.instanceId,
|
||||
approvalSlug: metadata.approvalSlug,
|
||||
approvalKind: metadata.approvalKind,
|
||||
allowedDecisions: metadata.allowedDecisions,
|
||||
@@ -228,6 +230,7 @@ export function registerIMessageApprovalReactionTarget(params: {
|
||||
conversation: IMessageApprovalConversationKey;
|
||||
messageId: string;
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
ttlMs?: number;
|
||||
@@ -247,7 +250,12 @@ export function registerIMessageApprovalReactionTarget(params: {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const target = { approvalId, approvalKind: params.approvalKind, allowedDecisions };
|
||||
const target = {
|
||||
approvalId,
|
||||
...(params.instanceId ? { instanceId: params.instanceId } : {}),
|
||||
approvalKind: params.approvalKind,
|
||||
allowedDecisions,
|
||||
};
|
||||
// Register the binding under every key we can derive from the conversation
|
||||
// (chat_guid / chat_identifier / chat_id / handle). Inbound lookup precedence
|
||||
// can differ from outbound — e.g. send only sees `{handle: "+1..."}` for a
|
||||
@@ -354,6 +362,7 @@ export function registerIMessageApprovalReactionTargetForDeliveredPayload(params
|
||||
conversation,
|
||||
messageId,
|
||||
approvalId: binding.approvalId,
|
||||
instanceId: binding.instanceId,
|
||||
approvalKind: binding.approvalKind,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
ttlMs: params.ttlMs,
|
||||
@@ -540,6 +549,7 @@ export async function handleIMessageApprovalReaction(params: {
|
||||
const result = await resolveApprovalOverGateway({
|
||||
cfg: params.cfg,
|
||||
approvalId: target.approvalId,
|
||||
instanceId: target.instanceId,
|
||||
approvalKind: target.approvalKind,
|
||||
decision: target.decision,
|
||||
channel: "imessage",
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("Slack approval actions", () => {
|
||||
type: "approval" as const,
|
||||
approvalId: "plugin:req/50%/😀",
|
||||
approvalKind: "plugin" as const,
|
||||
instanceId: "instance/50%/😀",
|
||||
decision: "allow-always" as const,
|
||||
};
|
||||
|
||||
@@ -19,6 +20,27 @@ describe("Slack approval actions", () => {
|
||||
expect(decodeSlackApprovalAction(encoded)).toEqual(action);
|
||||
});
|
||||
|
||||
it("binds compact callbacks to the approval lifecycle", () => {
|
||||
const approvalId = `approval/${"x".repeat(SLACK_BUTTON_VALUE_MAX)}`;
|
||||
const action = {
|
||||
type: "approval" as const,
|
||||
approvalId,
|
||||
approvalKind: "exec" as const,
|
||||
instanceId: `instance/${"y".repeat(SLACK_BUTTON_VALUE_MAX)}`,
|
||||
decision: "deny" as const,
|
||||
};
|
||||
|
||||
expect(decodeSlackApprovalAction(encodeSlackApprovalAction(action))).toEqual({
|
||||
...action,
|
||||
approvalId: buildApprovalResolutionRef({
|
||||
approvalId,
|
||||
approvalKind: "exec",
|
||||
instanceId: action.instanceId,
|
||||
}),
|
||||
instanceId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the durable transport reference when a Unicode id exceeds Slack's value limit", () => {
|
||||
const approvalId = `approval/${"\u{1F4F1}".repeat(SLACK_BUTTON_VALUE_MAX)}`;
|
||||
const action = {
|
||||
|
||||
@@ -14,9 +14,10 @@ function isApprovalDecision(value: unknown): value is SlackApprovalAction["decis
|
||||
|
||||
/** Encode portable approval facts without exposing a slash command to Slack callbacks. */
|
||||
export function encodeSlackApprovalAction(action: SlackApprovalAction): string {
|
||||
const encode = (approvalId: string) =>
|
||||
const encode = (approvalId: string, includeInstance = true) =>
|
||||
`${SLACK_APPROVAL_VALUE_PREFIX}${JSON.stringify({
|
||||
approvalId,
|
||||
...(includeInstance && action.instanceId ? { instanceId: action.instanceId } : {}),
|
||||
approvalKind: action.approvalKind,
|
||||
decision: action.decision,
|
||||
})}`;
|
||||
@@ -27,7 +28,9 @@ export function encodeSlackApprovalAction(action: SlackApprovalAction): string {
|
||||
buildApprovalResolutionRef({
|
||||
approvalId: action.approvalId,
|
||||
approvalKind: action.approvalKind,
|
||||
instanceId: action.instanceId,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,10 +45,21 @@ export function decodeSlackApprovalAction(value: unknown): SlackApprovalAction |
|
||||
return null;
|
||||
}
|
||||
const record = decoded as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
if (
|
||||
Object.keys(record).length !== 3 ||
|
||||
(keys.length !== 3 && keys.length !== 4) ||
|
||||
keys.some(
|
||||
(key) =>
|
||||
key !== "approvalId" &&
|
||||
key !== "instanceId" &&
|
||||
key !== "approvalKind" &&
|
||||
key !== "decision",
|
||||
) ||
|
||||
typeof record.approvalId !== "string" ||
|
||||
record.approvalId.length === 0 ||
|
||||
(record.instanceId !== undefined &&
|
||||
(typeof record.instanceId !== "string" || record.instanceId.length === 0)) ||
|
||||
(keys.length === 4 && typeof record.instanceId !== "string") ||
|
||||
(record.approvalKind !== "exec" && record.approvalKind !== "plugin") ||
|
||||
!isApprovalDecision(record.decision)
|
||||
) {
|
||||
@@ -54,6 +68,7 @@ export function decodeSlackApprovalAction(value: unknown): SlackApprovalAction |
|
||||
return {
|
||||
type: "approval",
|
||||
approvalId: record.approvalId,
|
||||
...(typeof record.instanceId === "string" ? { instanceId: record.instanceId } : {}),
|
||||
approvalKind: record.approvalKind,
|
||||
decision: record.decision,
|
||||
};
|
||||
|
||||
@@ -655,6 +655,7 @@ async function handleSlackApprovalInteraction(params: {
|
||||
const result = await resolveApprovalOverGateway({
|
||||
cfg: params.ctx.cfg,
|
||||
approvalId: params.approval.approvalId,
|
||||
instanceId: params.approval.instanceId,
|
||||
approvalKind: params.approval.approvalKind,
|
||||
decision: params.approval.decision,
|
||||
channel: "slack",
|
||||
|
||||
@@ -91,6 +91,34 @@ describe("approval callback data", () => {
|
||||
expect(parseTelegramApprovalCallbackData(callbackData)).toEqual(action);
|
||||
});
|
||||
|
||||
it("round-trips the approval lifecycle and binds compact callbacks to it", () => {
|
||||
const exact = {
|
||||
type: "approval" as const,
|
||||
approvalId: "approval-1",
|
||||
approvalKind: "exec" as const,
|
||||
instanceId: "instance-1",
|
||||
decision: "deny" as const,
|
||||
};
|
||||
expect(parseTelegramApprovalCallbackData(buildTelegramApprovalCallbackData(exact))).toEqual(
|
||||
exact,
|
||||
);
|
||||
|
||||
const approvalId = "x".repeat(80);
|
||||
expect(
|
||||
parseTelegramApprovalCallbackData(
|
||||
buildTelegramApprovalCallbackData({ ...exact, approvalId }),
|
||||
),
|
||||
).toEqual({
|
||||
...exact,
|
||||
approvalId: buildApprovalResolutionRef({
|
||||
approvalId,
|
||||
approvalKind: "exec",
|
||||
instanceId: "instance-1",
|
||||
}),
|
||||
instanceId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the canonical id at the Unicode byte boundary and compacts beyond it", () => {
|
||||
const exactId = "\u{1F4F1}".repeat(13);
|
||||
const compactedId = "\u{1F4F1}".repeat(14);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MessagePresentationAction } from "openclaw/plugin-sdk/interactive-
|
||||
|
||||
export const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
|
||||
const TELEGRAM_APPROVAL_CALLBACK_PREFIX = "tga1:";
|
||||
const TELEGRAM_BOUND_APPROVAL_CALLBACK_PREFIX = "tga2:";
|
||||
|
||||
export type TelegramApprovalCallback = Extract<MessagePresentationAction, { type: "approval" }>;
|
||||
|
||||
@@ -18,7 +19,10 @@ export function fitsTelegramCallbackData(value: string): boolean {
|
||||
|
||||
/** Reserve the Telegram approval namespace even when a callback is malformed. */
|
||||
export function hasTelegramApprovalCallbackPrefix(data?: string | null): boolean {
|
||||
return data?.startsWith(TELEGRAM_APPROVAL_CALLBACK_PREFIX) === true;
|
||||
return (
|
||||
data?.startsWith(TELEGRAM_APPROVAL_CALLBACK_PREFIX) === true ||
|
||||
data?.startsWith(TELEGRAM_BOUND_APPROVAL_CALLBACK_PREFIX) === true
|
||||
);
|
||||
}
|
||||
|
||||
/** Encode a typed approval action into Telegram-private, versioned callback data. */
|
||||
@@ -41,15 +45,21 @@ export function buildTelegramApprovalCallbackData(
|
||||
if (!kind || !decision) {
|
||||
return undefined;
|
||||
}
|
||||
const encode = (approvalId: string) =>
|
||||
`${TELEGRAM_APPROVAL_CALLBACK_PREFIX}${kind}:${decision}:${approvalId}`;
|
||||
const exact = encode(action.approvalId);
|
||||
const encode = (approvalId: string, instanceId?: string) =>
|
||||
`${instanceId ? TELEGRAM_BOUND_APPROVAL_CALLBACK_PREFIX : TELEGRAM_APPROVAL_CALLBACK_PREFIX}${kind}:${decision}:${instanceId ? encodeURIComponent(approvalId) : approvalId}${instanceId ? `:${encodeURIComponent(instanceId)}` : ""}`;
|
||||
const exact = encode(action.approvalId, action.instanceId);
|
||||
if (fitsTelegramCallbackData(exact)) {
|
||||
return exact;
|
||||
}
|
||||
// Telegram caps callback_data at 64 UTF-8 bytes. The full digest is only a
|
||||
// durable locator; Gateway authorization still guards the canonical record.
|
||||
return encode(buildApprovalResolutionRef({ approvalId: action.approvalId, approvalKind }));
|
||||
return encode(
|
||||
buildApprovalResolutionRef({
|
||||
approvalId: action.approvalId,
|
||||
approvalKind,
|
||||
instanceId: action.instanceId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Decode only callbacks emitted by buildTelegramApprovalCallbackData. */
|
||||
@@ -59,7 +69,10 @@ export function parseTelegramApprovalCallbackData(
|
||||
if (!hasTelegramApprovalCallbackPrefix(data) || !data || !fitsTelegramCallbackData(data)) {
|
||||
return null;
|
||||
}
|
||||
const encoded = data.slice(TELEGRAM_APPROVAL_CALLBACK_PREFIX.length);
|
||||
const bound = data.startsWith(TELEGRAM_BOUND_APPROVAL_CALLBACK_PREFIX);
|
||||
const encoded = data.slice(
|
||||
(bound ? TELEGRAM_BOUND_APPROVAL_CALLBACK_PREFIX : TELEGRAM_APPROVAL_CALLBACK_PREFIX).length,
|
||||
);
|
||||
if (encoded.length < 5 || encoded[1] !== ":" || encoded[3] !== ":") {
|
||||
return null;
|
||||
}
|
||||
@@ -72,11 +85,26 @@ export function parseTelegramApprovalCallbackData(
|
||||
: encoded[2] === "d"
|
||||
? "deny"
|
||||
: null;
|
||||
const approvalId = encoded.slice(4);
|
||||
if (!approvalKind || !decision || !approvalId) {
|
||||
const payload = encoded.slice(4);
|
||||
const [rawApprovalId, rawInstanceId] = bound ? payload.split(":", 2) : [payload, undefined];
|
||||
let approvalId: string;
|
||||
let instanceId: string | undefined;
|
||||
try {
|
||||
approvalId = bound ? decodeURIComponent(rawApprovalId ?? "") : (rawApprovalId ?? "");
|
||||
instanceId = bound ? decodeURIComponent(rawInstanceId ?? "") : undefined;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return { type: "approval", approvalId, approvalKind, decision };
|
||||
if (!approvalKind || !decision || !approvalId || (bound && !instanceId)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "approval",
|
||||
approvalId,
|
||||
...(instanceId ? { instanceId } : {}),
|
||||
approvalKind,
|
||||
decision,
|
||||
};
|
||||
}
|
||||
|
||||
export function rewriteTelegramApprovalDecisionAlias(value: string): string {
|
||||
|
||||
@@ -89,6 +89,7 @@ function buildPendingPayload(params: {
|
||||
})
|
||||
: buildExecApprovalPendingReplyPayload({
|
||||
approvalId: params.request.id,
|
||||
instanceId: params.request.instanceId,
|
||||
approvalSlug: params.request.id.slice(0, 8),
|
||||
approvalCommandId: params.request.id,
|
||||
warningText:
|
||||
|
||||
@@ -160,6 +160,7 @@ export function createTelegramCallbackApprovalRuntime(params: {
|
||||
(await resolveApproval({
|
||||
cfg: runtimeCfg,
|
||||
approvalId: approvalCallback.approvalId,
|
||||
instanceId: approvalCallback.instanceId,
|
||||
approvalKind: approvalCallback.approvalKind,
|
||||
decision: approvalCallback.decision,
|
||||
channel: "telegram",
|
||||
|
||||
@@ -33,6 +33,7 @@ export function buildTelegramExecApprovalPendingPayload(params: {
|
||||
}) {
|
||||
return buildTypedExecApprovalPendingReplyPayload({
|
||||
approvalId: params.request.id,
|
||||
instanceId: params.request.instanceId,
|
||||
approvalSlug: params.request.id.slice(0, 8),
|
||||
approvalCommandId: params.request.id,
|
||||
warningText: params.request.request.warningText ?? undefined,
|
||||
|
||||
@@ -484,6 +484,7 @@ function queuePendingToolMedia(
|
||||
|
||||
function readExecApprovalPendingDetails(result: unknown): {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalSlug: string;
|
||||
expiresAtMs?: number;
|
||||
allowedDecisions?: readonly ExecApprovalDecision[];
|
||||
@@ -513,6 +514,7 @@ function readExecApprovalPendingDetails(result: unknown): {
|
||||
}
|
||||
return {
|
||||
approvalId,
|
||||
instanceId: readStringValue(details.instanceId),
|
||||
approvalSlug,
|
||||
expiresAtMs: typeof details.expiresAtMs === "number" ? details.expiresAtMs : undefined,
|
||||
allowedDecisions: Array.isArray(details.allowedDecisions)
|
||||
@@ -616,6 +618,7 @@ export async function emitToolResultOutput(params: {
|
||||
await ctx.params.onToolResult(
|
||||
buildTypedExecApprovalPendingReplyPayload({
|
||||
approvalId: approvalPending.approvalId,
|
||||
instanceId: approvalPending.instanceId,
|
||||
approvalSlug: approvalPending.approvalSlug,
|
||||
allowedDecisions: approvalPending.allowedDecisions,
|
||||
command: approvalPending.command,
|
||||
|
||||
@@ -330,6 +330,11 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
|
||||
const inserted = insertOperatorApproval({
|
||||
approval: {
|
||||
id: record.id,
|
||||
resolutionRef: buildApprovalResolutionRef({
|
||||
approvalId: record.id,
|
||||
approvalKind: this.approvalKind,
|
||||
instanceId: record.instanceId,
|
||||
}),
|
||||
kind: this.approvalKind,
|
||||
presentation: presentation!,
|
||||
requester: {
|
||||
@@ -407,6 +412,7 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
|
||||
resolutionRef: buildApprovalResolutionRef({
|
||||
approvalId: record.id,
|
||||
approvalKind: this.approvalKind,
|
||||
instanceId: record.instanceId,
|
||||
}),
|
||||
kind: this.approvalKind,
|
||||
status,
|
||||
|
||||
@@ -98,6 +98,7 @@ export type OperatorApprovalRecord = {
|
||||
|
||||
type NewOperatorApproval = {
|
||||
id: string;
|
||||
resolutionRef?: string;
|
||||
kind: OperatorApprovalKind;
|
||||
presentation: ApprovalPresentation;
|
||||
requester?: Partial<OperatorApprovalRequester>;
|
||||
@@ -1267,10 +1268,8 @@ export function insertOperatorApproval(params: {
|
||||
}): InsertOperatorApprovalResult {
|
||||
const input = params.approval;
|
||||
const id = requireApprovalId(input.id);
|
||||
const resolutionRef = buildApprovalResolutionRef({
|
||||
approvalId: id,
|
||||
approvalKind: input.kind,
|
||||
});
|
||||
const resolutionRef =
|
||||
input.resolutionRef ?? buildApprovalResolutionRef({ approvalId: id, approvalKind: input.kind });
|
||||
const runtimeEpoch = requireString(input.runtimeEpoch, "operator approval runtime epoch");
|
||||
if (!isValidTimestamp(input.createdAtMs) || !isValidTimestamp(input.expiresAtMs)) {
|
||||
throw new Error("operator approval timestamps must be non-negative safe integers");
|
||||
|
||||
@@ -67,6 +67,7 @@ describe("resolveApprovalOverGateway", () => {
|
||||
cfg: { gateway: { auth: { token: "cfg-token" } } } as never,
|
||||
approvalId: "approval-1",
|
||||
approvalKind: "exec",
|
||||
instanceId: "instance-1",
|
||||
decision: "allow-once",
|
||||
gatewayUrl: "ws://gateway.example.test",
|
||||
clientDisplayName: "QuietChat approval (default)",
|
||||
@@ -85,6 +86,7 @@ describe("resolveApprovalOverGateway", () => {
|
||||
expect(hoisted.clientRequest).toHaveBeenCalledWith("approval.resolve", {
|
||||
id: "approval-1",
|
||||
kind: "exec",
|
||||
instanceId: "instance-1",
|
||||
decision: "allow-once",
|
||||
});
|
||||
expect(result).toEqual({ applied: true, approval: recordedApproval });
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ChannelApprovalKind } from "./approval-types.js";
|
||||
type ResolveApprovalOverGatewayBaseParams = {
|
||||
cfg: OpenClawConfig;
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
decision: ApprovalDecision;
|
||||
channel?: string;
|
||||
accountId?: string | null;
|
||||
@@ -133,6 +134,7 @@ export async function resolveApprovalOverGateway(
|
||||
"approval.resolve",
|
||||
{
|
||||
id: approvalId,
|
||||
...(params.instanceId ? { instanceId: params.instanceId } : {}),
|
||||
kind: canonicalKind,
|
||||
decision: params.decision,
|
||||
...(reviewer ? { reviewer } : {}),
|
||||
@@ -150,6 +152,7 @@ export async function resolveApprovalOverGateway(
|
||||
if (hasCanonicalKind) {
|
||||
const resolveParams: ApprovalResolveParams = {
|
||||
id: approvalId,
|
||||
...(params.instanceId ? { instanceId: params.instanceId } : {}),
|
||||
kind: canonicalKind,
|
||||
decision: params.decision,
|
||||
...(reviewer ? { reviewer } : {}),
|
||||
@@ -162,6 +165,7 @@ export async function resolveApprovalOverGateway(
|
||||
): Promise<void> => {
|
||||
await gatewayClient.request(method, {
|
||||
id: approvalId,
|
||||
...(params.instanceId ? { instanceId: params.instanceId } : {}),
|
||||
decision: params.decision,
|
||||
...(reviewer ? { reviewer } : {}),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,24 @@ describe("approval resolution references", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("binds lifecycle-aware locators without changing legacy locators", () => {
|
||||
const legacy = buildApprovalResolutionRef({ approvalId: "same-id", approvalKind: "exec" });
|
||||
const first = buildApprovalResolutionRef({
|
||||
approvalId: "same-id",
|
||||
approvalKind: "exec",
|
||||
instanceId: "instance-1",
|
||||
});
|
||||
|
||||
expect(first).not.toBe(legacy);
|
||||
expect(
|
||||
buildApprovalResolutionRef({
|
||||
approvalId: "same-id",
|
||||
approvalKind: "exec",
|
||||
instanceId: "instance-2",
|
||||
}),
|
||||
).not.toBe(first);
|
||||
});
|
||||
|
||||
it.each(["", "a".repeat(42), "a".repeat(44), "!".repeat(43)])(
|
||||
"rejects malformed transport references %#",
|
||||
(value) => {
|
||||
|
||||
@@ -7,12 +7,16 @@ const APPROVAL_RESOLUTION_REF_LENGTH = 43;
|
||||
export function buildApprovalResolutionRef(params: {
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin" | "system-agent";
|
||||
instanceId?: string;
|
||||
}): string {
|
||||
return createHash("sha256")
|
||||
const hash = createHash("sha256")
|
||||
.update(params.approvalKind, "utf8")
|
||||
.update("\0", "utf8")
|
||||
.update(params.approvalId, "utf8")
|
||||
.digest("base64url");
|
||||
.update(params.approvalId, "utf8");
|
||||
if (params.instanceId) {
|
||||
hash.update("\0", "utf8").update(params.instanceId, "utf8");
|
||||
}
|
||||
return hash.digest("base64url");
|
||||
}
|
||||
|
||||
export function isApprovalResolutionRef(value: string): boolean {
|
||||
|
||||
@@ -64,6 +64,7 @@ function buildExecViewBase<TPhase extends ApprovalPhase>(
|
||||
const { commandText, commandPreview } = resolveExecApprovalCommandDisplay(request.request);
|
||||
return {
|
||||
approvalId: request.id,
|
||||
...(request.instanceId ? { instanceId: request.instanceId } : {}),
|
||||
approvalKind: "exec",
|
||||
phase,
|
||||
title: phase === "pending" ? "Exec Approval Required" : "Exec Approval",
|
||||
@@ -89,6 +90,7 @@ function buildPluginViewBase<TPhase extends ApprovalPhase>(
|
||||
): PluginApprovalViewBase & { phase: TPhase } {
|
||||
return {
|
||||
approvalId: request.id,
|
||||
...(request.instanceId ? { instanceId: request.instanceId } : {}),
|
||||
approvalKind: "plugin",
|
||||
phase,
|
||||
title: request.request.title,
|
||||
@@ -109,6 +111,7 @@ export function buildPendingApprovalView(request: ApprovalRequest): PendingAppro
|
||||
...buildPluginViewBase(normalizedRequest, "pending"),
|
||||
actions: buildTypedApprovalActionDescriptors({
|
||||
approvalCommandId: normalizedRequest.id,
|
||||
instanceId: normalizedRequest.instanceId,
|
||||
approvalKind: normalizedRequest.approvalKind,
|
||||
allowedDecisions: resolveCanonicalPluginApprovalRequestAllowedDecisions(
|
||||
normalizedRequest.request,
|
||||
@@ -121,6 +124,7 @@ export function buildPendingApprovalView(request: ApprovalRequest): PendingAppro
|
||||
...buildExecViewBase(normalizedRequest, "pending"),
|
||||
actions: buildTypedApprovalActionDescriptors({
|
||||
approvalCommandId: normalizedRequest.id,
|
||||
instanceId: normalizedRequest.instanceId,
|
||||
approvalKind: normalizedRequest.approvalKind,
|
||||
ask: normalizedRequest.request.ask,
|
||||
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(normalizedRequest.request),
|
||||
|
||||
@@ -29,6 +29,7 @@ export type ApprovalMetadataView = {
|
||||
|
||||
type ApprovalViewBase = {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
phase: ApprovalPhase;
|
||||
title: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ export type ExecApprovalUnavailableReason =
|
||||
|
||||
export type ExecApprovalReplyMetadata = {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalSlug: string;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
agentId?: string;
|
||||
@@ -59,6 +60,7 @@ export type TypedApprovalActionDescriptor = ExecApprovalActionDescriptor & {
|
||||
export type ExecApprovalPendingReplyParams = {
|
||||
warningText?: string;
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalSlug: string;
|
||||
approvalCommandId?: string;
|
||||
ask?: string | null;
|
||||
@@ -139,6 +141,7 @@ export function buildExecApprovalCommandText(params: {
|
||||
|
||||
type BuildExecApprovalActionDescriptorsParams = {
|
||||
approvalCommandId: string;
|
||||
instanceId?: string;
|
||||
ask?: string | null;
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
};
|
||||
@@ -220,6 +223,7 @@ export function buildTypedApprovalActionDescriptors(
|
||||
action: {
|
||||
type: "approval",
|
||||
approvalId,
|
||||
...(params.instanceId ? { instanceId: params.instanceId } : {}),
|
||||
approvalKind: params.approvalKind,
|
||||
decision: descriptor.decision,
|
||||
},
|
||||
@@ -254,6 +258,7 @@ export function buildApprovalPresentationFromActionDescriptors(
|
||||
|
||||
type BuildApprovalPresentationParams = {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
ask?: string | null;
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
};
|
||||
@@ -265,6 +270,7 @@ export function buildApprovalButtonPresentation(
|
||||
return buildApprovalPresentationFromActionDescriptors(
|
||||
buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: params.approvalId,
|
||||
instanceId: params.instanceId,
|
||||
ask: params.ask,
|
||||
allowedDecisions: params.allowedDecisions,
|
||||
}),
|
||||
@@ -278,6 +284,7 @@ export function buildTypedApprovalPresentation(
|
||||
return buildApprovalPresentationFromActionDescriptors(
|
||||
buildTypedApprovalActionDescriptors({
|
||||
approvalCommandId: params.approvalId,
|
||||
instanceId: params.instanceId,
|
||||
approvalKind: params.approvalKind,
|
||||
ask: params.ask,
|
||||
allowedDecisions: params.allowedDecisions,
|
||||
@@ -301,11 +308,13 @@ export function buildExecApprovalPresentation(params: {
|
||||
/** Build an exec-approval presentation with canonical typed decision actions. */
|
||||
export function buildTypedExecApprovalPresentation(params: {
|
||||
approvalCommandId: string;
|
||||
instanceId?: string;
|
||||
ask?: string | null;
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
}): MessagePresentation | undefined {
|
||||
return buildTypedApprovalPresentation({
|
||||
approvalId: params.approvalCommandId,
|
||||
instanceId: params.instanceId,
|
||||
approvalKind: "exec",
|
||||
ask: params.ask,
|
||||
allowedDecisions: params.allowedDecisions,
|
||||
@@ -448,6 +457,7 @@ export function buildExecApprovalPendingReplyPayload(
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: params.approvalId,
|
||||
...(params.instanceId ? { instanceId: params.instanceId } : {}),
|
||||
approvalSlug: params.approvalSlug,
|
||||
approvalKind: "exec",
|
||||
agentId: normalizeOptionalString(params.agentId),
|
||||
@@ -467,6 +477,7 @@ export function buildTypedExecApprovalPendingReplyPayload(
|
||||
...payload,
|
||||
presentation: buildTypedExecApprovalPresentation({
|
||||
approvalCommandId: params.approvalId,
|
||||
instanceId: params.instanceId,
|
||||
allowedDecisions: resolveAllowedDecisions(params),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ export type PluginApprovalRequest = {
|
||||
/** Descriptive wire metadata; readers derive it from the payload when absent. */
|
||||
approvalKind?: "plugin";
|
||||
id: string;
|
||||
instanceId?: string;
|
||||
request: PluginApprovalRequestPayload;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
|
||||
@@ -85,6 +85,7 @@ export type MessagePresentationAction =
|
||||
/** Resolve one durable operator approval without exposing transport callback data. */
|
||||
type: "approval";
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
decision: "allow-once" | "allow-always" | "deny";
|
||||
}
|
||||
@@ -528,17 +529,25 @@ function normalizePresentationAction(raw: unknown): MessagePresentationAction |
|
||||
return undefined;
|
||||
}
|
||||
const approvalId = record.approvalId;
|
||||
const instanceId = record.instanceId;
|
||||
const approvalKind = record.approvalKind;
|
||||
const decision = record.decision;
|
||||
if (
|
||||
typeof approvalId !== "string" ||
|
||||
!isWellFormedApprovalId(approvalId) ||
|
||||
(instanceId !== undefined && (typeof instanceId !== "string" || !instanceId)) ||
|
||||
(approvalKind !== "exec" && approvalKind !== "plugin") ||
|
||||
(decision !== "allow-once" && decision !== "allow-always" && decision !== "deny")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { type: "approval", approvalId, approvalKind, decision };
|
||||
return {
|
||||
type: "approval",
|
||||
approvalId,
|
||||
...(typeof instanceId === "string" ? { instanceId } : {}),
|
||||
approvalKind,
|
||||
decision,
|
||||
};
|
||||
}
|
||||
if (type === "question") {
|
||||
if (record.type !== "question") {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ReplyPayload } from "./reply-payload.js";
|
||||
/** Validated identity and decisions shared by typed approval delivery surfaces. */
|
||||
export type ApprovalReactionDeliveryBinding = {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
allowedDecisions: ExecApprovalReplyDecision[];
|
||||
approvalSlug?: string;
|
||||
@@ -72,6 +73,7 @@ export function readApprovalReactionDeliveryMetadata(
|
||||
}
|
||||
const approvalId = options.trimApprovalId ? record.approvalId.trim() : record.approvalId;
|
||||
const approvalSlug = typeof record.approvalSlug === "string" ? record.approvalSlug.trim() : "";
|
||||
const instanceId = typeof record.instanceId === "string" ? record.instanceId : undefined;
|
||||
const allowedDecisions = readApprovalReactionDecisionList(record.allowedDecisions);
|
||||
if (
|
||||
!approvalId ||
|
||||
@@ -84,6 +86,7 @@ export function readApprovalReactionDeliveryMetadata(
|
||||
}
|
||||
return {
|
||||
approvalId,
|
||||
...(instanceId ? { instanceId } : {}),
|
||||
approvalKind: record.approvalKind,
|
||||
allowedDecisions,
|
||||
...(approvalSlug ? { approvalSlug } : {}),
|
||||
@@ -112,7 +115,9 @@ export function readApprovalReactionPresentationBinding(params: {
|
||||
!actions?.length ||
|
||||
actions.some(
|
||||
(action) =>
|
||||
action.approvalId !== metadata.approvalId || action.approvalKind !== metadata.approvalKind,
|
||||
action.approvalId !== metadata.approvalId ||
|
||||
action.approvalKind !== metadata.approvalKind ||
|
||||
action.instanceId !== metadata.instanceId,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
@@ -141,9 +146,11 @@ export function readApprovalReactionDeliveredBinding(params: {
|
||||
? record.approvalId.trim()
|
||||
: record.approvalId;
|
||||
const markerSlug = typeof record.approvalSlug === "string" ? record.approvalSlug.trim() : "";
|
||||
const markerInstanceId = typeof record.instanceId === "string" ? record.instanceId : undefined;
|
||||
const decisions = readApprovalReactionDecisionList(record.allowedDecisions);
|
||||
return record.version === 1 &&
|
||||
markerId === metadata.approvalId &&
|
||||
markerInstanceId === metadata.instanceId &&
|
||||
record.approvalKind === metadata.approvalKind &&
|
||||
(!params.requireApprovalSlug || markerSlug === metadata.approvalSlug) &&
|
||||
decisions &&
|
||||
|
||||
@@ -68,6 +68,7 @@ export type ApprovalReactionDecisionResolution = {
|
||||
/** Stored target metadata needed to convert a reaction into an approval decision. */
|
||||
export type ApprovalReactionTargetRecord<TRoute = unknown> = {
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
/** Explicit ownership; omission is supported only by the deprecated resolver. */
|
||||
approvalKind?: ChannelApprovalKind;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
|
||||
@@ -21,6 +21,7 @@ const DEFAULT_ALLOWED_DECISIONS = ["allow-once", "allow-always", "deny"] as cons
|
||||
type BuildApprovalPendingReplyPayloadParams = {
|
||||
/** Stable approval id used by `/approve` commands and metadata correlation. */
|
||||
approvalId: string;
|
||||
instanceId?: string;
|
||||
/** Short channel-facing approval slug for compact metadata displays. */
|
||||
approvalSlug: string;
|
||||
/** Visible approval request text sent to the channel. */
|
||||
@@ -51,6 +52,7 @@ export function buildApprovalPendingReplyPayload(
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: params.approvalId,
|
||||
...(params.instanceId ? { instanceId: params.instanceId } : {}),
|
||||
approvalSlug: params.approvalSlug,
|
||||
approvalKind: params.approvalKind ?? "exec",
|
||||
agentId: normalizeOptionalString(params.agentId),
|
||||
@@ -72,6 +74,7 @@ export function buildTypedApprovalPendingReplyPayload(
|
||||
...payload,
|
||||
presentation: buildTypedApprovalPresentation({
|
||||
approvalId: params.approvalId,
|
||||
instanceId: params.instanceId,
|
||||
approvalKind: params.approvalKind,
|
||||
allowedDecisions: params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS,
|
||||
}),
|
||||
@@ -124,6 +127,7 @@ export function buildPluginApprovalPendingReplyPayload(
|
||||
return buildApprovalPendingReplyPayload({
|
||||
approvalKind: "plugin",
|
||||
approvalId: params.request.id,
|
||||
instanceId: params.request.instanceId,
|
||||
approvalSlug: params.approvalSlug ?? params.request.id.slice(0, 8),
|
||||
text: params.text ?? buildPluginApprovalRequestMessage(params.request, params.nowMs),
|
||||
allowedDecisions:
|
||||
@@ -140,6 +144,7 @@ export function buildTypedPluginApprovalPendingReplyPayload(
|
||||
return buildTypedApprovalPendingReplyPayload({
|
||||
approvalKind: "plugin",
|
||||
approvalId: params.request.id,
|
||||
instanceId: params.request.instanceId,
|
||||
approvalSlug: params.approvalSlug ?? params.request.id.slice(0, 8),
|
||||
text: params.text ?? buildPluginApprovalRequestMessage(params.request, params.nowMs),
|
||||
allowedDecisions: resolveCanonicalPluginApprovalRequestAllowedDecisions({
|
||||
|
||||
Reference in New Issue
Block a user