@vellumai/assistant 0.3.2 → 0.3.3
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/README.md +82 -13
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
- package/src/__tests__/app-git-history.test.ts +22 -27
- package/src/__tests__/app-git-service.test.ts +44 -78
- package/src/__tests__/channel-approval-routes.test.ts +930 -14
- package/src/__tests__/channel-approval.test.ts +2 -0
- package/src/__tests__/channel-delivery-store.test.ts +104 -1
- package/src/__tests__/channel-guardian.test.ts +184 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +5 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
- package/src/__tests__/handlers-telegram-config.test.ts +82 -0
- package/src/__tests__/handlers-twilio-config.test.ts +665 -5
- package/src/__tests__/ingress-url-consistency.test.ts +64 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/run-orchestrator.test.ts +1 -1
- package/src/__tests__/session-process-bridge.test.ts +2 -0
- package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
- package/src/calls/twilio-config.ts +10 -1
- package/src/calls/twilio-rest.ts +70 -0
- package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/schema.ts +3 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
- package/src/daemon/handlers/config.ts +168 -15
- package/src/daemon/handlers/sessions.ts +5 -3
- package/src/daemon/handlers/skills.ts +61 -17
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +10 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +20 -3
- package/src/daemon/session-slash.ts +50 -2
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/inbound/public-ingress-urls.ts +20 -3
- package/src/index.ts +1 -23
- package/src/memory/app-git-service.ts +24 -0
- package/src/memory/app-store.ts +0 -21
- package/src/memory/channel-delivery-store.ts +74 -3
- package/src/memory/channel-guardian-store.ts +54 -26
- package/src/memory/conversation-key-store.ts +20 -0
- package/src/memory/conversation-store.ts +14 -2
- package/src/memory/db.ts +12 -0
- package/src/memory/schema.ts +5 -0
- package/src/runtime/http-server.ts +13 -5
- package/src/runtime/routes/channel-routes.ts +134 -43
- package/src/skills/clawhub.ts +6 -2
- package/src/subagent/manager.ts +4 -1
- package/src/subagent/types.ts +2 -0
- package/src/tools/skills/vellum-catalog.ts +45 -2
- package/src/tools/subagent/spawn.ts +2 -0
|
@@ -54,14 +54,14 @@ import {
|
|
|
54
54
|
createApprovalRequest,
|
|
55
55
|
getPendingApprovalForRun,
|
|
56
56
|
getUnresolvedApprovalForRun,
|
|
57
|
-
getExpiredPendingApprovals,
|
|
58
|
-
updateApprovalDecision,
|
|
59
57
|
} from '../memory/channel-guardian-store.js';
|
|
60
58
|
import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
|
|
61
59
|
import {
|
|
62
60
|
handleChannelInbound,
|
|
63
61
|
isChannelApprovalsEnabled,
|
|
64
62
|
sweepExpiredGuardianApprovals,
|
|
63
|
+
verifyGatewayOrigin,
|
|
64
|
+
_setTestPollMaxWait,
|
|
65
65
|
} from '../runtime/routes/channel-routes.js';
|
|
66
66
|
import * as gatewayClient from '../runtime/gateway-client.js';
|
|
67
67
|
|
|
@@ -98,6 +98,7 @@ function resetTables(): void {
|
|
|
98
98
|
db.run('DELETE FROM channel_inbound_events');
|
|
99
99
|
db.run('DELETE FROM messages');
|
|
100
100
|
db.run('DELETE FROM conversations');
|
|
101
|
+
channelDeliveryStore.resetAllRunDeliveryClaims();
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
const sampleConfirmation: PendingConfirmation = {
|
|
@@ -132,6 +133,10 @@ function makeMockOrchestrator(
|
|
|
132
133
|
} as unknown as RunOrchestrator;
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/** Default bearer token used by tests. Include the X-Gateway-Origin header
|
|
137
|
+
* so that verifyGatewayOrigin does not reject the request. */
|
|
138
|
+
const TEST_BEARER_TOKEN = 'token';
|
|
139
|
+
|
|
135
140
|
function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
136
141
|
const body = {
|
|
137
142
|
sourceChannel: 'telegram',
|
|
@@ -143,7 +148,10 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
|
143
148
|
};
|
|
144
149
|
return new Request('http://localhost/channels/inbound', {
|
|
145
150
|
method: 'POST',
|
|
146
|
-
headers: {
|
|
151
|
+
headers: {
|
|
152
|
+
'Content-Type': 'application/json',
|
|
153
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
154
|
+
},
|
|
147
155
|
body: JSON.stringify(body),
|
|
148
156
|
});
|
|
149
157
|
}
|
|
@@ -531,11 +539,14 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
531
539
|
};
|
|
532
540
|
const req = new Request('http://localhost/channels/inbound', {
|
|
533
541
|
method: 'POST',
|
|
534
|
-
headers: {
|
|
542
|
+
headers: {
|
|
543
|
+
'Content-Type': 'application/json',
|
|
544
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
545
|
+
},
|
|
535
546
|
body: JSON.stringify(body),
|
|
536
547
|
});
|
|
537
548
|
|
|
538
|
-
const res = await handleChannelInbound(req, noopProcessMessage,
|
|
549
|
+
const res = await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN, orchestrator);
|
|
539
550
|
expect(res.status).toBe(200);
|
|
540
551
|
const resBody = await res.json() as Record<string, unknown>;
|
|
541
552
|
expect(resBody.accepted).toBe(true);
|
|
@@ -1048,6 +1059,10 @@ describe('poll timeout handling by run state', () => {
|
|
|
1048
1059
|
});
|
|
1049
1060
|
|
|
1050
1061
|
test('marks event as processed when run is in needs_confirmation state after poll timeout', async () => {
|
|
1062
|
+
// Use a short poll timeout so the test can exercise the timeout path
|
|
1063
|
+
// without waiting 5 minutes.
|
|
1064
|
+
_setTestPollMaxWait(600);
|
|
1065
|
+
|
|
1051
1066
|
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1052
1067
|
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
1053
1068
|
const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
|
|
@@ -1080,8 +1095,8 @@ describe('poll timeout handling by run state', () => {
|
|
|
1080
1095
|
const req = makeInboundRequest({ content: 'hello needs_confirm' });
|
|
1081
1096
|
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1082
1097
|
|
|
1083
|
-
// Wait for the background async to complete
|
|
1084
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1098
|
+
// Wait for the background async to complete (poll timeout is 600ms + one poll interval of 500ms)
|
|
1099
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1085
1100
|
|
|
1086
1101
|
// markProcessed SHOULD have been called — the run is waiting for approval,
|
|
1087
1102
|
// and the post-decision delivery path will handle the final reply.
|
|
@@ -1094,6 +1109,7 @@ describe('poll timeout handling by run state', () => {
|
|
|
1094
1109
|
markSpy.mockRestore();
|
|
1095
1110
|
failureSpy.mockRestore();
|
|
1096
1111
|
deliverSpy.mockRestore();
|
|
1112
|
+
_setTestPollMaxWait(null);
|
|
1097
1113
|
});
|
|
1098
1114
|
|
|
1099
1115
|
test('does NOT call recordProcessingFailure when run reaches terminal state', async () => {
|
|
@@ -1335,7 +1351,10 @@ describe('SMS channel approval decisions', () => {
|
|
|
1335
1351
|
};
|
|
1336
1352
|
return new Request('http://localhost/channels/inbound', {
|
|
1337
1353
|
method: 'POST',
|
|
1338
|
-
headers: {
|
|
1354
|
+
headers: {
|
|
1355
|
+
'Content-Type': 'application/json',
|
|
1356
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1357
|
+
},
|
|
1339
1358
|
body: JSON.stringify(body),
|
|
1340
1359
|
});
|
|
1341
1360
|
}
|
|
@@ -1481,7 +1500,10 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1481
1500
|
|
|
1482
1501
|
const req = new Request('http://localhost/channels/inbound', {
|
|
1483
1502
|
method: 'POST',
|
|
1484
|
-
headers: {
|
|
1503
|
+
headers: {
|
|
1504
|
+
'Content-Type': 'application/json',
|
|
1505
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1506
|
+
},
|
|
1485
1507
|
body: JSON.stringify({
|
|
1486
1508
|
sourceChannel: 'sms',
|
|
1487
1509
|
externalChatId: 'sms-chat-verify',
|
|
@@ -1492,7 +1514,7 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1492
1514
|
}),
|
|
1493
1515
|
});
|
|
1494
1516
|
|
|
1495
|
-
const res = await handleChannelInbound(req, noopProcessMessage,
|
|
1517
|
+
const res = await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN);
|
|
1496
1518
|
const body = await res.json() as Record<string, unknown>;
|
|
1497
1519
|
|
|
1498
1520
|
expect(body.accepted).toBe(true);
|
|
@@ -1513,7 +1535,10 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1513
1535
|
|
|
1514
1536
|
const req = new Request('http://localhost/channels/inbound', {
|
|
1515
1537
|
method: 'POST',
|
|
1516
|
-
headers: {
|
|
1538
|
+
headers: {
|
|
1539
|
+
'Content-Type': 'application/json',
|
|
1540
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1541
|
+
},
|
|
1517
1542
|
body: JSON.stringify({
|
|
1518
1543
|
sourceChannel: 'sms',
|
|
1519
1544
|
externalChatId: 'sms-chat-verify-fail',
|
|
@@ -1524,7 +1549,7 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1524
1549
|
}),
|
|
1525
1550
|
});
|
|
1526
1551
|
|
|
1527
|
-
const res = await handleChannelInbound(req, noopProcessMessage,
|
|
1552
|
+
const res = await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN);
|
|
1528
1553
|
const body = await res.json() as Record<string, unknown>;
|
|
1529
1554
|
|
|
1530
1555
|
expect(body.accepted).toBe(true);
|
|
@@ -1585,7 +1610,10 @@ describe('SMS non-guardian actor gating', () => {
|
|
|
1585
1610
|
// Send message from a NON-guardian sms user
|
|
1586
1611
|
const req = new Request('http://localhost/channels/inbound', {
|
|
1587
1612
|
method: 'POST',
|
|
1588
|
-
headers: {
|
|
1613
|
+
headers: {
|
|
1614
|
+
'Content-Type': 'application/json',
|
|
1615
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1616
|
+
},
|
|
1589
1617
|
body: JSON.stringify({
|
|
1590
1618
|
sourceChannel: 'sms',
|
|
1591
1619
|
externalChatId: 'sms-other-chat',
|
|
@@ -1596,7 +1624,7 @@ describe('SMS non-guardian actor gating', () => {
|
|
|
1596
1624
|
}),
|
|
1597
1625
|
});
|
|
1598
1626
|
|
|
1599
|
-
await handleChannelInbound(req, noopProcessMessage,
|
|
1627
|
+
await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN, orchestrator);
|
|
1600
1628
|
|
|
1601
1629
|
// Wait for the background async to fire
|
|
1602
1630
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
@@ -2074,6 +2102,52 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2074
2102
|
});
|
|
2075
2103
|
});
|
|
2076
2104
|
|
|
2105
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2106
|
+
// 20b. Standard approval prompt delivery failure → auto-deny (WS-B)
|
|
2107
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2108
|
+
|
|
2109
|
+
describe('standard approval prompt delivery failure → auto-deny', () => {
|
|
2110
|
+
beforeEach(() => {
|
|
2111
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
test('standard prompt delivery failure auto-denies the run (fail-closed)', async () => {
|
|
2115
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2116
|
+
// Make the approval prompt delivery fail for the standard (self-approval) path
|
|
2117
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
|
|
2118
|
+
new Error('Network error: approval prompt unreachable'),
|
|
2119
|
+
);
|
|
2120
|
+
|
|
2121
|
+
// No guardian binding — sender is a guardian (default role), so the
|
|
2122
|
+
// standard self-approval path is used.
|
|
2123
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-std-fail', terminalStatus: 'failed' });
|
|
2124
|
+
|
|
2125
|
+
const req = makeInboundRequest({
|
|
2126
|
+
content: 'do something dangerous',
|
|
2127
|
+
senderExternalUserId: 'guardian-std-user',
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
// Set up a guardian binding so the sender is recognized as guardian
|
|
2131
|
+
createBinding({
|
|
2132
|
+
assistantId: 'self',
|
|
2133
|
+
channel: 'telegram',
|
|
2134
|
+
guardianExternalUserId: 'guardian-std-user',
|
|
2135
|
+
guardianDeliveryChatId: 'chat-123',
|
|
2136
|
+
});
|
|
2137
|
+
|
|
2138
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2139
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2140
|
+
|
|
2141
|
+
// The run should have been auto-denied because the prompt could not be delivered
|
|
2142
|
+
expect(orchestrator.submitDecision).toHaveBeenCalled();
|
|
2143
|
+
const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
|
|
2144
|
+
expect(decisionArgs[1]).toBe('deny');
|
|
2145
|
+
|
|
2146
|
+
deliverSpy.mockRestore();
|
|
2147
|
+
approvalSpy.mockRestore();
|
|
2148
|
+
});
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2077
2151
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2078
2152
|
// 21. Guardian decision scoping — callback for older run resolves correctly
|
|
2079
2153
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -2360,3 +2434,845 @@ describe('expired guardian approval auto-denies via sweep', () => {
|
|
|
2360
2434
|
deliverSpy.mockRestore();
|
|
2361
2435
|
});
|
|
2362
2436
|
});
|
|
2437
|
+
|
|
2438
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2439
|
+
// 24. Deliver-once idempotency guard
|
|
2440
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2441
|
+
|
|
2442
|
+
describe('deliver-once idempotency guard', () => {
|
|
2443
|
+
test('claimRunDelivery returns true on first call, false on subsequent calls', () => {
|
|
2444
|
+
const runId = 'run-idem-unit';
|
|
2445
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
|
|
2446
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(false);
|
|
2447
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(false);
|
|
2448
|
+
channelDeliveryStore.resetRunDeliveryClaim(runId);
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
test('different run IDs are independent', () => {
|
|
2452
|
+
expect(channelDeliveryStore.claimRunDelivery('run-a')).toBe(true);
|
|
2453
|
+
expect(channelDeliveryStore.claimRunDelivery('run-b')).toBe(true);
|
|
2454
|
+
expect(channelDeliveryStore.claimRunDelivery('run-a')).toBe(false);
|
|
2455
|
+
expect(channelDeliveryStore.claimRunDelivery('run-b')).toBe(false);
|
|
2456
|
+
channelDeliveryStore.resetRunDeliveryClaim('run-a');
|
|
2457
|
+
channelDeliveryStore.resetRunDeliveryClaim('run-b');
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
test('resetRunDeliveryClaim allows re-claim', () => {
|
|
2461
|
+
const runId = 'run-idem-reset';
|
|
2462
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
|
|
2463
|
+
channelDeliveryStore.resetRunDeliveryClaim(runId);
|
|
2464
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
|
|
2465
|
+
channelDeliveryStore.resetRunDeliveryClaim(runId);
|
|
2466
|
+
});
|
|
2467
|
+
});
|
|
2468
|
+
|
|
2469
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2470
|
+
// 25. Final reply idempotency — main poll wins vs post-decision poll wins
|
|
2471
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2472
|
+
|
|
2473
|
+
describe('final reply idempotency — no duplicate delivery', () => {
|
|
2474
|
+
beforeEach(() => {
|
|
2475
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2476
|
+
});
|
|
2477
|
+
|
|
2478
|
+
test('main poll wins: deliverChannelReply called exactly once when main poll delivers first', async () => {
|
|
2479
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2480
|
+
|
|
2481
|
+
// Establish the conversation
|
|
2482
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
2483
|
+
const orchestrator = makeMockOrchestrator();
|
|
2484
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
2485
|
+
|
|
2486
|
+
const db = getDb();
|
|
2487
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
2488
|
+
const conversationId = events[0]?.conversation_id;
|
|
2489
|
+
ensureConversation(conversationId!);
|
|
2490
|
+
|
|
2491
|
+
// Create a pending run and add an assistant message for delivery
|
|
2492
|
+
const run = createRun(conversationId!);
|
|
2493
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
2494
|
+
conversationStore.addMessage(conversationId!, 'assistant', 'Main poll result.');
|
|
2495
|
+
|
|
2496
|
+
// Orchestrator: first getRun returns needs_confirmation (to trigger
|
|
2497
|
+
// approval prompt delivery in the poll), subsequent calls return
|
|
2498
|
+
// completed so the main poll can deliver the reply.
|
|
2499
|
+
let getRunCount = 0;
|
|
2500
|
+
const racingOrchestrator = {
|
|
2501
|
+
submitDecision: mock(() => 'applied' as const),
|
|
2502
|
+
getRun: mock(() => {
|
|
2503
|
+
getRunCount++;
|
|
2504
|
+
if (getRunCount <= 1) {
|
|
2505
|
+
return {
|
|
2506
|
+
id: run.id,
|
|
2507
|
+
conversationId: conversationId!,
|
|
2508
|
+
messageId: null,
|
|
2509
|
+
status: 'needs_confirmation' as const,
|
|
2510
|
+
pendingConfirmation: sampleConfirmation,
|
|
2511
|
+
pendingSecret: null,
|
|
2512
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2513
|
+
error: null,
|
|
2514
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
return {
|
|
2518
|
+
id: run.id,
|
|
2519
|
+
conversationId: conversationId!,
|
|
2520
|
+
messageId: null,
|
|
2521
|
+
status: 'completed' as const,
|
|
2522
|
+
pendingConfirmation: null,
|
|
2523
|
+
pendingSecret: null,
|
|
2524
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2525
|
+
error: null,
|
|
2526
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2527
|
+
};
|
|
2528
|
+
}),
|
|
2529
|
+
startRun: mock(async () => ({
|
|
2530
|
+
id: run.id,
|
|
2531
|
+
conversationId: conversationId!,
|
|
2532
|
+
messageId: null,
|
|
2533
|
+
status: 'running' as const,
|
|
2534
|
+
pendingConfirmation: null,
|
|
2535
|
+
pendingSecret: null,
|
|
2536
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2537
|
+
error: null,
|
|
2538
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2539
|
+
})),
|
|
2540
|
+
} as unknown as RunOrchestrator;
|
|
2541
|
+
|
|
2542
|
+
deliverSpy.mockClear();
|
|
2543
|
+
|
|
2544
|
+
// Send a message that triggers the approval path, then send a decision
|
|
2545
|
+
// to trigger the post-decision poll. Both pollers should compete for delivery.
|
|
2546
|
+
const msgReq = makeInboundRequest({ content: 'do something' });
|
|
2547
|
+
await handleChannelInbound(msgReq, noopProcessMessage, 'token', racingOrchestrator);
|
|
2548
|
+
|
|
2549
|
+
// Send the decision to start the post-decision delivery poll
|
|
2550
|
+
const decisionReq = makeInboundRequest({
|
|
2551
|
+
content: '',
|
|
2552
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
2553
|
+
});
|
|
2554
|
+
await handleChannelInbound(decisionReq, noopProcessMessage, 'token', racingOrchestrator);
|
|
2555
|
+
|
|
2556
|
+
// Wait for both pollers to finish
|
|
2557
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
2558
|
+
|
|
2559
|
+
// Count deliverChannelReply calls that carry the assistant reply text.
|
|
2560
|
+
// Approval-related notifications (e.g. "has been sent to the guardian")
|
|
2561
|
+
// are separate from the final reply. The final reply call is the one
|
|
2562
|
+
// that delivers the actual conversation content.
|
|
2563
|
+
const replyDeliveryCalls = deliverSpy.mock.calls.filter(
|
|
2564
|
+
(call) => typeof call[1] === 'object' &&
|
|
2565
|
+
(call[1] as { text?: string }).text === 'Main poll result.',
|
|
2566
|
+
);
|
|
2567
|
+
|
|
2568
|
+
// The guard should ensure at most one delivery of the final reply
|
|
2569
|
+
expect(replyDeliveryCalls.length).toBeLessThanOrEqual(1);
|
|
2570
|
+
|
|
2571
|
+
deliverSpy.mockRestore();
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
test('post-decision poll wins: delivers exactly once when main poll already exited', async () => {
|
|
2575
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2576
|
+
|
|
2577
|
+
// Establish the conversation
|
|
2578
|
+
const initReq = makeInboundRequest({ content: 'init-late' });
|
|
2579
|
+
const orchestrator = makeMockOrchestrator();
|
|
2580
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
2581
|
+
|
|
2582
|
+
const db = getDb();
|
|
2583
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
2584
|
+
const conversationId = events[events.length - 1]?.conversation_id;
|
|
2585
|
+
ensureConversation(conversationId!);
|
|
2586
|
+
|
|
2587
|
+
// Create a pending run
|
|
2588
|
+
const run = createRun(conversationId!);
|
|
2589
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
2590
|
+
conversationStore.addMessage(conversationId!, 'assistant', 'Post-decision result.');
|
|
2591
|
+
|
|
2592
|
+
// Orchestrator: getRun always returns needs_confirmation for the main poll
|
|
2593
|
+
// (so the main poll times out without delivering), then returns completed
|
|
2594
|
+
// for the post-decision poll. We use a separate call counter per context.
|
|
2595
|
+
let mainPollExited = false;
|
|
2596
|
+
let postDecisionGetRunCount = 0;
|
|
2597
|
+
const lateOrchestrator = {
|
|
2598
|
+
submitDecision: mock(() => 'applied' as const),
|
|
2599
|
+
getRun: mock(() => {
|
|
2600
|
+
if (!mainPollExited) {
|
|
2601
|
+
// Main poll context — always return needs_confirmation so it exits
|
|
2602
|
+
// without delivering (the 5min timeout is simulated by having the
|
|
2603
|
+
// main poll see needs_confirmation until it gives up).
|
|
2604
|
+
return {
|
|
2605
|
+
id: run.id,
|
|
2606
|
+
conversationId: conversationId!,
|
|
2607
|
+
messageId: null,
|
|
2608
|
+
status: 'needs_confirmation' as const,
|
|
2609
|
+
pendingConfirmation: sampleConfirmation,
|
|
2610
|
+
pendingSecret: null,
|
|
2611
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2612
|
+
error: null,
|
|
2613
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
// Post-decision poll — return completed after a short delay
|
|
2617
|
+
postDecisionGetRunCount++;
|
|
2618
|
+
if (postDecisionGetRunCount <= 1) {
|
|
2619
|
+
return {
|
|
2620
|
+
id: run.id,
|
|
2621
|
+
conversationId: conversationId!,
|
|
2622
|
+
messageId: null,
|
|
2623
|
+
status: 'needs_confirmation' as const,
|
|
2624
|
+
pendingConfirmation: null,
|
|
2625
|
+
pendingSecret: null,
|
|
2626
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2627
|
+
error: null,
|
|
2628
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
return {
|
|
2632
|
+
id: run.id,
|
|
2633
|
+
conversationId: conversationId!,
|
|
2634
|
+
messageId: null,
|
|
2635
|
+
status: 'completed' as const,
|
|
2636
|
+
pendingConfirmation: null,
|
|
2637
|
+
pendingSecret: null,
|
|
2638
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2639
|
+
error: null,
|
|
2640
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2641
|
+
};
|
|
2642
|
+
}),
|
|
2643
|
+
startRun: mock(async () => ({
|
|
2644
|
+
id: run.id,
|
|
2645
|
+
conversationId: conversationId!,
|
|
2646
|
+
messageId: null,
|
|
2647
|
+
status: 'running' as const,
|
|
2648
|
+
pendingConfirmation: null,
|
|
2649
|
+
pendingSecret: null,
|
|
2650
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2651
|
+
error: null,
|
|
2652
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2653
|
+
})),
|
|
2654
|
+
} as unknown as RunOrchestrator;
|
|
2655
|
+
|
|
2656
|
+
deliverSpy.mockClear();
|
|
2657
|
+
|
|
2658
|
+
// Start the main poll — it will see needs_confirmation and exit after
|
|
2659
|
+
// the first poll interval (marking the event as processed, not delivering).
|
|
2660
|
+
const msgReq = makeInboundRequest({ content: 'do something late' });
|
|
2661
|
+
await handleChannelInbound(msgReq, noopProcessMessage, 'token', lateOrchestrator);
|
|
2662
|
+
|
|
2663
|
+
// Wait for the main poll to see needs_confirmation and mark processed
|
|
2664
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
2665
|
+
mainPollExited = true;
|
|
2666
|
+
|
|
2667
|
+
// Now send the decision to trigger the post-decision delivery
|
|
2668
|
+
const decisionReq = makeInboundRequest({
|
|
2669
|
+
content: '',
|
|
2670
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
2671
|
+
});
|
|
2672
|
+
await handleChannelInbound(decisionReq, noopProcessMessage, 'token', lateOrchestrator);
|
|
2673
|
+
|
|
2674
|
+
// Wait for the post-decision poll to deliver
|
|
2675
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
2676
|
+
|
|
2677
|
+
// Count deliveries of the final assistant reply
|
|
2678
|
+
const replyDeliveryCalls = deliverSpy.mock.calls.filter(
|
|
2679
|
+
(call) => typeof call[1] === 'object' &&
|
|
2680
|
+
(call[1] as { text?: string }).text === 'Post-decision result.',
|
|
2681
|
+
);
|
|
2682
|
+
|
|
2683
|
+
// Exactly one delivery should have occurred (from the post-decision poll)
|
|
2684
|
+
expect(replyDeliveryCalls.length).toBe(1);
|
|
2685
|
+
|
|
2686
|
+
deliverSpy.mockRestore();
|
|
2687
|
+
});
|
|
2688
|
+
});
|
|
2689
|
+
|
|
2690
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2691
|
+
// 26. Assistant-scoped guardian verification via handleChannelInbound
|
|
2692
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2693
|
+
|
|
2694
|
+
describe('assistant-scoped guardian verification via handleChannelInbound', () => {
|
|
2695
|
+
test('/guardian_verify uses the threaded assistantId (default: self)', async () => {
|
|
2696
|
+
const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
|
|
2697
|
+
const { secret } = createVerificationChallenge('self', 'telegram');
|
|
2698
|
+
|
|
2699
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2700
|
+
|
|
2701
|
+
const req = makeInboundRequest({
|
|
2702
|
+
content: `/guardian_verify ${secret}`,
|
|
2703
|
+
senderExternalUserId: 'user-default-asst',
|
|
2704
|
+
});
|
|
2705
|
+
|
|
2706
|
+
// No assistantId passed => defaults to 'self'
|
|
2707
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token');
|
|
2708
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2709
|
+
|
|
2710
|
+
expect(body.accepted).toBe(true);
|
|
2711
|
+
expect(body.guardianVerification).toBe('verified');
|
|
2712
|
+
|
|
2713
|
+
deliverSpy.mockRestore();
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
test('/guardian_verify with explicit assistantId resolves against that assistant', async () => {
|
|
2717
|
+
const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
|
|
2718
|
+
const { getGuardianBinding } = await import('../runtime/channel-guardian-service.js');
|
|
2719
|
+
|
|
2720
|
+
// Create a challenge for asst-route-X
|
|
2721
|
+
const { secret } = createVerificationChallenge('asst-route-X', 'telegram');
|
|
2722
|
+
|
|
2723
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2724
|
+
|
|
2725
|
+
const req = makeInboundRequest({
|
|
2726
|
+
content: `/guardian_verify ${secret}`,
|
|
2727
|
+
senderExternalUserId: 'user-for-asst-x',
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
// Pass assistantId = 'asst-route-X'
|
|
2731
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', undefined, 'asst-route-X');
|
|
2732
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2733
|
+
|
|
2734
|
+
expect(body.accepted).toBe(true);
|
|
2735
|
+
expect(body.guardianVerification).toBe('verified');
|
|
2736
|
+
|
|
2737
|
+
// Binding should exist for asst-route-X, not for 'self'
|
|
2738
|
+
const bindingX = getGuardianBinding('asst-route-X', 'telegram');
|
|
2739
|
+
expect(bindingX).not.toBeNull();
|
|
2740
|
+
expect(bindingX!.guardianExternalUserId).toBe('user-for-asst-x');
|
|
2741
|
+
|
|
2742
|
+
deliverSpy.mockRestore();
|
|
2743
|
+
});
|
|
2744
|
+
|
|
2745
|
+
test('cross-assistant challenge verification fails', async () => {
|
|
2746
|
+
const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
|
|
2747
|
+
|
|
2748
|
+
// Create challenge for asst-A
|
|
2749
|
+
const { secret } = createVerificationChallenge('asst-A-cross', 'telegram');
|
|
2750
|
+
|
|
2751
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2752
|
+
|
|
2753
|
+
const req = makeInboundRequest({
|
|
2754
|
+
content: `/guardian_verify ${secret}`,
|
|
2755
|
+
senderExternalUserId: 'user-cross-test',
|
|
2756
|
+
});
|
|
2757
|
+
|
|
2758
|
+
// Try to verify using asst-B — should fail because the challenge is for asst-A
|
|
2759
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', undefined, 'asst-B-cross');
|
|
2760
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2761
|
+
|
|
2762
|
+
expect(body.accepted).toBe(true);
|
|
2763
|
+
expect(body.guardianVerification).toBe('failed');
|
|
2764
|
+
|
|
2765
|
+
deliverSpy.mockRestore();
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
test('actor role resolution uses threaded assistantId', async () => {
|
|
2769
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2770
|
+
|
|
2771
|
+
// Create guardian binding for asst-role-X
|
|
2772
|
+
createBinding({
|
|
2773
|
+
assistantId: 'asst-role-X',
|
|
2774
|
+
channel: 'telegram',
|
|
2775
|
+
guardianExternalUserId: 'guardian-role-user',
|
|
2776
|
+
guardianDeliveryChatId: 'guardian-role-chat',
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2780
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2781
|
+
|
|
2782
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-role-scoped', terminalStatus: 'completed' });
|
|
2783
|
+
|
|
2784
|
+
// Non-guardian user sending to asst-role-X should be recognized as non-guardian
|
|
2785
|
+
const req = makeInboundRequest({
|
|
2786
|
+
content: 'do something dangerous',
|
|
2787
|
+
senderExternalUserId: 'non-guardian-role-user',
|
|
2788
|
+
});
|
|
2789
|
+
|
|
2790
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator, 'asst-role-X');
|
|
2791
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2792
|
+
|
|
2793
|
+
// The approval prompt should have been sent to the guardian's chat
|
|
2794
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
2795
|
+
const approvalArgs = approvalSpy.mock.calls[0];
|
|
2796
|
+
expect(approvalArgs[1]).toBe('guardian-role-chat');
|
|
2797
|
+
|
|
2798
|
+
deliverSpy.mockRestore();
|
|
2799
|
+
approvalSpy.mockRestore();
|
|
2800
|
+
});
|
|
2801
|
+
|
|
2802
|
+
test('same user is guardian for one assistant but not another', async () => {
|
|
2803
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2804
|
+
|
|
2805
|
+
// user-multi is guardian for asst-M1 but not asst-M2
|
|
2806
|
+
createBinding({
|
|
2807
|
+
assistantId: 'asst-M1',
|
|
2808
|
+
channel: 'telegram',
|
|
2809
|
+
guardianExternalUserId: 'user-multi',
|
|
2810
|
+
guardianDeliveryChatId: 'chat-multi',
|
|
2811
|
+
});
|
|
2812
|
+
createBinding({
|
|
2813
|
+
assistantId: 'asst-M2',
|
|
2814
|
+
channel: 'telegram',
|
|
2815
|
+
guardianExternalUserId: 'user-other-guardian',
|
|
2816
|
+
guardianDeliveryChatId: 'chat-other-guardian',
|
|
2817
|
+
});
|
|
2818
|
+
|
|
2819
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2820
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2821
|
+
|
|
2822
|
+
// For asst-M1: user-multi is the guardian, so should get standard self-approval
|
|
2823
|
+
const orch1 = makeSensitiveOrchestrator({ runId: 'run-m1', terminalStatus: 'completed' });
|
|
2824
|
+
const req1 = makeInboundRequest({
|
|
2825
|
+
content: 'dangerous action',
|
|
2826
|
+
senderExternalUserId: 'user-multi',
|
|
2827
|
+
});
|
|
2828
|
+
|
|
2829
|
+
await handleChannelInbound(req1, noopProcessMessage, 'token', orch1, 'asst-M1');
|
|
2830
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2831
|
+
|
|
2832
|
+
// For asst-M1, user-multi is guardian — approval prompt to own chat (standard flow)
|
|
2833
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
2834
|
+
const m1ApprovalArgs = approvalSpy.mock.calls[0];
|
|
2835
|
+
// Should be sent to user-multi's own chat (chat-123 from makeInboundRequest default)
|
|
2836
|
+
expect(m1ApprovalArgs[1]).toBe('chat-123');
|
|
2837
|
+
|
|
2838
|
+
approvalSpy.mockClear();
|
|
2839
|
+
deliverSpy.mockClear();
|
|
2840
|
+
|
|
2841
|
+
// For asst-M2: user-multi is NOT the guardian, so approval should route to asst-M2's guardian
|
|
2842
|
+
const orch2 = makeSensitiveOrchestrator({ runId: 'run-m2', terminalStatus: 'completed' });
|
|
2843
|
+
const req2 = makeInboundRequest({
|
|
2844
|
+
content: 'another dangerous action',
|
|
2845
|
+
senderExternalUserId: 'user-multi',
|
|
2846
|
+
});
|
|
2847
|
+
|
|
2848
|
+
await handleChannelInbound(req2, noopProcessMessage, 'token', orch2, 'asst-M2');
|
|
2849
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2850
|
+
|
|
2851
|
+
// For asst-M2, user-multi is non-guardian — approval should go to user-other-guardian's chat
|
|
2852
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
2853
|
+
const m2ApprovalArgs = approvalSpy.mock.calls[0];
|
|
2854
|
+
expect(m2ApprovalArgs[1]).toBe('chat-other-guardian');
|
|
2855
|
+
|
|
2856
|
+
deliverSpy.mockRestore();
|
|
2857
|
+
approvalSpy.mockRestore();
|
|
2858
|
+
});
|
|
2859
|
+
});
|
|
2860
|
+
|
|
2861
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2862
|
+
// 27. Guardian enforcement decoupled from CHANNEL_APPROVALS_ENABLED
|
|
2863
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2864
|
+
|
|
2865
|
+
describe('guardian enforcement independence from approval flag', () => {
|
|
2866
|
+
test('actor role resolution runs when CHANNEL_APPROVALS_ENABLED is off', async () => {
|
|
2867
|
+
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
2868
|
+
|
|
2869
|
+
// Create a guardian binding — user-guardian is the guardian
|
|
2870
|
+
createBinding({
|
|
2871
|
+
assistantId: 'self',
|
|
2872
|
+
channel: 'telegram',
|
|
2873
|
+
guardianExternalUserId: 'user-guardian',
|
|
2874
|
+
guardianDeliveryChatId: 'chat-guardian',
|
|
2875
|
+
});
|
|
2876
|
+
|
|
2877
|
+
// A non-guardian user sends a message with approvals disabled.
|
|
2878
|
+
// Actor role resolution should still classify them as non-guardian
|
|
2879
|
+
// even though the approval UX is off.
|
|
2880
|
+
const orchestrator = makeMockOrchestrator();
|
|
2881
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2882
|
+
|
|
2883
|
+
const req = makeInboundRequest({
|
|
2884
|
+
content: 'hello world',
|
|
2885
|
+
senderExternalUserId: 'user-non-guardian',
|
|
2886
|
+
});
|
|
2887
|
+
|
|
2888
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2889
|
+
expect(res.status).toBe(200);
|
|
2890
|
+
|
|
2891
|
+
// The message should proceed normally since approval UX is off,
|
|
2892
|
+
// but actor role resolution still ran (verified by the fact that
|
|
2893
|
+
// the message processed successfully without error)
|
|
2894
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2895
|
+
expect(body.accepted).toBe(true);
|
|
2896
|
+
|
|
2897
|
+
deliverSpy.mockRestore();
|
|
2898
|
+
});
|
|
2899
|
+
|
|
2900
|
+
test('missing senderExternalUserId with guardian binding fails closed', async () => {
|
|
2901
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2902
|
+
|
|
2903
|
+
// Create a guardian binding — guardian enforcement is active
|
|
2904
|
+
createBinding({
|
|
2905
|
+
assistantId: 'self',
|
|
2906
|
+
channel: 'telegram',
|
|
2907
|
+
guardianExternalUserId: 'user-guardian',
|
|
2908
|
+
guardianDeliveryChatId: 'chat-guardian',
|
|
2909
|
+
});
|
|
2910
|
+
|
|
2911
|
+
// Use makeSensitiveOrchestrator so that getRun returns needs_confirmation
|
|
2912
|
+
// on the first poll (triggering the unverified_channel auto-deny path)
|
|
2913
|
+
// and then returns terminal state.
|
|
2914
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-failclosed-1', terminalStatus: 'failed' });
|
|
2915
|
+
|
|
2916
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2917
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2918
|
+
|
|
2919
|
+
// Send a message WITHOUT senderExternalUserId
|
|
2920
|
+
const req = makeInboundRequest({
|
|
2921
|
+
content: 'do something dangerous',
|
|
2922
|
+
senderExternalUserId: undefined,
|
|
2923
|
+
});
|
|
2924
|
+
|
|
2925
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2926
|
+
expect(res.status).toBe(200);
|
|
2927
|
+
|
|
2928
|
+
// Wait for background processing
|
|
2929
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2930
|
+
|
|
2931
|
+
// The unknown actor should be treated as unverified_channel and
|
|
2932
|
+
// sensitive actions should be auto-denied via the no_identity branch.
|
|
2933
|
+
// deliverChannelReply args: (callbackUrl, payload, bearerToken?)
|
|
2934
|
+
// The denial notice is in payload.text (index 1 of the call args).
|
|
2935
|
+
expect(deliverSpy).toHaveBeenCalled();
|
|
2936
|
+
const denialCalls = deliverSpy.mock.calls.filter(
|
|
2937
|
+
(call) => {
|
|
2938
|
+
if (typeof call[1] !== 'object') return false;
|
|
2939
|
+
const text = (call[1] as { text?: string }).text ?? '';
|
|
2940
|
+
return text.includes('requires guardian approval') &&
|
|
2941
|
+
(text.includes('identity could not be determined') || text.includes('no guardian has been set up'));
|
|
2942
|
+
},
|
|
2943
|
+
);
|
|
2944
|
+
expect(denialCalls.length).toBeGreaterThanOrEqual(1);
|
|
2945
|
+
|
|
2946
|
+
// Auto-deny path should never prompt for approval
|
|
2947
|
+
expect(approvalSpy).not.toHaveBeenCalled();
|
|
2948
|
+
|
|
2949
|
+
deliverSpy.mockRestore();
|
|
2950
|
+
approvalSpy.mockRestore();
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
test('missing senderExternalUserId without guardian binding uses default flow', async () => {
|
|
2954
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2955
|
+
|
|
2956
|
+
// No guardian binding exists — default behavior should be preserved
|
|
2957
|
+
const orchestrator = makeMockOrchestrator();
|
|
2958
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2959
|
+
|
|
2960
|
+
const req = makeInboundRequest({
|
|
2961
|
+
content: 'hello world',
|
|
2962
|
+
senderExternalUserId: undefined,
|
|
2963
|
+
});
|
|
2964
|
+
|
|
2965
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2966
|
+
expect(res.status).toBe(200);
|
|
2967
|
+
|
|
2968
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2969
|
+
expect(body.accepted).toBe(true);
|
|
2970
|
+
|
|
2971
|
+
deliverSpy.mockRestore();
|
|
2972
|
+
});
|
|
2973
|
+
});
|
|
2974
|
+
|
|
2975
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2976
|
+
// 28. Gateway-origin proof hardening — dedicated secret support
|
|
2977
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2978
|
+
|
|
2979
|
+
describe('verifyGatewayOrigin with dedicated gateway-origin secret', () => {
|
|
2980
|
+
function makeReqWithHeader(value?: string): Request {
|
|
2981
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
2982
|
+
if (value !== undefined) {
|
|
2983
|
+
headers['X-Gateway-Origin'] = value;
|
|
2984
|
+
}
|
|
2985
|
+
return new Request('http://localhost/channels/inbound', {
|
|
2986
|
+
method: 'POST',
|
|
2987
|
+
headers,
|
|
2988
|
+
body: '{}',
|
|
2989
|
+
});
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
test('returns true when no secrets configured (local dev)', () => {
|
|
2993
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), undefined, undefined)).toBe(true);
|
|
2994
|
+
});
|
|
2995
|
+
|
|
2996
|
+
test('falls back to bearerToken when no dedicated secret is set', () => {
|
|
2997
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('my-bearer'), 'my-bearer', undefined)).toBe(true);
|
|
2998
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('wrong'), 'my-bearer', undefined)).toBe(false);
|
|
2999
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), 'my-bearer', undefined)).toBe(false);
|
|
3000
|
+
});
|
|
3001
|
+
|
|
3002
|
+
test('uses dedicated secret when set, ignoring bearer token', () => {
|
|
3003
|
+
// Dedicated secret matches — should pass even if bearer token differs
|
|
3004
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('dedicated-secret'), 'bearer-token', 'dedicated-secret')).toBe(true);
|
|
3005
|
+
// Bearer token matches but dedicated secret doesn't — should fail
|
|
3006
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('bearer-token'), 'bearer-token', 'dedicated-secret')).toBe(false);
|
|
3007
|
+
});
|
|
3008
|
+
|
|
3009
|
+
test('validates dedicated secret even when bearer token is not configured', () => {
|
|
3010
|
+
// No bearer token but dedicated secret is set — should validate against it
|
|
3011
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('my-secret'), undefined, 'my-secret')).toBe(true);
|
|
3012
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('wrong'), undefined, 'my-secret')).toBe(false);
|
|
3013
|
+
});
|
|
3014
|
+
|
|
3015
|
+
test('rejects missing header when any secret is configured', () => {
|
|
3016
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), 'bearer', undefined)).toBe(false);
|
|
3017
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), undefined, 'secret')).toBe(false);
|
|
3018
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), 'bearer', 'secret')).toBe(false);
|
|
3019
|
+
});
|
|
3020
|
+
|
|
3021
|
+
test('rejects mismatched length headers (constant-time comparison guard)', () => {
|
|
3022
|
+
// Different lengths should be rejected without timing leaks
|
|
3023
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('short'), 'a-much-longer-secret', undefined)).toBe(false);
|
|
3024
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('a-much-longer-secret'), 'short', undefined)).toBe(false);
|
|
3025
|
+
});
|
|
3026
|
+
});
|
|
3027
|
+
|
|
3028
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3029
|
+
// 29. handleChannelInbound passes gatewayOriginSecret to verifyGatewayOrigin
|
|
3030
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3031
|
+
|
|
3032
|
+
describe('handleChannelInbound gatewayOriginSecret integration', () => {
|
|
3033
|
+
test('rejects request when bearer token matches but dedicated secret does not', async () => {
|
|
3034
|
+
const bearerToken = 'my-bearer';
|
|
3035
|
+
const gatewaySecret = 'dedicated-gw-secret';
|
|
3036
|
+
|
|
3037
|
+
// Request carries the bearer token as X-Gateway-Origin, but the
|
|
3038
|
+
// dedicated secret is configured — verifyGatewayOrigin should require
|
|
3039
|
+
// the dedicated secret, not the bearer token.
|
|
3040
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
3041
|
+
method: 'POST',
|
|
3042
|
+
headers: {
|
|
3043
|
+
'Content-Type': 'application/json',
|
|
3044
|
+
'X-Gateway-Origin': bearerToken,
|
|
3045
|
+
},
|
|
3046
|
+
body: JSON.stringify({
|
|
3047
|
+
sourceChannel: 'telegram',
|
|
3048
|
+
externalChatId: 'chat-gw-secret-test',
|
|
3049
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
3050
|
+
content: 'hello',
|
|
3051
|
+
}),
|
|
3052
|
+
});
|
|
3053
|
+
|
|
3054
|
+
const res = await handleChannelInbound(
|
|
3055
|
+
req, noopProcessMessage, bearerToken, undefined, 'self', gatewaySecret,
|
|
3056
|
+
);
|
|
3057
|
+
expect(res.status).toBe(403);
|
|
3058
|
+
const body = await res.json() as { code: string };
|
|
3059
|
+
expect(body.code).toBe('GATEWAY_ORIGIN_REQUIRED');
|
|
3060
|
+
});
|
|
3061
|
+
|
|
3062
|
+
test('accepts request when dedicated secret matches', async () => {
|
|
3063
|
+
const bearerToken = 'my-bearer';
|
|
3064
|
+
const gatewaySecret = 'dedicated-gw-secret';
|
|
3065
|
+
|
|
3066
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
3067
|
+
method: 'POST',
|
|
3068
|
+
headers: {
|
|
3069
|
+
'Content-Type': 'application/json',
|
|
3070
|
+
'X-Gateway-Origin': gatewaySecret,
|
|
3071
|
+
},
|
|
3072
|
+
body: JSON.stringify({
|
|
3073
|
+
sourceChannel: 'telegram',
|
|
3074
|
+
externalChatId: 'chat-gw-secret-pass',
|
|
3075
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
3076
|
+
content: 'hello',
|
|
3077
|
+
}),
|
|
3078
|
+
});
|
|
3079
|
+
|
|
3080
|
+
const res = await handleChannelInbound(
|
|
3081
|
+
req, noopProcessMessage, bearerToken, undefined, 'self', gatewaySecret,
|
|
3082
|
+
);
|
|
3083
|
+
// Should pass the gateway-origin check and proceed to normal processing
|
|
3084
|
+
expect(res.status).toBe(200);
|
|
3085
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3086
|
+
expect(body.accepted).toBe(true);
|
|
3087
|
+
});
|
|
3088
|
+
|
|
3089
|
+
test('falls back to bearer token when no dedicated secret is set', async () => {
|
|
3090
|
+
const bearerToken = 'my-bearer';
|
|
3091
|
+
|
|
3092
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
3093
|
+
method: 'POST',
|
|
3094
|
+
headers: {
|
|
3095
|
+
'Content-Type': 'application/json',
|
|
3096
|
+
'X-Gateway-Origin': bearerToken,
|
|
3097
|
+
},
|
|
3098
|
+
body: JSON.stringify({
|
|
3099
|
+
sourceChannel: 'telegram',
|
|
3100
|
+
externalChatId: 'chat-gw-fallback',
|
|
3101
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
3102
|
+
content: 'hello',
|
|
3103
|
+
}),
|
|
3104
|
+
});
|
|
3105
|
+
|
|
3106
|
+
// No gatewayOriginSecret (6th param undefined) — should fall back to bearer
|
|
3107
|
+
const res = await handleChannelInbound(
|
|
3108
|
+
req, noopProcessMessage, bearerToken, undefined, 'self', undefined,
|
|
3109
|
+
);
|
|
3110
|
+
expect(res.status).toBe(200);
|
|
3111
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3112
|
+
expect(body.accepted).toBe(true);
|
|
3113
|
+
});
|
|
3114
|
+
});
|
|
3115
|
+
|
|
3116
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3117
|
+
// 30. Unknown actor identity — forceStrictSideEffects propagation
|
|
3118
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3119
|
+
|
|
3120
|
+
describe('unknown actor identity — forceStrictSideEffects', () => {
|
|
3121
|
+
beforeEach(() => {
|
|
3122
|
+
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
3123
|
+
});
|
|
3124
|
+
|
|
3125
|
+
test('unknown sender (no senderExternalUserId) with guardian binding gets forceStrictSideEffects', async () => {
|
|
3126
|
+
// Create a guardian binding so the channel is guardian-enforced
|
|
3127
|
+
createBinding({
|
|
3128
|
+
assistantId: 'self',
|
|
3129
|
+
channel: 'telegram',
|
|
3130
|
+
guardianExternalUserId: 'known-guardian',
|
|
3131
|
+
guardianDeliveryChatId: 'guardian-chat',
|
|
3132
|
+
});
|
|
3133
|
+
|
|
3134
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3135
|
+
|
|
3136
|
+
const mockRun = {
|
|
3137
|
+
id: 'run-unknown-actor',
|
|
3138
|
+
conversationId: 'conv-1',
|
|
3139
|
+
messageId: null,
|
|
3140
|
+
status: 'running' as const,
|
|
3141
|
+
pendingConfirmation: null,
|
|
3142
|
+
pendingSecret: null,
|
|
3143
|
+
inputTokens: 0,
|
|
3144
|
+
outputTokens: 0,
|
|
3145
|
+
estimatedCost: 0,
|
|
3146
|
+
error: null,
|
|
3147
|
+
createdAt: Date.now(),
|
|
3148
|
+
updatedAt: Date.now(),
|
|
3149
|
+
};
|
|
3150
|
+
|
|
3151
|
+
const orchestrator = {
|
|
3152
|
+
submitDecision: mock(() => 'applied' as const),
|
|
3153
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
3154
|
+
startRun: mock(async () => mockRun),
|
|
3155
|
+
} as unknown as RunOrchestrator;
|
|
3156
|
+
|
|
3157
|
+
// Send message with no senderExternalUserId — the unknown actor should
|
|
3158
|
+
// be classified as unverified_channel and forceStrictSideEffects set.
|
|
3159
|
+
const req = makeInboundRequest({
|
|
3160
|
+
content: 'do something',
|
|
3161
|
+
senderExternalUserId: undefined,
|
|
3162
|
+
});
|
|
3163
|
+
|
|
3164
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
3165
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
3166
|
+
|
|
3167
|
+
// startRun should have been called with forceStrictSideEffects: true
|
|
3168
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
3169
|
+
const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
|
|
3170
|
+
const options = startRunArgs[3] as { forceStrictSideEffects?: boolean } | undefined;
|
|
3171
|
+
expect(options).toBeDefined();
|
|
3172
|
+
expect(options!.forceStrictSideEffects).toBe(true);
|
|
3173
|
+
|
|
3174
|
+
deliverSpy.mockRestore();
|
|
3175
|
+
});
|
|
3176
|
+
|
|
3177
|
+
test('known non-guardian sender with guardian binding gets forceStrictSideEffects', async () => {
|
|
3178
|
+
createBinding({
|
|
3179
|
+
assistantId: 'self',
|
|
3180
|
+
channel: 'telegram',
|
|
3181
|
+
guardianExternalUserId: 'the-guardian',
|
|
3182
|
+
guardianDeliveryChatId: 'guardian-chat-2',
|
|
3183
|
+
});
|
|
3184
|
+
|
|
3185
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3186
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
3187
|
+
|
|
3188
|
+
const mockRun = {
|
|
3189
|
+
id: 'run-nongrd-strict',
|
|
3190
|
+
conversationId: 'conv-1',
|
|
3191
|
+
messageId: null,
|
|
3192
|
+
status: 'running' as const,
|
|
3193
|
+
pendingConfirmation: null,
|
|
3194
|
+
pendingSecret: null,
|
|
3195
|
+
inputTokens: 0,
|
|
3196
|
+
outputTokens: 0,
|
|
3197
|
+
estimatedCost: 0,
|
|
3198
|
+
error: null,
|
|
3199
|
+
createdAt: Date.now(),
|
|
3200
|
+
updatedAt: Date.now(),
|
|
3201
|
+
};
|
|
3202
|
+
|
|
3203
|
+
const orchestrator = {
|
|
3204
|
+
submitDecision: mock(() => 'applied' as const),
|
|
3205
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
3206
|
+
startRun: mock(async () => mockRun),
|
|
3207
|
+
} as unknown as RunOrchestrator;
|
|
3208
|
+
|
|
3209
|
+
// Non-guardian user sends a message
|
|
3210
|
+
const req = makeInboundRequest({
|
|
3211
|
+
content: 'do something',
|
|
3212
|
+
senderExternalUserId: 'not-the-guardian',
|
|
3213
|
+
});
|
|
3214
|
+
|
|
3215
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
3216
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
3217
|
+
|
|
3218
|
+
// startRun should have been called with forceStrictSideEffects: true
|
|
3219
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
3220
|
+
const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
|
|
3221
|
+
const options = startRunArgs[3] as { forceStrictSideEffects?: boolean } | undefined;
|
|
3222
|
+
expect(options).toBeDefined();
|
|
3223
|
+
expect(options!.forceStrictSideEffects).toBe(true);
|
|
3224
|
+
|
|
3225
|
+
deliverSpy.mockRestore();
|
|
3226
|
+
approvalSpy.mockRestore();
|
|
3227
|
+
});
|
|
3228
|
+
|
|
3229
|
+
test('guardian sender does NOT get forceStrictSideEffects', async () => {
|
|
3230
|
+
createBinding({
|
|
3231
|
+
assistantId: 'self',
|
|
3232
|
+
channel: 'telegram',
|
|
3233
|
+
guardianExternalUserId: 'the-guardian',
|
|
3234
|
+
guardianDeliveryChatId: 'guardian-chat-3',
|
|
3235
|
+
});
|
|
3236
|
+
|
|
3237
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3238
|
+
|
|
3239
|
+
const mockRun = {
|
|
3240
|
+
id: 'run-grd-no-strict',
|
|
3241
|
+
conversationId: 'conv-1',
|
|
3242
|
+
messageId: null,
|
|
3243
|
+
status: 'running' as const,
|
|
3244
|
+
pendingConfirmation: null,
|
|
3245
|
+
pendingSecret: null,
|
|
3246
|
+
inputTokens: 0,
|
|
3247
|
+
outputTokens: 0,
|
|
3248
|
+
estimatedCost: 0,
|
|
3249
|
+
error: null,
|
|
3250
|
+
createdAt: Date.now(),
|
|
3251
|
+
updatedAt: Date.now(),
|
|
3252
|
+
};
|
|
3253
|
+
|
|
3254
|
+
const orchestrator = {
|
|
3255
|
+
submitDecision: mock(() => 'applied' as const),
|
|
3256
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
3257
|
+
startRun: mock(async () => mockRun),
|
|
3258
|
+
} as unknown as RunOrchestrator;
|
|
3259
|
+
|
|
3260
|
+
// The guardian sends a message — should NOT get forceStrictSideEffects
|
|
3261
|
+
const req = makeInboundRequest({
|
|
3262
|
+
content: 'do something',
|
|
3263
|
+
senderExternalUserId: 'the-guardian',
|
|
3264
|
+
});
|
|
3265
|
+
|
|
3266
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
3267
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
3268
|
+
|
|
3269
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
3270
|
+
const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
|
|
3271
|
+
const options = startRunArgs[3] as { forceStrictSideEffects?: boolean; sourceChannel?: string } | undefined;
|
|
3272
|
+
expect(options).toBeDefined();
|
|
3273
|
+
// Guardian should NOT have forceStrictSideEffects set
|
|
3274
|
+
expect(options!.forceStrictSideEffects).toBeUndefined();
|
|
3275
|
+
|
|
3276
|
+
deliverSpy.mockRestore();
|
|
3277
|
+
});
|
|
3278
|
+
});
|