diff --git a/extensions/matrix/src/exec-approvals.test.ts b/extensions/matrix/src/exec-approvals.test.ts index 18a2d5a1162..379729a8c51 100644 --- a/extensions/matrix/src/exec-approvals.test.ts +++ b/extensions/matrix/src/exec-approvals.test.ts @@ -159,6 +159,51 @@ describe("matrix exec approvals", () => { ).toBe(false); }); + it("suppresses local prompts for generic exec payloads when metadata matches filters", () => { + const payload = { + channelData: { + execApproval: { + approvalId: "req-1", + approvalSlug: "req-1", + approvalKind: "exec", + agentId: "ops-agent", + sessionKey: "agent:ops-agent:matrix:channel:!ops:example.org", + }, + }, + }; + + expect( + shouldSuppressLocalMatrixExecApprovalPrompt({ + cfg: buildConfig({ + enabled: true, + approvers: ["@owner:example.org"], + agentFilter: ["ops-agent"], + sessionFilter: ["matrix:channel:"], + }), + payload, + }), + ).toBe(true); + }); + + it("does not suppress local prompts for plugin approval payloads", () => { + const payload = { + channelData: { + execApproval: { + approvalId: "plugin:req-1", + approvalSlug: "plugin:r", + approvalKind: "plugin", + }, + }, + }; + + expect( + shouldSuppressLocalMatrixExecApprovalPrompt({ + cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }), + payload, + }), + ).toBe(false); + }); + it("normalizes prefixed approver ids", () => { expect(normalizeMatrixApproverId("matrix:@owner:example.org")).toBe("@owner:example.org"); expect(normalizeMatrixApproverId("user:@owner:example.org")).toBe("@owner:example.org"); diff --git a/extensions/matrix/src/exec-approvals.ts b/extensions/matrix/src/exec-approvals.ts index 561747772a2..5c53a50dc21 100644 --- a/extensions/matrix/src/exec-approvals.ts +++ b/extensions/matrix/src/exec-approvals.ts @@ -117,6 +117,9 @@ export function shouldSuppressLocalMatrixExecApprovalPrompt(params: { if (!metadata) { return false; } + if (metadata.approvalKind !== "exec") { + return false; + } const request = buildFilterCheckRequest({ metadata, }); diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index ca4ee8230c1..38bf2dd91cb 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -483,6 +483,31 @@ describe("exec approval forwarder", () => { ); }); + it("stores exec metadata on generic forwarded fallback payloads", async () => { + vi.useFakeTimers(); + const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG }); + + await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + payloads: [ + expect.objectContaining({ + channelData: expect.objectContaining({ + execApproval: expect.objectContaining({ + approvalId: "req-1", + approvalKind: "exec", + agentId: "main", + sessionKey: "agent:main:main", + }), + }), + }), + ], + }), + ); + }); + it("formats single-line commands as inline code", async () => { vi.useFakeTimers(); const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index a4acff6e515..b3646f113f8 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -353,7 +353,9 @@ function buildExecPendingPayload(params: { approvalId: params.request.id, approvalSlug: params.request.id.slice(0, 8), text: buildRequestMessage(params.request, params.nowMs), + agentId: params.request.request.agentId ?? null, allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request), + sessionKey: params.request.request.sessionKey ?? null, }); } diff --git a/src/infra/exec-approval-reply.test.ts b/src/infra/exec-approval-reply.test.ts index ec7c1dc4287..7e32a3a071f 100644 --- a/src/infra/exec-approval-reply.test.ts +++ b/src/infra/exec-approval-reply.test.ts @@ -85,6 +85,7 @@ describe("exec approval reply helpers", () => { ).toEqual({ approvalId: "req-1", approvalSlug: "slug-1", + approvalKind: "exec", agentId: "agent-1", allowedDecisions: ["allow-once", "deny", "allow-always"], sessionKey: "session-1", @@ -108,6 +109,7 @@ describe("exec approval reply helpers", () => { execApproval: { approvalId: "req-1", approvalSlug: "slug-1", + approvalKind: "exec", agentId: undefined, allowedDecisions: ["allow-once", "allow-always", "deny"], sessionKey: undefined, @@ -157,6 +159,7 @@ describe("exec approval reply helpers", () => { execApproval: { approvalId: "req-ask-always", approvalSlug: "slug-always", + approvalKind: "exec", allowedDecisions: ["allow-once", "deny"], }, }); @@ -200,6 +203,7 @@ describe("exec approval reply helpers", () => { execApproval: { approvalId: "req-meta", approvalSlug: "slug-meta", + approvalKind: "exec", agentId: "ops-agent", allowedDecisions: ["allow-once", "allow-always", "deny"], sessionKey: "agent:ops-agent:matrix:channel:!room:example.org", diff --git a/src/infra/exec-approval-reply.ts b/src/infra/exec-approval-reply.ts index 1f4c4189644..6f53fe23567 100644 --- a/src/infra/exec-approval-reply.ts +++ b/src/infra/exec-approval-reply.ts @@ -15,6 +15,7 @@ export type ExecApprovalUnavailableReason = export type ExecApprovalReplyMetadata = { approvalId: string; approvalSlug: string; + approvalKind: "exec" | "plugin"; agentId?: string; allowedDecisions?: readonly ExecApprovalReplyDecision[]; sessionKey?: string; @@ -229,6 +230,7 @@ export function getExecApprovalReplyMetadata( if (!approvalId || !approvalSlug) { return null; } + const approvalKind = record.approvalKind === "plugin" ? "plugin" : "exec"; const allowedDecisions = Array.isArray(record.allowedDecisions) ? record.allowedDecisions.filter( (value): value is ExecApprovalReplyDecision => @@ -242,6 +244,7 @@ export function getExecApprovalReplyMetadata( return { approvalId, approvalSlug, + approvalKind, agentId, allowedDecisions, sessionKey, @@ -307,6 +310,7 @@ export function buildExecApprovalPendingReplyPayload( execApproval: { approvalId: params.approvalId, approvalSlug: params.approvalSlug, + approvalKind: "exec", agentId: params.agentId?.trim() || undefined, allowedDecisions, sessionKey: params.sessionKey?.trim() || undefined, diff --git a/src/plugin-sdk/approval-renderers.test.ts b/src/plugin-sdk/approval-renderers.test.ts index 1df2f1ea708..00c9994880b 100644 --- a/src/plugin-sdk/approval-renderers.test.ts +++ b/src/plugin-sdk/approval-renderers.test.ts @@ -89,9 +89,12 @@ describe("plugin-sdk/approval-renderers", () => { }, channelDataExpected: { execApproval: { + agentId: undefined, approvalId: "plugin-approval-123", + approvalKind: "plugin", approvalSlug: "custom-slug", allowedDecisions: ["allow-once", "allow-always", "deny"], + sessionKey: undefined, state: "pending", }, telegram: { diff --git a/src/plugin-sdk/approval-renderers.ts b/src/plugin-sdk/approval-renderers.ts index ca5be8eba58..8feebb43c8d 100644 --- a/src/plugin-sdk/approval-renderers.ts +++ b/src/plugin-sdk/approval-renderers.ts @@ -13,10 +13,13 @@ import { const DEFAULT_ALLOWED_DECISIONS = ["allow-once", "allow-always", "deny"] as const; export function buildApprovalPendingReplyPayload(params: { + approvalKind?: "exec" | "plugin"; approvalId: string; approvalSlug: string; text: string; + agentId?: string | null; allowedDecisions?: readonly ExecApprovalReplyDecision[]; + sessionKey?: string | null; channelData?: Record; }): ReplyPayload { const allowedDecisions = params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS; @@ -30,7 +33,10 @@ export function buildApprovalPendingReplyPayload(params: { execApproval: { approvalId: params.approvalId, approvalSlug: params.approvalSlug, + approvalKind: params.approvalKind ?? "exec", + agentId: params.agentId?.trim() || undefined, allowedDecisions, + sessionKey: params.sessionKey?.trim() || undefined, state: "pending", }, ...params.channelData, @@ -66,6 +72,7 @@ export function buildPluginApprovalPendingReplyPayload(params: { channelData?: Record; }): ReplyPayload { return buildApprovalPendingReplyPayload({ + approvalKind: "plugin", approvalId: params.request.id, approvalSlug: params.approvalSlug ?? params.request.id.slice(0, 8), text: params.text ?? buildPluginApprovalRequestMessage(params.request, params.nowMs),