@vellumai/assistant 0.9.0-dev.202606182057.d0c9925 → 0.9.0-dev.202606182147.d5eebd3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/guardian-card-withdrawal.test.ts +278 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +219 -2
- package/src/approvals/guardian-card-withdrawal.ts +145 -0
- package/src/approvals/guardian-decision-primitive.ts +18 -0
- package/src/approvals/guardian-request-resolvers.ts +181 -78
- package/src/calls/guardian-dispatch.ts +2 -6
- package/src/daemon/conversation-surfaces.ts +26 -0
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/messaging/providers/slack/api.ts +31 -0
- package/src/messaging/providers/slack/withdraw.test.ts +200 -0
- package/src/messaging/providers/slack/withdraw.ts +161 -0
- package/src/notifications/approval-card-data.ts +38 -2
- package/src/notifications/canonical-delivery-recorder.ts +39 -0
- package/src/runtime/access-request-helper.ts +5 -6
- package/src/runtime/confirmation-request-guardian-bridge.ts +2 -10
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +17 -8
- package/src/runtime/tool-grant-request-helper.ts +2 -6
package/package.json
CHANGED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("../util/logger.js", () => ({
|
|
4
|
+
getLogger: () =>
|
|
5
|
+
new Proxy({} as Record<string, unknown>, {
|
|
6
|
+
get: () => () => {},
|
|
7
|
+
}),
|
|
8
|
+
truncateForLog: (value: string) => value,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const completeSurfaceAndNotify = mock(() => {});
|
|
12
|
+
mock.module("../daemon/conversation-surfaces.js", () => ({
|
|
13
|
+
completeSurfaceAndNotify,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const withdrawSlackApprovalCard = mock(
|
|
17
|
+
async (_params: Record<string, unknown>) => {},
|
|
18
|
+
);
|
|
19
|
+
mock.module("../messaging/providers/slack/withdraw.js", () => ({
|
|
20
|
+
withdrawSlackApprovalCard,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { withdrawGuardianRequestCards } from "../approvals/guardian-card-withdrawal.js";
|
|
24
|
+
import {
|
|
25
|
+
type CanonicalGuardianRequest,
|
|
26
|
+
createCanonicalGuardianDelivery,
|
|
27
|
+
createCanonicalGuardianRequest,
|
|
28
|
+
listCanonicalGuardianDeliveries,
|
|
29
|
+
} from "../memory/canonical-guardian-store.js";
|
|
30
|
+
import { getDb } from "../memory/db-connection.js";
|
|
31
|
+
import { initializeDb } from "../memory/db-init.js";
|
|
32
|
+
import { recordCanonicalChannelDelivery } from "../notifications/canonical-delivery-recorder.js";
|
|
33
|
+
import type { NotificationDeliveryResult } from "../notifications/types.js";
|
|
34
|
+
|
|
35
|
+
initializeDb();
|
|
36
|
+
|
|
37
|
+
const PRINCIPAL_ID = "withdrawal-test-principal";
|
|
38
|
+
|
|
39
|
+
function resetTables(): void {
|
|
40
|
+
const db = getDb();
|
|
41
|
+
db.run("DELETE FROM canonical_guardian_deliveries");
|
|
42
|
+
db.run("DELETE FROM canonical_guardian_requests");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeRequest(
|
|
46
|
+
overrides: Partial<Parameters<typeof createCanonicalGuardianRequest>[0]> = {},
|
|
47
|
+
): CanonicalGuardianRequest {
|
|
48
|
+
return createCanonicalGuardianRequest({
|
|
49
|
+
kind: "access_request",
|
|
50
|
+
sourceType: "channel",
|
|
51
|
+
sourceChannel: "slack",
|
|
52
|
+
guardianPrincipalId: PRINCIPAL_ID,
|
|
53
|
+
...overrides,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("withdrawGuardianRequestCards", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
resetTables();
|
|
60
|
+
completeSurfaceAndNotify.mockClear();
|
|
61
|
+
withdrawSlackApprovalCard.mockClear();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("withdraws + broadcasts the in-app card when the decision came from another surface", async () => {
|
|
65
|
+
const req = makeRequest();
|
|
66
|
+
createCanonicalGuardianDelivery({
|
|
67
|
+
requestId: req.id,
|
|
68
|
+
destinationChannel: "vellum",
|
|
69
|
+
destinationConversationId: "conv-1",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await withdrawGuardianRequestCards({
|
|
73
|
+
request: req,
|
|
74
|
+
status: "approved",
|
|
75
|
+
originChannel: "slack",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(completeSurfaceAndNotify).toHaveBeenCalledTimes(1);
|
|
79
|
+
expect(completeSurfaceAndNotify).toHaveBeenCalledWith(
|
|
80
|
+
"conv-1",
|
|
81
|
+
`access-request-${req.id}`,
|
|
82
|
+
"Approved",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("skips the in-app card when the decision originated in-app", async () => {
|
|
87
|
+
const req = makeRequest();
|
|
88
|
+
createCanonicalGuardianDelivery({
|
|
89
|
+
requestId: req.id,
|
|
90
|
+
destinationChannel: "vellum",
|
|
91
|
+
destinationConversationId: "conv-1",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await withdrawGuardianRequestCards({
|
|
95
|
+
request: req,
|
|
96
|
+
status: "approved",
|
|
97
|
+
originChannel: "vellum",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// The acting in-app client already completed its own card optimistically.
|
|
101
|
+
expect(completeSurfaceAndNotify).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("withdraws the Slack card with decider and decision time", async () => {
|
|
105
|
+
const req = makeRequest({ decidedByExternalUserId: "U-guardian" });
|
|
106
|
+
createCanonicalGuardianDelivery({
|
|
107
|
+
requestId: req.id,
|
|
108
|
+
destinationChannel: "slack",
|
|
109
|
+
destinationChatId: "C123",
|
|
110
|
+
destinationMessageId: "1700000000.0001",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await withdrawGuardianRequestCards({
|
|
114
|
+
request: req,
|
|
115
|
+
status: "denied",
|
|
116
|
+
originChannel: "vellum",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(withdrawSlackApprovalCard).toHaveBeenCalledTimes(1);
|
|
120
|
+
const [params] = withdrawSlackApprovalCard.mock.calls[0];
|
|
121
|
+
expect(params).toMatchObject({
|
|
122
|
+
channel: "C123",
|
|
123
|
+
messageTs: "1700000000.0001",
|
|
124
|
+
status: "denied",
|
|
125
|
+
decidedByExternalUserId: "U-guardian",
|
|
126
|
+
});
|
|
127
|
+
expect(typeof (params as { decidedAtMs?: number }).decidedAtMs).toBe(
|
|
128
|
+
"number",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("skips the Slack edit when no channel message id was captured", async () => {
|
|
133
|
+
const req = makeRequest();
|
|
134
|
+
createCanonicalGuardianDelivery({
|
|
135
|
+
requestId: req.id,
|
|
136
|
+
destinationChannel: "slack",
|
|
137
|
+
destinationChatId: "C123",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await withdrawGuardianRequestCards({ request: req, status: "approved" });
|
|
141
|
+
|
|
142
|
+
expect(withdrawSlackApprovalCard).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("withdraws every surface (including in-app broadcast) when no origin channel", async () => {
|
|
146
|
+
const req = makeRequest();
|
|
147
|
+
createCanonicalGuardianDelivery({
|
|
148
|
+
requestId: req.id,
|
|
149
|
+
destinationChannel: "vellum",
|
|
150
|
+
destinationConversationId: "conv-1",
|
|
151
|
+
});
|
|
152
|
+
createCanonicalGuardianDelivery({
|
|
153
|
+
requestId: req.id,
|
|
154
|
+
destinationChannel: "slack",
|
|
155
|
+
destinationChatId: "C1",
|
|
156
|
+
destinationMessageId: "1.0",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await withdrawGuardianRequestCards({ request: req, status: "expired" });
|
|
160
|
+
|
|
161
|
+
expect(completeSurfaceAndNotify).toHaveBeenCalledWith(
|
|
162
|
+
"conv-1",
|
|
163
|
+
`access-request-${req.id}`,
|
|
164
|
+
"Expired",
|
|
165
|
+
);
|
|
166
|
+
expect(withdrawSlackApprovalCard).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("ignores channels without in-place edit support (telegram)", async () => {
|
|
170
|
+
const req = makeRequest({ sourceChannel: "telegram" });
|
|
171
|
+
createCanonicalGuardianDelivery({
|
|
172
|
+
requestId: req.id,
|
|
173
|
+
destinationChannel: "telegram",
|
|
174
|
+
destinationChatId: "T1",
|
|
175
|
+
destinationMessageId: "9",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await withdrawGuardianRequestCards({ request: req, status: "approved" });
|
|
179
|
+
|
|
180
|
+
expect(withdrawSlackApprovalCard).not.toHaveBeenCalled();
|
|
181
|
+
expect(completeSurfaceAndNotify).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("is best-effort: a failing surface never blocks the others or throws", async () => {
|
|
185
|
+
withdrawSlackApprovalCard.mockImplementationOnce(async () => {
|
|
186
|
+
throw new Error("slack unavailable");
|
|
187
|
+
});
|
|
188
|
+
const req = makeRequest();
|
|
189
|
+
createCanonicalGuardianDelivery({
|
|
190
|
+
requestId: req.id,
|
|
191
|
+
destinationChannel: "slack",
|
|
192
|
+
destinationChatId: "C1",
|
|
193
|
+
destinationMessageId: "1.0",
|
|
194
|
+
});
|
|
195
|
+
createCanonicalGuardianDelivery({
|
|
196
|
+
requestId: req.id,
|
|
197
|
+
destinationChannel: "vellum",
|
|
198
|
+
destinationConversationId: "conv-1",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
withdrawGuardianRequestCards({
|
|
203
|
+
request: req,
|
|
204
|
+
status: "approved",
|
|
205
|
+
originChannel: "telegram",
|
|
206
|
+
}),
|
|
207
|
+
).resolves.toBeUndefined();
|
|
208
|
+
|
|
209
|
+
// The in-app card was still withdrawn despite the Slack failure.
|
|
210
|
+
expect(completeSurfaceAndNotify).toHaveBeenCalledTimes(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("tool-approval cards resolve to the tool-approval surface id", async () => {
|
|
214
|
+
const req = makeRequest({ kind: "tool_approval", toolName: "shell" });
|
|
215
|
+
createCanonicalGuardianDelivery({
|
|
216
|
+
requestId: req.id,
|
|
217
|
+
destinationChannel: "vellum",
|
|
218
|
+
destinationConversationId: "conv-1",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await withdrawGuardianRequestCards({
|
|
222
|
+
request: req,
|
|
223
|
+
status: "approved",
|
|
224
|
+
originChannel: "telegram",
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(completeSurfaceAndNotify).toHaveBeenCalledWith(
|
|
228
|
+
"conv-1",
|
|
229
|
+
`tool-approval-${req.id}`,
|
|
230
|
+
"Approved",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("recordCanonicalChannelDelivery", () => {
|
|
236
|
+
beforeEach(() => {
|
|
237
|
+
resetTables();
|
|
238
|
+
withdrawSlackApprovalCard.mockClear();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("captures the channel-native message id so the card can be withdrawn", async () => {
|
|
242
|
+
const req = makeRequest();
|
|
243
|
+
const result: NotificationDeliveryResult = {
|
|
244
|
+
channel: "slack",
|
|
245
|
+
destination: "C999",
|
|
246
|
+
status: "sent",
|
|
247
|
+
messageId: "1700000000.1234",
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const delivery = recordCanonicalChannelDelivery(req.id, result);
|
|
251
|
+
|
|
252
|
+
expect(delivery.destinationChannel).toBe("slack");
|
|
253
|
+
expect(delivery.destinationChatId).toBe("C999");
|
|
254
|
+
expect(delivery.destinationMessageId).toBe("1700000000.1234");
|
|
255
|
+
|
|
256
|
+
// End-to-end: the recorded delivery is now editable by the withdrawal path.
|
|
257
|
+
await withdrawGuardianRequestCards({
|
|
258
|
+
request: req,
|
|
259
|
+
status: "approved",
|
|
260
|
+
originChannel: "vellum",
|
|
261
|
+
});
|
|
262
|
+
const [params] = withdrawSlackApprovalCard.mock.calls[0];
|
|
263
|
+
expect((params as { messageTs?: string }).messageTs).toBe(
|
|
264
|
+
"1700000000.1234",
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("omits chat id when the destination is empty", () => {
|
|
269
|
+
const req = makeRequest();
|
|
270
|
+
const delivery = recordCanonicalChannelDelivery(req.id, {
|
|
271
|
+
channel: "slack",
|
|
272
|
+
destination: "",
|
|
273
|
+
status: "sent",
|
|
274
|
+
});
|
|
275
|
+
expect(delivery.destinationChatId).toBeNull();
|
|
276
|
+
expect(listCanonicalGuardianDeliveries(req.id)).toHaveLength(1);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -92,7 +92,7 @@ mock.module("../runtime/channel-verification-service.js", () => ({
|
|
|
92
92
|
return null;
|
|
93
93
|
},
|
|
94
94
|
createOutboundSession: () => ({
|
|
95
|
-
|
|
95
|
+
sessionId: "test-session",
|
|
96
96
|
secret: "123456",
|
|
97
97
|
}),
|
|
98
98
|
bindSessionIdentity: () => {},
|
|
@@ -108,16 +108,23 @@ mock.module("../runtime/channel-verification-service.js", () => ({
|
|
|
108
108
|
}),
|
|
109
109
|
}));
|
|
110
110
|
|
|
111
|
-
// Mock gateway client — capture delivery calls
|
|
111
|
+
// Mock gateway client — capture delivery calls. `failDeliveryWhen` lets a test
|
|
112
|
+
// simulate a delivery failure for a specific payload (e.g. a DM that can't be
|
|
113
|
+
// opened) so fallback paths can be exercised.
|
|
112
114
|
const deliveredReplies: Array<{
|
|
113
115
|
url: string;
|
|
114
116
|
payload: Record<string, unknown>;
|
|
115
117
|
}> = [];
|
|
118
|
+
let failDeliveryWhen: ((payload: Record<string, unknown>) => boolean) | null =
|
|
119
|
+
null;
|
|
116
120
|
mock.module("../runtime/gateway-client.js", () => ({
|
|
117
121
|
deliverChannelReply: async (
|
|
118
122
|
url: string,
|
|
119
123
|
payload: Record<string, unknown>,
|
|
120
124
|
) => {
|
|
125
|
+
if (failDeliveryWhen?.(payload)) {
|
|
126
|
+
throw new Error("simulated delivery failure");
|
|
127
|
+
}
|
|
121
128
|
deliveredReplies.push({ url, payload });
|
|
122
129
|
return { ok: true };
|
|
123
130
|
},
|
|
@@ -150,6 +157,7 @@ mock.module("../config/env.js", () => ({
|
|
|
150
157
|
import { applyCanonicalGuardianDecision } from "../approvals/guardian-decision-primitive.js";
|
|
151
158
|
import type { ActorContext } from "../approvals/guardian-request-resolvers.js";
|
|
152
159
|
import { getResolver } from "../approvals/guardian-request-resolvers.js";
|
|
160
|
+
import { upsertContactChannel } from "../contacts/contacts-write.js";
|
|
153
161
|
import type { TrustContext } from "../daemon/trust-context.js";
|
|
154
162
|
import {
|
|
155
163
|
createCanonicalGuardianRequest,
|
|
@@ -1088,3 +1096,212 @@ describe("cross-milestone integration checks", () => {
|
|
|
1088
1096
|
});
|
|
1089
1097
|
});
|
|
1090
1098
|
|
|
1099
|
+
// ===========================================================================
|
|
1100
|
+
// (g) access_request resolver: requester verification-code delivery
|
|
1101
|
+
//
|
|
1102
|
+
// On approval the requester must receive the 6-digit code so the guardian
|
|
1103
|
+
// never has to relay it by hand. On Slack the code is DM'd straight to the
|
|
1104
|
+
// requester (a private path is guaranteed via their user ID); other channels
|
|
1105
|
+
// keep the courier message because a private path to the requester is not
|
|
1106
|
+
// guaranteed there (e.g. a group chat would leak the secret).
|
|
1107
|
+
// ===========================================================================
|
|
1108
|
+
|
|
1109
|
+
describe("(g) access_request resolver: requester code delivery", () => {
|
|
1110
|
+
const REQUESTER_UID = "U_REQUESTER";
|
|
1111
|
+
const GUARDIAN_UID = "U_GUARDIAN";
|
|
1112
|
+
|
|
1113
|
+
function createAccessRequest(overrides: Record<string, unknown> = {}) {
|
|
1114
|
+
return createCanonicalGuardianRequest({
|
|
1115
|
+
id: `access-req-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
1116
|
+
kind: "access_request",
|
|
1117
|
+
sourceType: "channel",
|
|
1118
|
+
sourceChannel: "slack",
|
|
1119
|
+
conversationId: "conv-access-slack",
|
|
1120
|
+
requesterExternalUserId: REQUESTER_UID,
|
|
1121
|
+
requesterChatId: "C_SHARED_CHANNEL",
|
|
1122
|
+
guardianExternalUserId: GUARDIAN_UID,
|
|
1123
|
+
guardianPrincipalId: "test-principal-id",
|
|
1124
|
+
toolName: "ingress_access_request",
|
|
1125
|
+
expiresAt: Date.now() + 60_000,
|
|
1126
|
+
...overrides,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
beforeEach(() => {
|
|
1131
|
+
resetTables();
|
|
1132
|
+
deliveredReplies.length = 0;
|
|
1133
|
+
emittedSignals.length = 0;
|
|
1134
|
+
failDeliveryWhen = null;
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
test("on-channel Slack approval DMs the verification code to the requester", async () => {
|
|
1138
|
+
const req = createAccessRequest();
|
|
1139
|
+
|
|
1140
|
+
const result = await applyCanonicalGuardianDecision({
|
|
1141
|
+
requestId: req.id,
|
|
1142
|
+
action: "approve_once",
|
|
1143
|
+
actorContext: guardianActor({
|
|
1144
|
+
channel: "slack",
|
|
1145
|
+
actorExternalUserId: GUARDIAN_UID,
|
|
1146
|
+
}),
|
|
1147
|
+
channelDeliveryContext: {
|
|
1148
|
+
replyCallbackUrl:
|
|
1149
|
+
"http://localhost:3000/deliver/slack?threadTs=111.222",
|
|
1150
|
+
guardianChatId: "C_SHARED_CHANNEL",
|
|
1151
|
+
assistantId: "self",
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
expect(result.applied).toBe(true);
|
|
1156
|
+
|
|
1157
|
+
// The requester receives the actual code in their DM (chatId = user ID),
|
|
1158
|
+
// not a "ask the guardian" courier message.
|
|
1159
|
+
const requesterCodeReply = deliveredReplies.find(
|
|
1160
|
+
(r) =>
|
|
1161
|
+
r.payload.chatId === REQUESTER_UID &&
|
|
1162
|
+
typeof r.payload.text === "string" &&
|
|
1163
|
+
(r.payload.text as string).includes("123456"),
|
|
1164
|
+
);
|
|
1165
|
+
expect(requesterCodeReply).toBeDefined();
|
|
1166
|
+
expect(requesterCodeReply!.payload.text).toContain(
|
|
1167
|
+
"your access request was approved",
|
|
1168
|
+
);
|
|
1169
|
+
// The code DM is durable, never ephemeral.
|
|
1170
|
+
expect(requesterCodeReply!.payload.ephemeral).toBeUndefined();
|
|
1171
|
+
// threadTs (the guardian's channel thread) is stripped for the DM.
|
|
1172
|
+
expect(requesterCodeReply!.url).not.toContain("threadTs");
|
|
1173
|
+
|
|
1174
|
+
// No courier "receive from the guardian" message goes to the requester.
|
|
1175
|
+
const courier = deliveredReplies.find(
|
|
1176
|
+
(r) =>
|
|
1177
|
+
typeof r.payload.text === "string" &&
|
|
1178
|
+
(r.payload.text as string).includes("receive from the guardian"),
|
|
1179
|
+
);
|
|
1180
|
+
expect(courier).toBeUndefined();
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("desktop-decided approval DMs the code to the Slack requester via the deliver path", async () => {
|
|
1184
|
+
const req = createAccessRequest();
|
|
1185
|
+
|
|
1186
|
+
const result = await applyCanonicalGuardianDecision({
|
|
1187
|
+
requestId: req.id,
|
|
1188
|
+
action: "approve_once",
|
|
1189
|
+
// Desktop decision: no channelDeliveryContext, actor on the vellum channel.
|
|
1190
|
+
actorContext: guardianActor({
|
|
1191
|
+
channel: "vellum",
|
|
1192
|
+
actorExternalUserId: undefined,
|
|
1193
|
+
}),
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
expect(result.applied).toBe(true);
|
|
1197
|
+
|
|
1198
|
+
const requesterCodeReply = deliveredReplies.find(
|
|
1199
|
+
(r) =>
|
|
1200
|
+
r.payload.chatId === REQUESTER_UID &&
|
|
1201
|
+
typeof r.payload.text === "string" &&
|
|
1202
|
+
(r.payload.text as string).includes("123456"),
|
|
1203
|
+
);
|
|
1204
|
+
expect(requesterCodeReply).toBeDefined();
|
|
1205
|
+
expect(requesterCodeReply!.url).toContain("/deliver/slack");
|
|
1206
|
+
expect(requesterCodeReply!.payload.text).toContain(
|
|
1207
|
+
"your access request was approved",
|
|
1208
|
+
);
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
test("non-Slack channel keeps the courier message and never delivers the code to the requester chat", async () => {
|
|
1212
|
+
const req = createAccessRequest({
|
|
1213
|
+
sourceChannel: "telegram",
|
|
1214
|
+
requesterChatId: "requester-chat-1",
|
|
1215
|
+
conversationId: "conv-access-telegram",
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
const result = await applyCanonicalGuardianDecision({
|
|
1219
|
+
requestId: req.id,
|
|
1220
|
+
action: "approve_once",
|
|
1221
|
+
actorContext: guardianActor({
|
|
1222
|
+
channel: "telegram",
|
|
1223
|
+
actorExternalUserId: GUARDIAN_UID,
|
|
1224
|
+
}),
|
|
1225
|
+
channelDeliveryContext: {
|
|
1226
|
+
replyCallbackUrl: "http://localhost:3000/deliver/telegram",
|
|
1227
|
+
guardianChatId: "guardian-chat-1",
|
|
1228
|
+
assistantId: "self",
|
|
1229
|
+
},
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
expect(result.applied).toBe(true);
|
|
1233
|
+
|
|
1234
|
+
// The requester is told to expect the code from the guardian; the secret is
|
|
1235
|
+
// never delivered to the requester's (possibly group) chat.
|
|
1236
|
+
const requesterReply = deliveredReplies.find(
|
|
1237
|
+
(r) => r.payload.chatId === "requester-chat-1",
|
|
1238
|
+
);
|
|
1239
|
+
expect(requesterReply).toBeDefined();
|
|
1240
|
+
expect(requesterReply!.payload.text).toContain("receive from the guardian");
|
|
1241
|
+
expect(requesterReply!.payload.text).not.toContain("123456");
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
test("Slack shared-channel fallback posts an ephemeral notice to the channel when the DM fails", async () => {
|
|
1245
|
+
const req = createAccessRequest();
|
|
1246
|
+
|
|
1247
|
+
// Make the direct DM (to the U... user ID) fail so the courier fallback runs.
|
|
1248
|
+
failDeliveryWhen = (payload) => payload.chatId === REQUESTER_UID;
|
|
1249
|
+
|
|
1250
|
+
const result = await applyCanonicalGuardianDecision({
|
|
1251
|
+
requestId: req.id,
|
|
1252
|
+
action: "approve_once",
|
|
1253
|
+
actorContext: guardianActor({
|
|
1254
|
+
channel: "slack",
|
|
1255
|
+
actorExternalUserId: GUARDIAN_UID,
|
|
1256
|
+
}),
|
|
1257
|
+
channelDeliveryContext: {
|
|
1258
|
+
replyCallbackUrl:
|
|
1259
|
+
"http://localhost:3000/deliver/slack?threadTs=111.222",
|
|
1260
|
+
guardianChatId: "C_SHARED_CHANNEL",
|
|
1261
|
+
assistantId: "self",
|
|
1262
|
+
},
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
expect(result.applied).toBe(true);
|
|
1266
|
+
|
|
1267
|
+
// The courier notice falls back to an ephemeral message targeting the
|
|
1268
|
+
// originating channel (C...), since chat.postEphemeral needs a channel ID —
|
|
1269
|
+
// not the requester's user ID.
|
|
1270
|
+
const courier = deliveredReplies.find(
|
|
1271
|
+
(r) =>
|
|
1272
|
+
typeof r.payload.text === "string" &&
|
|
1273
|
+
(r.payload.text as string).includes("receive from the guardian"),
|
|
1274
|
+
);
|
|
1275
|
+
expect(courier).toBeDefined();
|
|
1276
|
+
expect(courier!.payload.chatId).toBe("C_SHARED_CHANNEL");
|
|
1277
|
+
expect(courier!.payload.ephemeral).toBe(true);
|
|
1278
|
+
expect(courier!.payload.user).toBe(REQUESTER_UID);
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
test("guardian-facing reply uses the requester's display name, not the raw ID", async () => {
|
|
1282
|
+
// Seed a contact so the resolver can resolve a display name.
|
|
1283
|
+
upsertContactChannel({
|
|
1284
|
+
sourceChannel: "slack",
|
|
1285
|
+
externalUserId: REQUESTER_UID,
|
|
1286
|
+
displayName: "Alice",
|
|
1287
|
+
status: "unverified",
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
const req = createAccessRequest();
|
|
1291
|
+
|
|
1292
|
+
const result = await applyCanonicalGuardianDecision({
|
|
1293
|
+
requestId: req.id,
|
|
1294
|
+
action: "approve_once",
|
|
1295
|
+
// Desktop decision → resolver returns guardianReplyText for assertion.
|
|
1296
|
+
actorContext: guardianActor({
|
|
1297
|
+
channel: "vellum",
|
|
1298
|
+
actorExternalUserId: undefined,
|
|
1299
|
+
}),
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
expect(result.applied).toBe(true);
|
|
1303
|
+
const replyText = result.applied ? result.resolverReplyText : undefined;
|
|
1304
|
+
expect(replyText).toContain("Alice");
|
|
1305
|
+
expect(replyText).not.toContain(REQUESTER_UID);
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-surface withdrawal of guardian approval cards.
|
|
3
|
+
*
|
|
4
|
+
* A single guardian request is projected onto every surface it was delivered
|
|
5
|
+
* to — the in-app Vellum card, a Slack message, etc. When the request reaches a
|
|
6
|
+
* terminal status its cards must stop offering live actions on *all* surfaces,
|
|
7
|
+
* not just the one the guardian acted on. This is the withdrawal counterpart to
|
|
8
|
+
* the unified card *rendering* dispatcher (`notifications/approval-card-data.ts`):
|
|
9
|
+
* rendering projects `pending` onto each surface, withdrawal projects the
|
|
10
|
+
* terminal status back onto each surface.
|
|
11
|
+
*
|
|
12
|
+
* Withdrawal preserves the card's information and removes only the live
|
|
13
|
+
* affordances (it does not delete the message/card) so each surface keeps an
|
|
14
|
+
* audit trail of what was decided.
|
|
15
|
+
*
|
|
16
|
+
* It is driven off `canonical_guardian_deliveries` — the per-request registry of
|
|
17
|
+
* where each card was sent — so it stays correct as surfaces are added and is
|
|
18
|
+
* agnostic to which surface originated the decision.
|
|
19
|
+
*
|
|
20
|
+
* Best-effort by contract: the request is already resolved (CAS committed)
|
|
21
|
+
* before this runs, so a failed edit must never surface as a decision failure.
|
|
22
|
+
* Every surface is attempted independently; one failure never blocks the rest.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { completeSurfaceAndNotify } from "../daemon/conversation-surfaces.js";
|
|
26
|
+
import {
|
|
27
|
+
type CanonicalGuardianDelivery,
|
|
28
|
+
type CanonicalGuardianRequest,
|
|
29
|
+
type CanonicalRequestStatus,
|
|
30
|
+
listCanonicalGuardianDeliveries,
|
|
31
|
+
} from "../memory/canonical-guardian-store.js";
|
|
32
|
+
import { withdrawSlackApprovalCard } from "../messaging/providers/slack/withdraw.js";
|
|
33
|
+
import { approvalCardSurfaceId } from "../notifications/approval-card-data.js";
|
|
34
|
+
import { getLogger } from "../util/logger.js";
|
|
35
|
+
|
|
36
|
+
const log = getLogger("guardian-card-withdrawal");
|
|
37
|
+
|
|
38
|
+
/** Completion-summary label shown on an in-app card for a resolved request. */
|
|
39
|
+
const SURFACE_STATUS_LABELS: Partial<Record<CanonicalRequestStatus, string>> = {
|
|
40
|
+
approved: "Approved",
|
|
41
|
+
denied: "Denied",
|
|
42
|
+
expired: "Expired",
|
|
43
|
+
cancelled: "Cancelled",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export interface WithdrawGuardianCardsParams {
|
|
47
|
+
/** The request, already transitioned to its terminal status. */
|
|
48
|
+
request: CanonicalGuardianRequest;
|
|
49
|
+
/** Terminal status to reflect on each card. */
|
|
50
|
+
status: CanonicalRequestStatus;
|
|
51
|
+
/**
|
|
52
|
+
* Channel the decision originated on, when applicable.
|
|
53
|
+
*
|
|
54
|
+
* The acting in-app client completes its own card optimistically, so the
|
|
55
|
+
* in-app card is skipped when the decision originated in-app; it is withdrawn
|
|
56
|
+
* here only when the decision came from another surface. Omit (e.g. the expiry
|
|
57
|
+
* sweep) to withdraw every surface.
|
|
58
|
+
*/
|
|
59
|
+
originChannel?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Withdraw a resolved request's approval cards across all delivery surfaces.
|
|
64
|
+
* Never throws.
|
|
65
|
+
*/
|
|
66
|
+
export async function withdrawGuardianRequestCards(
|
|
67
|
+
params: WithdrawGuardianCardsParams,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const { request, status, originChannel } = params;
|
|
70
|
+
|
|
71
|
+
let deliveries: CanonicalGuardianDelivery[];
|
|
72
|
+
try {
|
|
73
|
+
deliveries = listCanonicalGuardianDeliveries(request.id);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
log.warn(
|
|
76
|
+
{ err, requestId: request.id },
|
|
77
|
+
"Failed to list deliveries for card withdrawal",
|
|
78
|
+
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const delivery of deliveries) {
|
|
83
|
+
try {
|
|
84
|
+
if (delivery.destinationChannel === "vellum") {
|
|
85
|
+
withdrawVellumCard(request, delivery, status, originChannel);
|
|
86
|
+
} else if (delivery.destinationChannel === "slack") {
|
|
87
|
+
await withdrawSlackCard(request, delivery, status);
|
|
88
|
+
}
|
|
89
|
+
// Telegram/WhatsApp direct delivery can't edit a message in place (it
|
|
90
|
+
// would post a new one), so their stale clicks are left to the existing
|
|
91
|
+
// "already resolved" reply until in-place edit support lands.
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log.warn(
|
|
94
|
+
{
|
|
95
|
+
err,
|
|
96
|
+
requestId: request.id,
|
|
97
|
+
channel: delivery.destinationChannel,
|
|
98
|
+
},
|
|
99
|
+
"Failed to withdraw guardian card on surface (non-fatal)",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Withdraw the in-app approval card so it stops offering live actions while
|
|
107
|
+
* keeping its content. Skipped when the decision originated in-app — the acting
|
|
108
|
+
* client already completed the card itself.
|
|
109
|
+
*/
|
|
110
|
+
function withdrawVellumCard(
|
|
111
|
+
request: CanonicalGuardianRequest,
|
|
112
|
+
delivery: CanonicalGuardianDelivery,
|
|
113
|
+
status: CanonicalRequestStatus,
|
|
114
|
+
originChannel: string | undefined,
|
|
115
|
+
): void {
|
|
116
|
+
if (originChannel === "vellum") return;
|
|
117
|
+
if (!delivery.destinationConversationId) return;
|
|
118
|
+
const surfaceId = approvalCardSurfaceId(request.kind, request.id);
|
|
119
|
+
if (!surfaceId) return;
|
|
120
|
+
completeSurfaceAndNotify(
|
|
121
|
+
delivery.destinationConversationId,
|
|
122
|
+
surfaceId,
|
|
123
|
+
SURFACE_STATUS_LABELS[status] ?? "Resolved",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Edit the Slack message in place to its resolved state — original card content
|
|
129
|
+
* preserved, action buttons removed, an outcome/decider/time line appended.
|
|
130
|
+
* No-ops when the channel-native message id was not captured at delivery time.
|
|
131
|
+
*/
|
|
132
|
+
async function withdrawSlackCard(
|
|
133
|
+
request: CanonicalGuardianRequest,
|
|
134
|
+
delivery: CanonicalGuardianDelivery,
|
|
135
|
+
status: CanonicalRequestStatus,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
if (!delivery.destinationChatId || !delivery.destinationMessageId) return;
|
|
138
|
+
await withdrawSlackApprovalCard({
|
|
139
|
+
channel: delivery.destinationChatId,
|
|
140
|
+
messageTs: delivery.destinationMessageId,
|
|
141
|
+
status,
|
|
142
|
+
decidedByExternalUserId: request.decidedByExternalUserId ?? undefined,
|
|
143
|
+
decidedAtMs: request.updatedAt,
|
|
144
|
+
});
|
|
145
|
+
}
|