@vellumai/assistant 0.4.2 → 0.4.4
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/.env.example +3 -0
- package/ARCHITECTURE.md +124 -10
- package/README.md +43 -35
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +1 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/actor-token-service.test.ts +1099 -0
- package/src/__tests__/agent-loop.test.ts +51 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
- package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
- package/src/__tests__/call-controller.test.ts +49 -0
- package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
- package/src/__tests__/call-pointer-messages.test.ts +93 -3
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/callback-handoff-copy.test.ts +186 -0
- package/src/__tests__/channel-approval-routes.test.ts +133 -12
- package/src/__tests__/channel-guardian.test.ts +0 -86
- package/src/__tests__/channel-readiness-service.test.ts +10 -16
- package/src/__tests__/checker.test.ts +33 -12
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
- package/src/__tests__/conversation-routes.test.ts +12 -3
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/daemon-server-session-init.test.ts +4 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
- package/src/__tests__/guardian-dispatch.test.ts +8 -0
- package/src/__tests__/guardian-outbound-http.test.ts +4 -5
- package/src/__tests__/guardian-question-mode.test.ts +200 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
- package/src/__tests__/guardian-routing-state.test.ts +525 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
- package/src/__tests__/handlers-telegram-config.test.ts +0 -83
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
- package/src/__tests__/headless-browser-navigate.test.ts +2 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/ipc-snapshot.test.ts +18 -51
- package/src/__tests__/non-member-access-request.test.ts +159 -9
- package/src/__tests__/notification-decision-fallback.test.ts +129 -4
- package/src/__tests__/notification-decision-strategy.test.ts +106 -2
- package/src/__tests__/notification-guardian-path.test.ts +3 -0
- package/src/__tests__/recording-intent-handler.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +1475 -33
- package/src/__tests__/send-endpoint-busy.test.ts +5 -0
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-confirmation-signals.test.ts +523 -0
- package/src/__tests__/session-init.benchmark.test.ts +0 -2
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
- package/src/__tests__/tool-executor.test.ts +21 -2
- package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +0 -1
- package/src/__tests__/twilio-config.test.ts +2 -13
- package/src/__tests__/twilio-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-decision-primitive.ts +12 -3
- package/src/approvals/guardian-request-resolvers.ts +169 -11
- package/src/calls/call-constants.ts +29 -0
- package/src/calls/call-controller.ts +11 -3
- package/src/calls/call-domain.ts +33 -11
- package/src/calls/call-pointer-message-composer.ts +154 -0
- package/src/calls/call-pointer-messages.ts +106 -27
- package/src/calls/guardian-dispatch.ts +4 -2
- package/src/calls/relay-server.ts +921 -112
- package/src/calls/twilio-config.ts +4 -11
- package/src/calls/twilio-routes.ts +4 -6
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/cli.ts +5 -4
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
- package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
- package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
- package/src/config/bundled-skills/messaging/SKILL.md +61 -12
- package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
- package/src/config/bundled-skills/twitter/SKILL.md +3 -3
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
- package/src/config/calls-schema.ts +36 -0
- package/src/config/env.ts +22 -0
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/schema.ts +2 -2
- package/src/config/skills.ts +11 -0
- package/src/config/system-prompt.ts +11 -1
- package/src/config/templates/SOUL.md +2 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
- package/src/daemon/call-pointer-generators.ts +59 -0
- package/src/daemon/computer-use-session.ts +2 -5
- package/src/daemon/handlers/apps.ts +76 -20
- package/src/daemon/handlers/config-channels.ts +9 -61
- package/src/daemon/handlers/config-inbox.ts +11 -3
- package/src/daemon/handlers/config-ingress.ts +28 -3
- package/src/daemon/handlers/config-telegram.ts +12 -0
- package/src/daemon/handlers/config.ts +2 -6
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/pairing.ts +2 -0
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +59 -5
- package/src/daemon/handlers/shared.ts +17 -2
- package/src/daemon/ipc-contract/apps.ts +1 -0
- package/src/daemon/ipc-contract/inbox.ts +4 -0
- package/src/daemon/ipc-contract/integrations.ts +1 -97
- package/src/daemon/ipc-contract/messages.ts +47 -1
- package/src/daemon/ipc-contract/notifications.ts +11 -0
- package/src/daemon/ipc-contract-inventory.json +2 -4
- package/src/daemon/lifecycle.ts +17 -0
- package/src/daemon/server.ts +16 -2
- package/src/daemon/session-agent-loop-handlers.ts +20 -0
- package/src/daemon/session-agent-loop.ts +24 -12
- package/src/daemon/session-lifecycle.ts +1 -1
- package/src/daemon/session-process.ts +11 -1
- package/src/daemon/session-runtime-assembly.ts +6 -1
- package/src/daemon/session-surfaces.ts +32 -3
- package/src/daemon/session.ts +88 -1
- package/src/daemon/tool-side-effects.ts +22 -0
- package/src/home-base/prebuilt/brain-graph.html +1483 -0
- package/src/home-base/prebuilt/index.html +40 -0
- package/src/inbound/platform-callback-registration.ts +157 -0
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +8 -0
- package/src/memory/delivery-crud.ts +2 -1
- package/src/memory/guardian-action-store.ts +2 -1
- package/src/memory/guardian-approvals.ts +3 -2
- package/src/memory/ingress-invite-store.ts +12 -2
- package/src/memory/ingress-member-store.ts +4 -3
- package/src/memory/migrations/038-actor-token-records.ts +39 -0
- package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema.ts +26 -5
- package/src/messaging/provider-types.ts +24 -0
- package/src/messaging/provider.ts +7 -0
- package/src/messaging/providers/gmail/adapter.ts +127 -0
- package/src/messaging/providers/sms/adapter.ts +40 -37
- package/src/notifications/adapters/macos.ts +45 -2
- package/src/notifications/broadcaster.ts +16 -0
- package/src/notifications/copy-composer.ts +50 -2
- package/src/notifications/decision-engine.ts +22 -9
- package/src/notifications/destination-resolver.ts +16 -2
- package/src/notifications/emit-signal.ts +18 -9
- package/src/notifications/guardian-question-mode.ts +419 -0
- package/src/notifications/signal.ts +14 -3
- package/src/permissions/checker.ts +13 -1
- package/src/permissions/prompter.ts +14 -0
- package/src/providers/anthropic/client.ts +20 -0
- package/src/providers/provider-send-message.ts +15 -3
- package/src/runtime/access-request-helper.ts +82 -4
- package/src/runtime/actor-token-service.ts +234 -0
- package/src/runtime/actor-token-store.ts +236 -0
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/channel-approvals.ts +5 -3
- package/src/runtime/channel-readiness-service.ts +23 -64
- package/src/runtime/channel-readiness-types.ts +3 -4
- package/src/runtime/channel-retry-sweep.ts +4 -1
- package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-context-resolver.ts +82 -0
- package/src/runtime/guardian-outbound-actions.ts +5 -7
- package/src/runtime/guardian-reply-router.ts +67 -30
- package/src/runtime/guardian-vellum-migration.ts +57 -0
- package/src/runtime/http-server.ts +75 -31
- package/src/runtime/http-types.ts +13 -0
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +10 -1
- package/src/runtime/local-actor-identity.ts +76 -0
- package/src/runtime/middleware/actor-token.ts +271 -0
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/approval-routes.ts +82 -7
- package/src/runtime/routes/brain-graph-routes.ts +222 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-readiness-routes.ts +71 -0
- package/src/runtime/routes/channel-route-shared.ts +3 -3
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +142 -53
- package/src/runtime/routes/events-routes.ts +22 -8
- package/src/runtime/routes/guardian-action-routes.ts +45 -3
- package/src/runtime/routes/guardian-approval-interception.ts +29 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +147 -5
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/runtime/routes/integration-routes.ts +7 -15
- package/src/runtime/routes/pairing-routes.ts +163 -0
- package/src/runtime/routes/twilio-routes.ts +934 -0
- package/src/runtime/tool-grant-request-helper.ts +3 -1
- package/src/security/oauth2.ts +27 -2
- package/src/security/token-manager.ts +46 -10
- package/src/tools/browser/browser-execution.ts +4 -3
- package/src/tools/browser/browser-handoff.ts +10 -18
- package/src/tools/browser/browser-manager.ts +80 -25
- package/src/tools/browser/browser-screencast.ts +35 -119
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/permission-checker.ts +15 -4
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +244 -19
- package/src/workspace/git-service.ts +19 -0
- package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
- package/src/daemon/handlers/config-twilio.ts +0 -1082
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration tests for the trusted-contact inline guardian approval feature.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the full integration of M1-M4 milestones:
|
|
5
|
+
* M1: RoutingState (guardian-context-resolver.ts)
|
|
6
|
+
* M2: Confirmation request guardian bridge (confirmation-request-guardian-bridge.ts)
|
|
7
|
+
* M3: Pending approval notifier (inbound-message-handler.ts)
|
|
8
|
+
* M4: Inline grant wait-and-resume (tool-approval-handler.ts) +
|
|
9
|
+
* staleness guard (guardian-request-resolvers.ts)
|
|
10
|
+
*
|
|
11
|
+
* Covered UX flows:
|
|
12
|
+
* a. Target flow: trusted contact -> guardian-gated action -> pending msg -> guardian approves -> tool executes
|
|
13
|
+
* b. Prompt-path flow: confirmation_request bridges to guardian notification and resumes
|
|
14
|
+
* c. No-binding flow: trusted contact without guardian binding fails fast (no dead-end wait)
|
|
15
|
+
* d. Unknown actor flow: remains fail-closed (no interactive approval)
|
|
16
|
+
* e. Guardian-only prompt delivery invariant: non-guardian never receives approval prompt UI
|
|
17
|
+
* f. Timeout/stale flow: guardian decision after prompt timeout produces deterministic outcome
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
25
|
+
|
|
26
|
+
const testDir = mkdtempSync(join(tmpdir(), 'tc-inline-approval-integration-'));
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Mocks — must be set before any production imports
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
mock.module('../util/platform.js', () => ({
|
|
33
|
+
getDataDir: () => testDir,
|
|
34
|
+
getRootDir: () => testDir,
|
|
35
|
+
isMacOS: () => process.platform === 'darwin',
|
|
36
|
+
isLinux: () => process.platform === 'linux',
|
|
37
|
+
isWindows: () => process.platform === 'win32',
|
|
38
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
39
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
40
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
41
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
42
|
+
readHttpToken: () => 'test-token',
|
|
43
|
+
ensureDataDir: () => {},
|
|
44
|
+
migrateToDataLayout: () => {},
|
|
45
|
+
migrateToWorkspaceLayout: () => {},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
mock.module('../util/logger.js', () => ({
|
|
49
|
+
getLogger: () =>
|
|
50
|
+
new Proxy({} as Record<string, unknown>, {
|
|
51
|
+
get: () => () => {},
|
|
52
|
+
}),
|
|
53
|
+
isDebug: () => false,
|
|
54
|
+
truncateForLog: (value: string) => value,
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// Mock notification emission — capture calls
|
|
58
|
+
const emittedSignals: Array<Record<string, unknown>> = [];
|
|
59
|
+
mock.module('../notifications/emit-signal.js', () => ({
|
|
60
|
+
emitNotificationSignal: async (params: Record<string, unknown>) => {
|
|
61
|
+
emittedSignals.push(params);
|
|
62
|
+
return {
|
|
63
|
+
signalId: 'test-signal',
|
|
64
|
+
deduplicated: false,
|
|
65
|
+
dispatched: true,
|
|
66
|
+
reason: 'ok',
|
|
67
|
+
deliveryResults: [
|
|
68
|
+
{ channel: 'telegram', destination: 'guardian-chat-1', success: true },
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
registerBroadcastFn: () => {},
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
// Mock guardian control-plane policy — not targeting control-plane by default
|
|
76
|
+
mock.module('../tools/guardian-control-plane-policy.js', () => ({
|
|
77
|
+
enforceGuardianOnlyPolicy: () => ({ denied: false }),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
// Mock task run rules
|
|
81
|
+
mock.module('../tasks/ephemeral-permissions.js', () => ({
|
|
82
|
+
getTaskRunRules: () => [],
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
// Mock tool registry — provide a fake 'bash' tool
|
|
86
|
+
const fakeTool = {
|
|
87
|
+
name: 'bash',
|
|
88
|
+
description: 'Run a shell command',
|
|
89
|
+
category: 'shell',
|
|
90
|
+
defaultRiskLevel: 'high',
|
|
91
|
+
getDefinition: () => ({ name: 'bash', description: 'Run a shell command', input_schema: {} }),
|
|
92
|
+
execute: async () => ({ content: 'ok', isError: false }),
|
|
93
|
+
};
|
|
94
|
+
mock.module('../tools/registry.js', () => ({
|
|
95
|
+
getTool: (name: string) => (name === 'bash' ? fakeTool : undefined),
|
|
96
|
+
getAllTools: () => [fakeTool],
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
// Mock channel guardian service — configurable per test
|
|
100
|
+
let mockGuardianBinding: Record<string, unknown> | null = {
|
|
101
|
+
id: 'binding-1',
|
|
102
|
+
assistantId: 'self',
|
|
103
|
+
channel: 'telegram',
|
|
104
|
+
guardianExternalUserId: 'guardian-1',
|
|
105
|
+
guardianDeliveryChatId: 'guardian-chat-1',
|
|
106
|
+
status: 'active',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
mock.module('../runtime/channel-guardian-service.js', () => ({
|
|
110
|
+
getGuardianBinding: (assistantId: string, channel: string) => {
|
|
111
|
+
if (assistantId === 'self' && channel === 'telegram' && mockGuardianBinding) {
|
|
112
|
+
return mockGuardianBinding;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
},
|
|
116
|
+
createOutboundSession: () => ({
|
|
117
|
+
sessionId: 'test-session',
|
|
118
|
+
secret: '123456',
|
|
119
|
+
}),
|
|
120
|
+
bindSessionIdentity: () => {},
|
|
121
|
+
findActiveSession: () => null,
|
|
122
|
+
getPendingChallenge: () => null,
|
|
123
|
+
isGuardian: () => false,
|
|
124
|
+
resolveBootstrapToken: () => null,
|
|
125
|
+
updateSessionDelivery: () => {},
|
|
126
|
+
updateSessionStatus: () => {},
|
|
127
|
+
validateAndConsumeChallenge: () => ({ success: false, reason: 'no_challenge' }),
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
// Mock gateway client — capture delivery calls
|
|
131
|
+
const deliveredReplies: Array<{ url: string; payload: Record<string, unknown>; bearerToken?: string }> = [];
|
|
132
|
+
mock.module('../runtime/gateway-client.js', () => ({
|
|
133
|
+
deliverChannelReply: async (
|
|
134
|
+
url: string,
|
|
135
|
+
payload: Record<string, unknown>,
|
|
136
|
+
bearerToken?: string,
|
|
137
|
+
) => {
|
|
138
|
+
deliveredReplies.push({ url, payload, bearerToken });
|
|
139
|
+
return { ok: true };
|
|
140
|
+
},
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
// Mock pending interactions (channel-approvals)
|
|
144
|
+
let mockPendingApprovals: Array<{
|
|
145
|
+
requestId: string;
|
|
146
|
+
toolName: string;
|
|
147
|
+
input: Record<string, unknown>;
|
|
148
|
+
riskLevel: string;
|
|
149
|
+
}> = [];
|
|
150
|
+
|
|
151
|
+
mock.module('../runtime/channel-approvals.js', () => ({
|
|
152
|
+
getApprovalInfoByConversation: () => mockPendingApprovals,
|
|
153
|
+
getChannelApprovalPrompt: () => null,
|
|
154
|
+
buildApprovalUIMetadata: () => ({}),
|
|
155
|
+
handleChannelDecision: () => ({ applied: false }),
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
mock.module('../config/env.js', () => ({
|
|
159
|
+
getGatewayInternalBaseUrl: () => 'http://localhost:3000',
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Production imports (AFTER mocks)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
import {
|
|
167
|
+
applyCanonicalGuardianDecision,
|
|
168
|
+
} from '../approvals/guardian-decision-primitive.js';
|
|
169
|
+
import type { ActorContext } from '../approvals/guardian-request-resolvers.js';
|
|
170
|
+
import { getResolver } from '../approvals/guardian-request-resolvers.js';
|
|
171
|
+
import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
172
|
+
import {
|
|
173
|
+
createCanonicalGuardianRequest,
|
|
174
|
+
getCanonicalGuardianRequest,
|
|
175
|
+
listCanonicalGuardianRequests,
|
|
176
|
+
updateCanonicalGuardianRequest,
|
|
177
|
+
} from '../memory/canonical-guardian-store.js';
|
|
178
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
179
|
+
import { scopedApprovalGrants } from '../memory/schema.js';
|
|
180
|
+
import { bridgeConfirmationRequestToGuardian } from '../runtime/confirmation-request-guardian-bridge.js';
|
|
181
|
+
import {
|
|
182
|
+
type GuardianContext,
|
|
183
|
+
resolveRoutingState,
|
|
184
|
+
} from '../runtime/guardian-context-resolver.js';
|
|
185
|
+
import { TC_GRANT_WAIT_MAX_MS, ToolApprovalHandler } from '../tools/tool-approval-handler.js';
|
|
186
|
+
import type { ToolContext, ToolLifecycleEvent } from '../tools/types.js';
|
|
187
|
+
|
|
188
|
+
initializeDb();
|
|
189
|
+
|
|
190
|
+
function resetTables(): void {
|
|
191
|
+
const db = getDb();
|
|
192
|
+
db.delete(scopedApprovalGrants).run();
|
|
193
|
+
db.run('DELETE FROM canonical_guardian_deliveries');
|
|
194
|
+
db.run('DELETE FROM canonical_guardian_requests');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
afterAll(() => {
|
|
198
|
+
resetDb();
|
|
199
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Helpers
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
function makeToolContext(overrides: Partial<ToolContext> = {}): ToolContext {
|
|
207
|
+
return {
|
|
208
|
+
workingDir: testDir,
|
|
209
|
+
sessionId: 'session-1',
|
|
210
|
+
conversationId: 'conv-1',
|
|
211
|
+
assistantId: 'self',
|
|
212
|
+
requestId: 'req-1',
|
|
213
|
+
guardianTrustClass: 'trusted_contact',
|
|
214
|
+
executionChannel: 'telegram',
|
|
215
|
+
requesterExternalUserId: 'requester-1',
|
|
216
|
+
...overrides,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
221
|
+
return {
|
|
222
|
+
externalUserId: 'guardian-1',
|
|
223
|
+
channel: 'telegram',
|
|
224
|
+
isTrusted: false,
|
|
225
|
+
...overrides,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function makeTrustedContactGuardianContext(): GuardianRuntimeContext {
|
|
230
|
+
return {
|
|
231
|
+
sourceChannel: 'telegram',
|
|
232
|
+
trustClass: 'trusted_contact',
|
|
233
|
+
guardianExternalUserId: 'guardian-1',
|
|
234
|
+
guardianChatId: 'guardian-chat-1',
|
|
235
|
+
requesterExternalUserId: 'requester-1',
|
|
236
|
+
requesterChatId: 'requester-chat-1',
|
|
237
|
+
requesterIdentifier: '@requester',
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const events: ToolLifecycleEvent[] = [];
|
|
242
|
+
const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
|
|
243
|
+
|
|
244
|
+
// ===========================================================================
|
|
245
|
+
// a. Target flow: trusted contact -> guardian-gated tool -> approve -> execute
|
|
246
|
+
// ===========================================================================
|
|
247
|
+
|
|
248
|
+
describe('(a) target flow: trusted-contact inline guardian approval end-to-end', () => {
|
|
249
|
+
const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 } });
|
|
250
|
+
|
|
251
|
+
beforeEach(() => {
|
|
252
|
+
resetTables();
|
|
253
|
+
events.length = 0;
|
|
254
|
+
emittedSignals.length = 0;
|
|
255
|
+
deliveredReplies.length = 0;
|
|
256
|
+
mockGuardianBinding = {
|
|
257
|
+
id: 'binding-1',
|
|
258
|
+
assistantId: 'self',
|
|
259
|
+
channel: 'telegram',
|
|
260
|
+
guardianExternalUserId: 'guardian-1',
|
|
261
|
+
guardianDeliveryChatId: 'guardian-chat-1',
|
|
262
|
+
status: 'active',
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('trusted contact requests tool, guardian approves mid-wait, tool executes inline', async () => {
|
|
267
|
+
const toolName = 'bash';
|
|
268
|
+
const input = { command: 'echo hello' };
|
|
269
|
+
const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
|
|
270
|
+
|
|
271
|
+
// Schedule guardian approval after 100ms during the inline wait
|
|
272
|
+
const approvalPromise = (async () => {
|
|
273
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
274
|
+
const pending = listCanonicalGuardianRequests({
|
|
275
|
+
kind: 'tool_grant_request',
|
|
276
|
+
status: 'pending',
|
|
277
|
+
toolName: 'bash',
|
|
278
|
+
});
|
|
279
|
+
expect(pending.length).toBeGreaterThan(0);
|
|
280
|
+
|
|
281
|
+
// Verify the request has an inline_wait_active stamp
|
|
282
|
+
const freshReq = getCanonicalGuardianRequest(pending[0].id);
|
|
283
|
+
expect(freshReq?.followupState).toMatch(/^inline_wait_active:\d+$/);
|
|
284
|
+
|
|
285
|
+
await applyCanonicalGuardianDecision({
|
|
286
|
+
requestId: pending[0].id,
|
|
287
|
+
action: 'approve_once',
|
|
288
|
+
actorContext: guardianActor(),
|
|
289
|
+
});
|
|
290
|
+
})();
|
|
291
|
+
|
|
292
|
+
const result = await handler.checkPreExecutionGates(
|
|
293
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
await approvalPromise;
|
|
297
|
+
|
|
298
|
+
// Tool execution should proceed inline
|
|
299
|
+
expect(result.allowed).toBe(true);
|
|
300
|
+
if (!result.allowed) return;
|
|
301
|
+
expect(result.grantConsumed).toBe(true);
|
|
302
|
+
|
|
303
|
+
// followupState should be cleared after a successful inline grant
|
|
304
|
+
const resolved = listCanonicalGuardianRequests({ kind: 'tool_grant_request' });
|
|
305
|
+
expect(resolved.length).toBe(1);
|
|
306
|
+
const freshReq = getCanonicalGuardianRequest(resolved[0].id);
|
|
307
|
+
expect(freshReq?.followupState).toBeNull();
|
|
308
|
+
|
|
309
|
+
// A guardian.question notification should have been emitted
|
|
310
|
+
const questionSignals = emittedSignals.filter((s) => s.sourceEventName === 'guardian.question');
|
|
311
|
+
expect(questionSignals.length).toBeGreaterThan(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('complete flow: routing state allows interactive + bridge notifies guardian + tool resumes', async () => {
|
|
315
|
+
// Step 1: Verify routing state allows interactive turns for trusted contacts
|
|
316
|
+
const guardianCtx: GuardianContext = {
|
|
317
|
+
trustClass: 'trusted_contact',
|
|
318
|
+
guardianExternalUserId: 'guardian-1',
|
|
319
|
+
guardianChatId: 'guardian-chat-1',
|
|
320
|
+
};
|
|
321
|
+
const routing = resolveRoutingState(guardianCtx);
|
|
322
|
+
expect(routing.promptWaitingAllowed).toBe(true);
|
|
323
|
+
expect(routing.guardianRouteResolvable).toBe(true);
|
|
324
|
+
|
|
325
|
+
// Step 2: Tool invocation creates escalation + waits inline + guardian approves
|
|
326
|
+
const toolName = 'bash';
|
|
327
|
+
const input = { command: 'deploy' };
|
|
328
|
+
const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
|
|
329
|
+
|
|
330
|
+
const approvalPromise = (async () => {
|
|
331
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
332
|
+
const pending = listCanonicalGuardianRequests({
|
|
333
|
+
kind: 'tool_grant_request',
|
|
334
|
+
status: 'pending',
|
|
335
|
+
});
|
|
336
|
+
if (pending.length > 0) {
|
|
337
|
+
await applyCanonicalGuardianDecision({
|
|
338
|
+
requestId: pending[0].id,
|
|
339
|
+
action: 'approve_once',
|
|
340
|
+
actorContext: guardianActor(),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
})();
|
|
344
|
+
|
|
345
|
+
const result = await handler.checkPreExecutionGates(
|
|
346
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await approvalPromise;
|
|
350
|
+
|
|
351
|
+
expect(result.allowed).toBe(true);
|
|
352
|
+
if (!result.allowed) return;
|
|
353
|
+
expect(result.grantConsumed).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ===========================================================================
|
|
358
|
+
// b. Prompt-path flow: confirmation_request bridges to guardian notification
|
|
359
|
+
// ===========================================================================
|
|
360
|
+
|
|
361
|
+
describe('(b) prompt-path flow: confirmation_request bridges to guardian', () => {
|
|
362
|
+
beforeEach(() => {
|
|
363
|
+
resetTables();
|
|
364
|
+
emittedSignals.length = 0;
|
|
365
|
+
mockGuardianBinding = {
|
|
366
|
+
id: 'binding-1',
|
|
367
|
+
assistantId: 'self',
|
|
368
|
+
channel: 'telegram',
|
|
369
|
+
guardianExternalUserId: 'guardian-1',
|
|
370
|
+
guardianDeliveryChatId: 'guardian-chat-1',
|
|
371
|
+
status: 'active',
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('trusted-contact confirmation_request emits guardian.question and creates delivery records', () => {
|
|
376
|
+
const canonicalRequest = createCanonicalGuardianRequest({
|
|
377
|
+
id: `req-bridge-${Date.now()}`,
|
|
378
|
+
kind: 'tool_approval',
|
|
379
|
+
sourceType: 'channel',
|
|
380
|
+
sourceChannel: 'telegram',
|
|
381
|
+
conversationId: 'conv-bridge-1',
|
|
382
|
+
requesterExternalUserId: 'requester-1',
|
|
383
|
+
guardianExternalUserId: 'guardian-1',
|
|
384
|
+
toolName: 'bash',
|
|
385
|
+
status: 'pending',
|
|
386
|
+
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const guardianContext = makeTrustedContactGuardianContext();
|
|
390
|
+
|
|
391
|
+
const result = bridgeConfirmationRequestToGuardian({
|
|
392
|
+
canonicalRequest,
|
|
393
|
+
guardianContext,
|
|
394
|
+
conversationId: 'conv-bridge-1',
|
|
395
|
+
toolName: 'bash',
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
expect('bridged' in result && result.bridged).toBe(true);
|
|
399
|
+
|
|
400
|
+
// guardian.question notification was emitted
|
|
401
|
+
expect(emittedSignals.length).toBeGreaterThan(0);
|
|
402
|
+
expect(emittedSignals[0].sourceEventName).toBe('guardian.question');
|
|
403
|
+
|
|
404
|
+
const payload = emittedSignals[0].contextPayload as Record<string, unknown>;
|
|
405
|
+
expect(payload.requestId).toBe(canonicalRequest.id);
|
|
406
|
+
expect(payload.toolName).toBe('bash');
|
|
407
|
+
expect(payload.requesterIdentifier).toBe('@requester');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('bridge + tool_grant_request both use guardian.question for unified routing', () => {
|
|
411
|
+
// The confirmation_request bridge and tool_grant_request helper both
|
|
412
|
+
// use 'guardian.question' as the notification signal, ensuring consistent
|
|
413
|
+
// guardian routing regardless of the approval path.
|
|
414
|
+
const canonicalRequest = createCanonicalGuardianRequest({
|
|
415
|
+
id: `req-unified-${Date.now()}`,
|
|
416
|
+
kind: 'tool_approval',
|
|
417
|
+
sourceType: 'channel',
|
|
418
|
+
sourceChannel: 'telegram',
|
|
419
|
+
conversationId: 'conv-unified-1',
|
|
420
|
+
requesterExternalUserId: 'requester-1',
|
|
421
|
+
guardianExternalUserId: 'guardian-1',
|
|
422
|
+
toolName: 'bash',
|
|
423
|
+
status: 'pending',
|
|
424
|
+
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const guardianContext = makeTrustedContactGuardianContext();
|
|
428
|
+
|
|
429
|
+
bridgeConfirmationRequestToGuardian({
|
|
430
|
+
canonicalRequest,
|
|
431
|
+
guardianContext,
|
|
432
|
+
conversationId: 'conv-unified-1',
|
|
433
|
+
toolName: 'bash',
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// All emitted signals should use guardian.question
|
|
437
|
+
const eventNames = emittedSignals.map((s) => s.sourceEventName);
|
|
438
|
+
for (const name of eventNames) {
|
|
439
|
+
expect(name).toBe('guardian.question');
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ===========================================================================
|
|
445
|
+
// c. No-binding flow: trusted contact fails fast without guardian binding
|
|
446
|
+
// ===========================================================================
|
|
447
|
+
|
|
448
|
+
describe('(c) no-binding flow: trusted contact fails fast without guardian binding', () => {
|
|
449
|
+
const shortHandler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 100, intervalMs: 20 } });
|
|
450
|
+
|
|
451
|
+
beforeEach(() => {
|
|
452
|
+
resetTables();
|
|
453
|
+
events.length = 0;
|
|
454
|
+
emittedSignals.length = 0;
|
|
455
|
+
deliveredReplies.length = 0;
|
|
456
|
+
mockGuardianBinding = null; // No guardian binding
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('routing state blocks prompt waiting when no guardian binding exists', () => {
|
|
460
|
+
const ctx: GuardianContext = {
|
|
461
|
+
trustClass: 'trusted_contact',
|
|
462
|
+
// No guardianExternalUserId — mirrors no binding
|
|
463
|
+
};
|
|
464
|
+
const state = resolveRoutingState(ctx);
|
|
465
|
+
|
|
466
|
+
expect(state.canBeInteractive).toBe(true);
|
|
467
|
+
expect(state.guardianRouteResolvable).toBe(false);
|
|
468
|
+
expect(state.promptWaitingAllowed).toBe(false);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('tool escalation returns generic denial (no dead-end wait) without binding', async () => {
|
|
472
|
+
const toolName = 'bash';
|
|
473
|
+
const input = { command: 'ls' };
|
|
474
|
+
const context = makeToolContext({
|
|
475
|
+
guardianTrustClass: 'trusted_contact',
|
|
476
|
+
executionChannel: 'telegram',
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const start = Date.now();
|
|
480
|
+
const result = await shortHandler.checkPreExecutionGates(
|
|
481
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
482
|
+
);
|
|
483
|
+
const elapsed = Date.now() - start;
|
|
484
|
+
|
|
485
|
+
expect(result.allowed).toBe(false);
|
|
486
|
+
if (result.allowed) return;
|
|
487
|
+
|
|
488
|
+
// Should return the generic guardian approval message, not enter inline wait
|
|
489
|
+
expect(result.result.content).toContain('guardian approval');
|
|
490
|
+
|
|
491
|
+
// No canonical tool_grant_request should have been created (no binding)
|
|
492
|
+
const requests = listCanonicalGuardianRequests({
|
|
493
|
+
kind: 'tool_grant_request',
|
|
494
|
+
status: 'pending',
|
|
495
|
+
});
|
|
496
|
+
expect(requests.length).toBe(0);
|
|
497
|
+
|
|
498
|
+
// Should complete nearly instantly, not block for the full wait budget
|
|
499
|
+
expect(elapsed).toBeLessThan(500);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('bridge skips when no guardian binding exists for channel', () => {
|
|
503
|
+
const canonicalRequest = createCanonicalGuardianRequest({
|
|
504
|
+
id: `req-nobinding-${Date.now()}`,
|
|
505
|
+
kind: 'tool_approval',
|
|
506
|
+
sourceType: 'channel',
|
|
507
|
+
sourceChannel: 'telegram',
|
|
508
|
+
conversationId: 'conv-nobinding',
|
|
509
|
+
requesterExternalUserId: 'requester-1',
|
|
510
|
+
guardianExternalUserId: 'guardian-1',
|
|
511
|
+
toolName: 'bash',
|
|
512
|
+
status: 'pending',
|
|
513
|
+
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const guardianContext = makeTrustedContactGuardianContext();
|
|
517
|
+
|
|
518
|
+
const result = bridgeConfirmationRequestToGuardian({
|
|
519
|
+
canonicalRequest,
|
|
520
|
+
guardianContext,
|
|
521
|
+
conversationId: 'conv-nobinding',
|
|
522
|
+
toolName: 'bash',
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect('skipped' in result && result.skipped).toBe(true);
|
|
526
|
+
if ('skipped' in result) {
|
|
527
|
+
expect(result.reason).toBe('no_guardian_binding');
|
|
528
|
+
}
|
|
529
|
+
expect(emittedSignals.length).toBe(0);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// ===========================================================================
|
|
534
|
+
// d. Unknown actor flow: remains fail-closed
|
|
535
|
+
// ===========================================================================
|
|
536
|
+
|
|
537
|
+
describe('(d) unknown actor flow: fail-closed with no interactive approval', () => {
|
|
538
|
+
const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 } });
|
|
539
|
+
|
|
540
|
+
beforeEach(() => {
|
|
541
|
+
resetTables();
|
|
542
|
+
events.length = 0;
|
|
543
|
+
emittedSignals.length = 0;
|
|
544
|
+
mockGuardianBinding = {
|
|
545
|
+
id: 'binding-1',
|
|
546
|
+
assistantId: 'self',
|
|
547
|
+
channel: 'telegram',
|
|
548
|
+
guardianExternalUserId: 'guardian-1',
|
|
549
|
+
guardianDeliveryChatId: 'guardian-chat-1',
|
|
550
|
+
status: 'active',
|
|
551
|
+
};
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test('unknown actors get immediate denial with no escalation or wait', async () => {
|
|
555
|
+
const toolName = 'bash';
|
|
556
|
+
const input = { command: 'ls' };
|
|
557
|
+
const context = makeToolContext({
|
|
558
|
+
guardianTrustClass: 'unknown',
|
|
559
|
+
executionChannel: 'telegram',
|
|
560
|
+
requesterExternalUserId: 'unknown-user',
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const start = Date.now();
|
|
564
|
+
const result = await handler.checkPreExecutionGates(
|
|
565
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
566
|
+
);
|
|
567
|
+
const elapsed = Date.now() - start;
|
|
568
|
+
|
|
569
|
+
expect(result.allowed).toBe(false);
|
|
570
|
+
if (result.allowed) return;
|
|
571
|
+
|
|
572
|
+
// Unknown actors get the verified-identity message
|
|
573
|
+
expect(result.result.content).toContain('verified channel identity');
|
|
574
|
+
|
|
575
|
+
// No canonical request created — unknown actors don't escalate
|
|
576
|
+
const requests = listCanonicalGuardianRequests({
|
|
577
|
+
kind: 'tool_grant_request',
|
|
578
|
+
status: 'pending',
|
|
579
|
+
});
|
|
580
|
+
expect(requests.length).toBe(0);
|
|
581
|
+
|
|
582
|
+
// Near-instant: no inline wait for unknown actors
|
|
583
|
+
expect(elapsed).toBeLessThan(200);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('unknown actors have promptWaitingAllowed=false regardless of guardian route', () => {
|
|
587
|
+
const withRoute: GuardianContext = {
|
|
588
|
+
trustClass: 'unknown',
|
|
589
|
+
guardianExternalUserId: 'guardian-1',
|
|
590
|
+
};
|
|
591
|
+
const withoutRoute: GuardianContext = {
|
|
592
|
+
trustClass: 'unknown',
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
expect(resolveRoutingState(withRoute).promptWaitingAllowed).toBe(false);
|
|
596
|
+
expect(resolveRoutingState(withRoute).canBeInteractive).toBe(false);
|
|
597
|
+
expect(resolveRoutingState(withoutRoute).promptWaitingAllowed).toBe(false);
|
|
598
|
+
expect(resolveRoutingState(withoutRoute).canBeInteractive).toBe(false);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('bridge skips unknown actor sessions entirely', () => {
|
|
602
|
+
const canonicalRequest = createCanonicalGuardianRequest({
|
|
603
|
+
id: `req-unknown-${Date.now()}`,
|
|
604
|
+
kind: 'tool_approval',
|
|
605
|
+
sourceType: 'channel',
|
|
606
|
+
sourceChannel: 'telegram',
|
|
607
|
+
conversationId: 'conv-unknown',
|
|
608
|
+
requesterExternalUserId: 'unknown-user',
|
|
609
|
+
guardianExternalUserId: 'guardian-1',
|
|
610
|
+
toolName: 'bash',
|
|
611
|
+
status: 'pending',
|
|
612
|
+
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
616
|
+
sourceChannel: 'telegram',
|
|
617
|
+
trustClass: 'unknown',
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const result = bridgeConfirmationRequestToGuardian({
|
|
621
|
+
canonicalRequest,
|
|
622
|
+
guardianContext,
|
|
623
|
+
conversationId: 'conv-unknown',
|
|
624
|
+
toolName: 'bash',
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
expect('skipped' in result && result.skipped).toBe(true);
|
|
628
|
+
if ('skipped' in result) {
|
|
629
|
+
expect(result.reason).toBe('not_trusted_contact');
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// ===========================================================================
|
|
635
|
+
// e. Guardian-only prompt delivery invariant
|
|
636
|
+
// ===========================================================================
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Mirrors the `isBoundGuardianActor` guard from inbound-message-handler.ts.
|
|
640
|
+
* Uses the same runtime-value shape so TypeScript treats the comparisons as
|
|
641
|
+
* `string === string` rather than `'literal_a' === 'literal_b'` (which TS
|
|
642
|
+
* flags as always-false under strict literal narrowing — TS2367/TS2872).
|
|
643
|
+
*/
|
|
644
|
+
function checkIsBoundGuardianActor(params: {
|
|
645
|
+
guardianTrustClass: string;
|
|
646
|
+
guardianExternalUserId: string | undefined;
|
|
647
|
+
requesterExternalUserId: string;
|
|
648
|
+
}): boolean {
|
|
649
|
+
return (
|
|
650
|
+
params.guardianTrustClass === 'guardian'
|
|
651
|
+
&& !!params.guardianExternalUserId
|
|
652
|
+
&& params.requesterExternalUserId === params.guardianExternalUserId
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
describe('(e) guardian-only prompt delivery invariant', () => {
|
|
657
|
+
beforeEach(() => {
|
|
658
|
+
deliveredReplies.length = 0;
|
|
659
|
+
mockPendingApprovals = [{
|
|
660
|
+
requestId: 'req-prompt-test',
|
|
661
|
+
toolName: 'bash',
|
|
662
|
+
input: { command: 'ls' },
|
|
663
|
+
riskLevel: 'high',
|
|
664
|
+
}];
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test('trusted_contact does NOT receive approval prompt UI (notifier only sends waiting message)', async () => {
|
|
668
|
+
// The startPendingApprovalPromptWatcher in inbound-message-handler.ts
|
|
669
|
+
// has a guard: isBoundGuardianActor check. Non-guardian actors (including
|
|
670
|
+
// trusted contacts) get () => {} (noop) for the watcher. Only guardian
|
|
671
|
+
// actors matching the binding receive the prompt.
|
|
672
|
+
|
|
673
|
+
const result = checkIsBoundGuardianActor({
|
|
674
|
+
guardianTrustClass: 'trusted_contact',
|
|
675
|
+
guardianExternalUserId: 'guardian-1',
|
|
676
|
+
requesterExternalUserId: 'requester-1',
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
expect(result).toBe(false);
|
|
680
|
+
// The prompt watcher would return a noop for trusted contacts
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('unknown actors do NOT receive approval prompt UI', () => {
|
|
684
|
+
const result = checkIsBoundGuardianActor({
|
|
685
|
+
guardianTrustClass: 'unknown',
|
|
686
|
+
guardianExternalUserId: 'guardian-1',
|
|
687
|
+
requesterExternalUserId: 'unknown-user',
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
expect(result).toBe(false);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test('guardian actor that matches binding DOES receive approval prompt UI', () => {
|
|
694
|
+
const result = checkIsBoundGuardianActor({
|
|
695
|
+
guardianTrustClass: 'guardian',
|
|
696
|
+
guardianExternalUserId: 'guardian-1',
|
|
697
|
+
requesterExternalUserId: 'guardian-1',
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
expect(result).toBe(true);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test('guardian actor with identity mismatch does NOT receive approval prompt UI', () => {
|
|
704
|
+
// After guardian rotation, old guardian identity should not receive prompts
|
|
705
|
+
const result = checkIsBoundGuardianActor({
|
|
706
|
+
guardianTrustClass: 'guardian',
|
|
707
|
+
guardianExternalUserId: 'new-guardian-2',
|
|
708
|
+
requesterExternalUserId: 'old-guardian-1',
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
expect(result).toBe(false);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// ===========================================================================
|
|
716
|
+
// f. Timeout/stale flow: guardian decision after prompt timeout
|
|
717
|
+
// ===========================================================================
|
|
718
|
+
|
|
719
|
+
describe('(f) timeout/stale flow: stale guardian decision after inline wait timeout', () => {
|
|
720
|
+
const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 100, intervalMs: 20 } });
|
|
721
|
+
|
|
722
|
+
beforeEach(() => {
|
|
723
|
+
resetTables();
|
|
724
|
+
events.length = 0;
|
|
725
|
+
emittedSignals.length = 0;
|
|
726
|
+
deliveredReplies.length = 0;
|
|
727
|
+
mockGuardianBinding = {
|
|
728
|
+
id: 'binding-1',
|
|
729
|
+
assistantId: 'self',
|
|
730
|
+
channel: 'telegram',
|
|
731
|
+
guardianExternalUserId: 'guardian-1',
|
|
732
|
+
guardianDeliveryChatId: 'guardian-chat-1',
|
|
733
|
+
status: 'active',
|
|
734
|
+
};
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('inline wait timeout clears followupState so later approval sends retry notification', async () => {
|
|
738
|
+
const toolName = 'bash';
|
|
739
|
+
const input = { command: 'echo stale' };
|
|
740
|
+
const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
|
|
741
|
+
|
|
742
|
+
// Let the tool invocation time out (no guardian approval within 100ms)
|
|
743
|
+
const result = await handler.checkPreExecutionGates(
|
|
744
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
expect(result.allowed).toBe(false);
|
|
748
|
+
if (result.allowed) return;
|
|
749
|
+
expect(result.result.content).toContain('guardian approval was not received in time');
|
|
750
|
+
|
|
751
|
+
// After timeout, the followupState should be cleared (null)
|
|
752
|
+
const pending = listCanonicalGuardianRequests({
|
|
753
|
+
kind: 'tool_grant_request',
|
|
754
|
+
status: 'pending',
|
|
755
|
+
});
|
|
756
|
+
expect(pending.length).toBe(1);
|
|
757
|
+
|
|
758
|
+
const freshReq = getCanonicalGuardianRequest(pending[0].id);
|
|
759
|
+
// followupState should be null after timeout — the inline wait cleared it
|
|
760
|
+
expect(freshReq?.followupState).toBeNull();
|
|
761
|
+
|
|
762
|
+
// Now simulate guardian approving after the timeout
|
|
763
|
+
const approvalResult = await applyCanonicalGuardianDecision({
|
|
764
|
+
requestId: pending[0].id,
|
|
765
|
+
action: 'approve_once',
|
|
766
|
+
actorContext: guardianActor(),
|
|
767
|
+
channelDeliveryContext: {
|
|
768
|
+
replyCallbackUrl: 'http://localhost:3000/reply',
|
|
769
|
+
guardianChatId: 'guardian-chat-1',
|
|
770
|
+
assistantId: 'self',
|
|
771
|
+
bearerToken: 'test-token',
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
expect(approvalResult.applied).toBe(true);
|
|
775
|
+
|
|
776
|
+
// The resolver should have sent the retry notification because
|
|
777
|
+
// followupState was cleared (not inline_wait_active)
|
|
778
|
+
const retryNotifications = deliveredReplies.filter(
|
|
779
|
+
(r) => typeof r.payload.text === 'string' && (r.payload.text as string).includes('approved'),
|
|
780
|
+
);
|
|
781
|
+
expect(retryNotifications.length).toBeGreaterThan(0);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test('inline_wait_active staleness guard: expired marker allows retry notification', async () => {
|
|
785
|
+
// Create a canonical request with a stale inline_wait_active marker
|
|
786
|
+
// that simulates a daemon crash during the wait.
|
|
787
|
+
const staleTimestamp = Date.now() - TC_GRANT_WAIT_MAX_MS - 60_000;
|
|
788
|
+
const req = createCanonicalGuardianRequest({
|
|
789
|
+
id: `req-stale-${Date.now()}`,
|
|
790
|
+
kind: 'tool_grant_request',
|
|
791
|
+
sourceType: 'channel',
|
|
792
|
+
sourceChannel: 'telegram',
|
|
793
|
+
conversationId: 'conv-stale-1',
|
|
794
|
+
requesterExternalUserId: 'requester-1',
|
|
795
|
+
requesterChatId: 'requester-chat-1',
|
|
796
|
+
guardianExternalUserId: 'guardian-1',
|
|
797
|
+
toolName: 'bash',
|
|
798
|
+
inputDigest: 'sha256:stale',
|
|
799
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// Set a stale inline_wait_active marker
|
|
803
|
+
updateCanonicalGuardianRequest(req.id, {
|
|
804
|
+
followupState: `inline_wait_active:${staleTimestamp}`,
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Verify marker is stale
|
|
808
|
+
const freshReq = getCanonicalGuardianRequest(req.id);
|
|
809
|
+
expect(freshReq?.followupState).toContain('inline_wait_active:');
|
|
810
|
+
|
|
811
|
+
// Guardian approves — the resolver should detect the stale marker
|
|
812
|
+
// and send the retry notification instead of suppressing it.
|
|
813
|
+
const approvalResult = await applyCanonicalGuardianDecision({
|
|
814
|
+
requestId: req.id,
|
|
815
|
+
action: 'approve_once',
|
|
816
|
+
actorContext: guardianActor(),
|
|
817
|
+
channelDeliveryContext: {
|
|
818
|
+
replyCallbackUrl: 'http://localhost:3000/reply',
|
|
819
|
+
guardianChatId: 'guardian-chat-1',
|
|
820
|
+
assistantId: 'self',
|
|
821
|
+
bearerToken: 'test-token',
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
expect(approvalResult.applied).toBe(true);
|
|
825
|
+
|
|
826
|
+
// The retry notification should have been sent (stale marker treated as cleared)
|
|
827
|
+
const retryNotifications = deliveredReplies.filter(
|
|
828
|
+
(r) => typeof r.payload.text === 'string' && (r.payload.text as string).includes('approved'),
|
|
829
|
+
);
|
|
830
|
+
expect(retryNotifications.length).toBeGreaterThan(0);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test('fresh inline_wait_active marker suppresses retry notification', async () => {
|
|
834
|
+
// Create a request with a FRESH inline_wait_active marker
|
|
835
|
+
const freshTimestamp = Date.now();
|
|
836
|
+
const req = createCanonicalGuardianRequest({
|
|
837
|
+
id: `req-fresh-${Date.now()}`,
|
|
838
|
+
kind: 'tool_grant_request',
|
|
839
|
+
sourceType: 'channel',
|
|
840
|
+
sourceChannel: 'telegram',
|
|
841
|
+
conversationId: 'conv-fresh-1',
|
|
842
|
+
requesterExternalUserId: 'requester-1',
|
|
843
|
+
requesterChatId: 'requester-chat-1',
|
|
844
|
+
guardianExternalUserId: 'guardian-1',
|
|
845
|
+
toolName: 'bash',
|
|
846
|
+
inputDigest: 'sha256:fresh',
|
|
847
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
updateCanonicalGuardianRequest(req.id, {
|
|
851
|
+
followupState: `inline_wait_active:${freshTimestamp}`,
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Guardian approves while an active inline waiter is running
|
|
855
|
+
deliveredReplies.length = 0;
|
|
856
|
+
const approvalResult = await applyCanonicalGuardianDecision({
|
|
857
|
+
requestId: req.id,
|
|
858
|
+
action: 'approve_once',
|
|
859
|
+
actorContext: guardianActor(),
|
|
860
|
+
channelDeliveryContext: {
|
|
861
|
+
replyCallbackUrl: 'http://localhost:3000/reply',
|
|
862
|
+
guardianChatId: 'guardian-chat-1',
|
|
863
|
+
assistantId: 'self',
|
|
864
|
+
bearerToken: 'test-token',
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
expect(approvalResult.applied).toBe(true);
|
|
868
|
+
|
|
869
|
+
// The retry notification should NOT have been sent — the inline waiter
|
|
870
|
+
// is still active and will consume the grant directly.
|
|
871
|
+
const retryNotifications = deliveredReplies.filter(
|
|
872
|
+
(r) => typeof r.payload.text === 'string' && (r.payload.text as string).includes('Please retry'),
|
|
873
|
+
);
|
|
874
|
+
expect(retryNotifications.length).toBe(0);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test('denied inline wait produces explicit denial (no false success)', async () => {
|
|
878
|
+
const toolName = 'bash';
|
|
879
|
+
const input = { command: 'rm -rf /' };
|
|
880
|
+
const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
|
|
881
|
+
|
|
882
|
+
// Schedule guardian rejection after 80ms
|
|
883
|
+
const rejectionPromise = (async () => {
|
|
884
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
885
|
+
const pending = listCanonicalGuardianRequests({
|
|
886
|
+
kind: 'tool_grant_request',
|
|
887
|
+
status: 'pending',
|
|
888
|
+
toolName: 'bash',
|
|
889
|
+
});
|
|
890
|
+
if (pending.length > 0) {
|
|
891
|
+
await applyCanonicalGuardianDecision({
|
|
892
|
+
requestId: pending[0].id,
|
|
893
|
+
action: 'reject',
|
|
894
|
+
actorContext: guardianActor(),
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
})();
|
|
898
|
+
|
|
899
|
+
const wideHandler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 } });
|
|
900
|
+
const result = await wideHandler.checkPreExecutionGates(
|
|
901
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
await rejectionPromise;
|
|
905
|
+
|
|
906
|
+
expect(result.allowed).toBe(false);
|
|
907
|
+
if (result.allowed) return;
|
|
908
|
+
expect(result.result.content).toContain('guardian rejected the request');
|
|
909
|
+
expect(result.result.isError).toBe(true);
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
test('timeout produces explicit timeout message (no false success)', async () => {
|
|
913
|
+
const toolName = 'bash';
|
|
914
|
+
const input = { command: 'curl example.com' };
|
|
915
|
+
const context = makeToolContext({ guardianTrustClass: 'trusted_contact' });
|
|
916
|
+
|
|
917
|
+
const result = await handler.checkPreExecutionGates(
|
|
918
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
expect(result.allowed).toBe(false);
|
|
922
|
+
if (result.allowed) return;
|
|
923
|
+
expect(result.result.content).toContain('guardian approval was not received in time');
|
|
924
|
+
expect(result.result.content).toContain('request code:');
|
|
925
|
+
expect(result.result.isError).toBe(true);
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
// ===========================================================================
|
|
930
|
+
// Cross-milestone integration checks
|
|
931
|
+
// ===========================================================================
|
|
932
|
+
|
|
933
|
+
describe('cross-milestone integration checks', () => {
|
|
934
|
+
beforeEach(() => {
|
|
935
|
+
resetTables();
|
|
936
|
+
events.length = 0;
|
|
937
|
+
emittedSignals.length = 0;
|
|
938
|
+
deliveredReplies.length = 0;
|
|
939
|
+
mockGuardianBinding = {
|
|
940
|
+
id: 'binding-1',
|
|
941
|
+
assistantId: 'self',
|
|
942
|
+
channel: 'telegram',
|
|
943
|
+
guardianExternalUserId: 'guardian-1',
|
|
944
|
+
guardianDeliveryChatId: 'guardian-chat-1',
|
|
945
|
+
status: 'active',
|
|
946
|
+
};
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test('M1+M4: routing state interactivity drives inline wait eligibility', async () => {
|
|
950
|
+
// With guardian binding: interactive + inline wait allowed
|
|
951
|
+
const withBinding: GuardianContext = {
|
|
952
|
+
trustClass: 'trusted_contact',
|
|
953
|
+
guardianExternalUserId: 'guardian-1',
|
|
954
|
+
};
|
|
955
|
+
expect(resolveRoutingState(withBinding).promptWaitingAllowed).toBe(true);
|
|
956
|
+
|
|
957
|
+
// Without guardian binding: not interactive + inline wait should not enter dead-end
|
|
958
|
+
const withoutBinding: GuardianContext = {
|
|
959
|
+
trustClass: 'trusted_contact',
|
|
960
|
+
};
|
|
961
|
+
expect(resolveRoutingState(withoutBinding).promptWaitingAllowed).toBe(false);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test('M2+M4: bridge and tool_grant_request target the same guardian identity', () => {
|
|
965
|
+
// Both the confirmation_request bridge (M2) and tool grant request escalation (M4)
|
|
966
|
+
// use the guardian binding's guardianExternalUserId to route notifications.
|
|
967
|
+
// Verify this consistency:
|
|
968
|
+
|
|
969
|
+
const canonicalRequest = createCanonicalGuardianRequest({
|
|
970
|
+
id: `req-consistency-${Date.now()}`,
|
|
971
|
+
kind: 'tool_approval',
|
|
972
|
+
sourceType: 'channel',
|
|
973
|
+
sourceChannel: 'telegram',
|
|
974
|
+
conversationId: 'conv-consistency',
|
|
975
|
+
requesterExternalUserId: 'requester-1',
|
|
976
|
+
guardianExternalUserId: 'guardian-1',
|
|
977
|
+
toolName: 'bash',
|
|
978
|
+
status: 'pending',
|
|
979
|
+
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
const guardianContext = makeTrustedContactGuardianContext();
|
|
983
|
+
|
|
984
|
+
const bridgeResult = bridgeConfirmationRequestToGuardian({
|
|
985
|
+
canonicalRequest,
|
|
986
|
+
guardianContext,
|
|
987
|
+
conversationId: 'conv-consistency',
|
|
988
|
+
toolName: 'bash',
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
expect('bridged' in bridgeResult && bridgeResult.bridged).toBe(true);
|
|
992
|
+
|
|
993
|
+
// Both the bridge signal and the tool_grant_request signal would target
|
|
994
|
+
// the same guardian binding (guardian-1)
|
|
995
|
+
if (emittedSignals.length > 0) {
|
|
996
|
+
const payload = emittedSignals[0].contextPayload as Record<string, unknown>;
|
|
997
|
+
expect(payload.requesterExternalUserId).toBe('requester-1');
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test('M4: tool_grant_request resolver is correctly registered', () => {
|
|
1002
|
+
const resolver = getResolver('tool_grant_request');
|
|
1003
|
+
expect(resolver).toBeDefined();
|
|
1004
|
+
expect(resolver!.kind).toBe('tool_grant_request');
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
test('M1: guardian actors bypass inline wait entirely (self-approve path)', async () => {
|
|
1008
|
+
const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 100, intervalMs: 20 } });
|
|
1009
|
+
const toolName = 'bash';
|
|
1010
|
+
const input = { command: 'ls' };
|
|
1011
|
+
const context = makeToolContext({
|
|
1012
|
+
guardianTrustClass: 'guardian',
|
|
1013
|
+
executionChannel: 'telegram',
|
|
1014
|
+
requesterExternalUserId: 'guardian-1',
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// Guardian actors resolve through the standard permission prompt path,
|
|
1018
|
+
// not the grant escalation path. The tool should be allowed without
|
|
1019
|
+
// going through grant consumption.
|
|
1020
|
+
const result = await handler.checkPreExecutionGates(
|
|
1021
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
// Guardian + no grant check = allowed without grantConsumed
|
|
1025
|
+
// (guardians use the interactive prompt, not the grant system)
|
|
1026
|
+
expect(result.allowed).toBe(true);
|
|
1027
|
+
if (!result.allowed) return;
|
|
1028
|
+
expect(result.grantConsumed).toBeUndefined();
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
test('M4: abort signal during inline wait clears followupState for later retries', async () => {
|
|
1032
|
+
const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 5_000, intervalMs: 20 } });
|
|
1033
|
+
const toolName = 'bash';
|
|
1034
|
+
const input = { command: 'aborted-command' };
|
|
1035
|
+
const controller = new AbortController();
|
|
1036
|
+
const context = makeToolContext({
|
|
1037
|
+
guardianTrustClass: 'trusted_contact',
|
|
1038
|
+
signal: controller.signal,
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// Abort after 100ms
|
|
1042
|
+
setTimeout(() => controller.abort(), 100);
|
|
1043
|
+
|
|
1044
|
+
const result = await handler.checkPreExecutionGates(
|
|
1045
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
expect(result.allowed).toBe(false);
|
|
1049
|
+
if (result.allowed) return;
|
|
1050
|
+
expect(result.result.content).toBe('Cancelled');
|
|
1051
|
+
|
|
1052
|
+
// The canonical request should exist but followupState should be cleared
|
|
1053
|
+
const pending = listCanonicalGuardianRequests({
|
|
1054
|
+
kind: 'tool_grant_request',
|
|
1055
|
+
status: 'pending',
|
|
1056
|
+
});
|
|
1057
|
+
expect(pending.length).toBe(1);
|
|
1058
|
+
|
|
1059
|
+
const freshReq = getCanonicalGuardianRequest(pending[0].id);
|
|
1060
|
+
// After abort, followupState should be cleared so a later guardian
|
|
1061
|
+
// approval sends the retry notification
|
|
1062
|
+
expect(freshReq?.followupState).toBeNull();
|
|
1063
|
+
});
|
|
1064
|
+
});
|