fix(approvals): bind portable actions to lifecycle

This commit is contained in:
Dallin Romney
2026-08-21 01:26:58 -07:00
parent 4c9aa453c5
commit abbb43d5cc
29 changed files with 245 additions and 31 deletions
+10 -1
View File
@@ -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,
+11 -1
View File
@@ -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 = {
+17 -2
View File
@@ -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,
+6
View File
@@ -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,
+3 -4
View File
@@ -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 });
+4
View File
@@ -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 } : {}),
});
+18
View File
@@ -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 -3
View File
@@ -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 {
+4
View File
@@ -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),
+1
View File
@@ -29,6 +29,7 @@ export type ApprovalMetadataView = {
type ApprovalViewBase = {
approvalId: string;
instanceId?: string;
approvalKind: ChannelApprovalKind;
phase: ApprovalPhase;
title: string;
+11
View File
@@ -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),
}),
};
+1
View File
@@ -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;
+10 -1
View File
@@ -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") {
+8 -1
View File
@@ -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[];
+5
View File
@@ -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({