@vellumai/assistant 0.4.29 → 0.4.30
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/ARCHITECTURE.md +39 -37
- package/README.md +5 -6
- package/docs/runbook-trusted-contacts.md +79 -43
- package/package.json +1 -1
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -3
- package/scripts/test.sh +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -37
- package/src/__tests__/actor-token-service.test.ts +4 -3
- package/src/__tests__/app-executors.test.ts +7 -17
- package/src/__tests__/assistant-feature-flags-integration.test.ts +18 -10
- package/src/__tests__/browser-skill-endstate.test.ts +10 -1
- package/src/__tests__/bundled-skill-retrieval-guard.test.ts +1 -0
- package/src/__tests__/channel-approval-routes.test.ts +44 -44
- package/src/__tests__/channel-approval.test.ts +8 -0
- package/src/__tests__/channel-approvals.test.ts +39 -1
- package/src/__tests__/channel-guardian.test.ts +15 -5
- package/src/__tests__/channel-reply-delivery.test.ts +31 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +4 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +9 -0
- package/src/__tests__/gateway-only-guard.test.ts +1 -0
- package/src/__tests__/gemini-image-service.test.ts +2 -2
- package/src/__tests__/guardian-grant-minting.test.ts +6 -6
- package/src/__tests__/guardian-routing-invariants.test.ts +34 -11
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +4 -6
- package/src/__tests__/inbound-invite-redemption.test.ts +1 -1
- package/src/__tests__/integrations-cli.test.ts +3 -27
- package/src/__tests__/intent-routing.test.ts +3 -0
- package/src/__tests__/invite-redemption-service.test.ts +1 -1
- package/src/__tests__/{ingress-routes-http.test.ts → invite-routes-http.test.ts} +40 -320
- package/src/__tests__/ipc-snapshot.test.ts +4 -31
- package/src/__tests__/nl-approval-parser.test.ts +305 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +34 -0
- package/src/__tests__/provider-error-scenarios.test.ts +68 -0
- package/src/__tests__/relay-server.test.ts +1 -1
- package/src/__tests__/retry-after-extraction.test.ts +111 -0
- package/src/__tests__/script-proxy-profile-template-fallback.test.ts +127 -0
- package/src/__tests__/session-media-retry.test.ts +147 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +9 -5
- package/src/__tests__/skill-feature-flags.test.ts +18 -12
- package/src/__tests__/skill-load-feature-flag.test.ts +4 -3
- package/src/__tests__/slack-block-formatting.test.ts +100 -0
- package/src/__tests__/slack-inbound-verification.test.ts +346 -0
- package/src/__tests__/slack-reaction-approvals.test.ts +77 -0
- package/src/__tests__/slack-skill.test.ts +3 -2
- package/src/__tests__/starter-task-flow.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +3 -1
- package/src/__tests__/voice-invite-redemption.test.ts +1 -1
- package/src/amazon/client.ts +7 -24
- package/src/calls/relay-server.ts +39 -11
- package/src/channels/config.ts +1 -1
- package/src/cli/integrations.ts +10 -66
- package/src/config/bundled-skills/app-builder/SKILL.md +193 -1500
- package/src/config/bundled-skills/app-builder/TOOLS.json +70 -18
- package/src/config/bundled-skills/browser/TOOLS.json +59 -2
- package/src/config/bundled-skills/chatgpt-import/TOOLS.json +4 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +50 -2
- package/src/config/bundled-skills/contacts/SKILL.md +42 -35
- package/src/config/bundled-skills/contacts/TOOLS.json +22 -2
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +38 -58
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +11 -31
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +19 -37
- package/src/config/bundled-skills/document/TOOLS.json +8 -0
- package/src/config/bundled-skills/email-setup/SKILL.md +10 -7
- package/src/config/bundled-skills/followups/TOOLS.json +12 -0
- package/src/config/bundled-skills/google-calendar/TOOLS.json +124 -26
- package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +54 -21
- package/src/config/bundled-skills/image-studio/TOOLS.json +12 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +14 -8
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +13 -3
- package/src/config/bundled-skills/media-processing/SKILL.md +1 -1
- package/src/config/bundled-skills/media-processing/TOOLS.json +28 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +26 -6
- package/src/config/bundled-skills/messaging/TOOLS.json +228 -182
- package/src/config/bundled-skills/notifications/SKILL.md +3 -2
- package/src/config/bundled-skills/notifications/TOOLS.json +7 -13
- package/src/config/bundled-skills/phone-calls/TOOLS.json +13 -1
- package/src/config/bundled-skills/playbooks/TOOLS.json +16 -0
- package/src/config/bundled-skills/reminder/TOOLS.json +15 -2
- package/src/config/bundled-skills/schedule/SKILL.md +33 -15
- package/src/config/bundled-skills/schedule/TOOLS.json +17 -1
- package/src/config/bundled-skills/slack/SKILL.md +30 -1
- package/src/config/bundled-skills/slack/TOOLS.json +89 -2
- package/src/config/bundled-skills/slack/tools/slack-channel-permissions.ts +146 -0
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +120 -0
- package/src/config/bundled-skills/slack-app-setup/SKILL.md +200 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +22 -2
- package/src/config/bundled-skills/tasks/TOOLS.json +86 -14
- package/src/config/bundled-skills/transcribe/TOOLS.json +4 -0
- package/src/config/bundled-skills/watcher/TOOLS.json +20 -0
- package/src/config/bundled-skills/weather/TOOLS.json +4 -0
- package/src/config/bundled-tool-registry.ts +2 -0
- package/src/config/channel-permission-profiles.ts +155 -0
- package/src/config/env.ts +4 -1
- package/src/contacts/contact-store.ts +195 -4
- package/src/contacts/types.ts +26 -0
- package/src/daemon/assistant-attachments.ts +23 -3
- package/src/daemon/guardian-verification-intent.ts +7 -4
- package/src/daemon/handlers/apps.ts +1 -2
- package/src/daemon/handlers/config-inbox.ts +16 -134
- package/src/daemon/handlers/guardian-actions.ts +20 -87
- package/src/daemon/handlers/sessions.ts +0 -1
- package/src/daemon/ipc-contract/apps.ts +0 -1
- package/src/daemon/ipc-contract/inbox.ts +7 -66
- package/src/daemon/ipc-contract/sessions.ts +1 -0
- package/src/daemon/ipc-contract/surfaces.ts +0 -1
- package/src/daemon/ipc-contract-inventory.json +2 -4
- package/src/daemon/lifecycle.ts +14 -2
- package/src/daemon/session-agent-loop-handlers.ts +9 -0
- package/src/daemon/session-agent-loop.ts +1 -0
- package/src/daemon/session-attachments.ts +5 -1
- package/src/daemon/session-error.ts +18 -0
- package/src/daemon/session-lifecycle.ts +4 -5
- package/src/daemon/session-media-retry.ts +15 -1
- package/src/daemon/session-surfaces.ts +0 -1
- package/src/daemon/session-tool-setup.ts +7 -4
- package/src/events/domain-events.ts +2 -1
- package/src/home-base/prebuilt/seed.ts +0 -1
- package/src/influencer/client.ts +7 -24
- package/src/media/gemini-image-service.ts +48 -3
- package/src/memory/app-store.ts +0 -4
- package/src/memory/conversation-attention-store.ts +3 -1
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/133-assistant-contact-metadata.ts +21 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +12 -0
- package/src/memory/slack-thread-store.ts +187 -0
- package/src/messaging/providers/slack/client.ts +84 -26
- package/src/messaging/providers/slack/types.ts +4 -0
- package/src/notifications/adapters/slack.ts +90 -0
- package/src/notifications/destination-resolver.ts +42 -1
- package/src/notifications/emit-signal.ts +17 -1
- package/src/oauth/provider-profiles.ts +22 -0
- package/src/providers/anthropic/client.ts +3 -0
- package/src/providers/openai/client.ts +3 -0
- package/src/providers/retry.ts +9 -1
- package/src/runtime/actor-trust-resolver.ts +8 -0
- package/src/runtime/auth/require-bound-guardian.ts +44 -0
- package/src/runtime/auth/route-policy.ts +4 -8
- package/src/runtime/channel-approval-types.ts +18 -0
- package/src/runtime/channel-approvals.ts +8 -0
- package/src/runtime/channel-invite-transport.ts +1 -1
- package/src/runtime/channel-reply-delivery.ts +62 -3
- package/src/runtime/gateway-client.ts +36 -2
- package/src/runtime/gateway-internal-client.ts +86 -0
- package/src/runtime/guardian-action-service.ts +127 -0
- package/src/runtime/guardian-verification-templates.ts +16 -1
- package/src/runtime/http-server.ts +20 -49
- package/src/runtime/invite-redemption-service.ts +1 -1
- package/src/runtime/{ingress-service.ts → invite-service.ts} +5 -157
- package/src/runtime/nl-approval-parser.ts +138 -0
- package/src/runtime/routes/approval-routes.ts +1 -40
- package/src/runtime/routes/channel-route-shared.ts +35 -1
- package/src/runtime/routes/contact-routes.ts +196 -28
- package/src/runtime/routes/guardian-action-routes.ts +19 -111
- package/src/runtime/routes/guardian-approval-interception.ts +76 -0
- package/src/runtime/routes/inbound-message-handler.ts +40 -12
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +222 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +108 -0
- package/src/runtime/routes/{ingress-routes.ts → invite-routes.ts} +10 -110
- package/src/runtime/slack-block-formatting.ts +176 -0
- package/src/schedule/scheduler.ts +11 -2
- package/src/tools/apps/executors.ts +16 -15
- package/src/tools/calls/call-end.ts +1 -1
- package/src/tools/computer-use/definitions.ts +16 -0
- package/src/tools/credentials/vault.ts +86 -2
- package/src/tools/network/script-proxy/session-manager.ts +28 -3
- package/src/tools/permission-checker.ts +18 -0
- package/src/tools/terminal/shell.ts +15 -5
- package/src/tools/tool-approval-handler.ts +48 -4
- package/src/tools/types.ts +38 -1
- package/src/util/errors.ts +5 -1
- package/src/util/retry.ts +21 -0
- package/src/watcher/providers/slack.ts +33 -3
- /package/src/memory/{ingress-invite-store.ts → invite-store.ts} +0 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Slack inbound trusted contact verification.
|
|
3
|
+
*
|
|
4
|
+
* When an unknown Slack user messages the bot, the system should:
|
|
5
|
+
* 1. Create an outbound verification session bound to the user's identity
|
|
6
|
+
* 2. Send the verification code to the user's DM via the gateway
|
|
7
|
+
* 3. Reply in the original channel telling the user to check their DMs
|
|
8
|
+
* 4. Notify the guardian of the access attempt
|
|
9
|
+
* 5. When the user replies with the code in the DM, verify and activate
|
|
10
|
+
*/
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Test isolation: in-memory SQLite via temp directory
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const testDir = mkdtempSync(join(tmpdir(), "slack-inbound-verification-test-"));
|
|
21
|
+
|
|
22
|
+
mock.module("../util/platform.js", () => ({
|
|
23
|
+
getRootDir: () => testDir,
|
|
24
|
+
getDataDir: () => testDir,
|
|
25
|
+
isMacOS: () => process.platform === "darwin",
|
|
26
|
+
isLinux: () => process.platform === "linux",
|
|
27
|
+
isWindows: () => process.platform === "win32",
|
|
28
|
+
getSocketPath: () => join(testDir, "test.sock"),
|
|
29
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
30
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
31
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
32
|
+
ensureDataDir: () => {},
|
|
33
|
+
readHttpToken: () => "test-bearer-token",
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
mock.module("../util/logger.js", () => ({
|
|
37
|
+
getLogger: () =>
|
|
38
|
+
new Proxy({} as Record<string, unknown>, {
|
|
39
|
+
get: () => () => {},
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
mock.module("../security/secret-ingress.js", () => ({
|
|
44
|
+
checkIngressForSecrets: () => ({ blocked: false }),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
mock.module("../config/env.js", () => ({
|
|
48
|
+
isHttpAuthDisabled: () => true,
|
|
49
|
+
getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// Track emitNotificationSignal calls
|
|
53
|
+
const emitSignalCalls: Array<Record<string, unknown>> = [];
|
|
54
|
+
mock.module("../notifications/emit-signal.js", () => ({
|
|
55
|
+
emitNotificationSignal: async (params: Record<string, unknown>) => {
|
|
56
|
+
emitSignalCalls.push(params);
|
|
57
|
+
return {
|
|
58
|
+
signalId: "mock-signal-id",
|
|
59
|
+
deduplicated: false,
|
|
60
|
+
dispatched: true,
|
|
61
|
+
reason: "mock",
|
|
62
|
+
deliveryResults: [],
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// Track deliverChannelReply calls
|
|
68
|
+
const deliverReplyCalls: Array<{
|
|
69
|
+
url: string;
|
|
70
|
+
payload: Record<string, unknown>;
|
|
71
|
+
}> = [];
|
|
72
|
+
mock.module("../runtime/gateway-client.js", () => ({
|
|
73
|
+
deliverChannelReply: async (
|
|
74
|
+
url: string,
|
|
75
|
+
payload: Record<string, unknown>,
|
|
76
|
+
) => {
|
|
77
|
+
deliverReplyCalls.push({ url, payload });
|
|
78
|
+
},
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
import { createGuardianBinding } from "../contacts/contacts-write.js";
|
|
82
|
+
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
83
|
+
import { findActiveSession } from "../runtime/channel-guardian-service.js";
|
|
84
|
+
import { handleChannelInbound } from "../runtime/routes/channel-routes.js";
|
|
85
|
+
|
|
86
|
+
initializeDb();
|
|
87
|
+
|
|
88
|
+
afterAll(() => {
|
|
89
|
+
resetDb();
|
|
90
|
+
try {
|
|
91
|
+
rmSync(testDir, { recursive: true });
|
|
92
|
+
} catch {
|
|
93
|
+
/* best effort */
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Helpers
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const TEST_BEARER_TOKEN = "test-token";
|
|
102
|
+
|
|
103
|
+
function resetState(): void {
|
|
104
|
+
const db = getDb();
|
|
105
|
+
db.run("DELETE FROM channel_guardian_approval_requests");
|
|
106
|
+
db.run("DELETE FROM channel_guardian_verification_challenges");
|
|
107
|
+
db.run("DELETE FROM channel_guardian_rate_limits");
|
|
108
|
+
db.run("DELETE FROM channel_inbound_events");
|
|
109
|
+
db.run("DELETE FROM conversations");
|
|
110
|
+
db.run("DELETE FROM notification_events");
|
|
111
|
+
db.run("DELETE FROM canonical_guardian_requests");
|
|
112
|
+
db.run("DELETE FROM canonical_guardian_deliveries");
|
|
113
|
+
db.run("DELETE FROM contact_channels");
|
|
114
|
+
db.run("DELETE FROM contacts");
|
|
115
|
+
emitSignalCalls.length = 0;
|
|
116
|
+
deliverReplyCalls.length = 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildSlackInboundRequest(
|
|
120
|
+
overrides: Record<string, unknown> = {},
|
|
121
|
+
): Request {
|
|
122
|
+
const body: Record<string, unknown> = {
|
|
123
|
+
sourceChannel: "slack",
|
|
124
|
+
interface: "slack",
|
|
125
|
+
conversationExternalId: "C0123CHANNEL",
|
|
126
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()
|
|
127
|
+
.toString(36)
|
|
128
|
+
.slice(2, 8)}`,
|
|
129
|
+
content: "Hello, can I use this assistant?",
|
|
130
|
+
actorExternalId: "U0123UNKNOWN",
|
|
131
|
+
actorDisplayName: "Alice Unknown",
|
|
132
|
+
actorUsername: "alice_unknown",
|
|
133
|
+
replyCallbackUrl: "http://localhost:7830/deliver/slack",
|
|
134
|
+
...overrides,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return new Request("http://localhost:8080/channels/inbound", {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: {
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
"X-Gateway-Origin": TEST_BEARER_TOKEN,
|
|
142
|
+
},
|
|
143
|
+
body: JSON.stringify(body),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Tests
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe("Slack inbound trusted contact verification", () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
resetState();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("unknown Slack user receives verification challenge via DM", async () => {
|
|
157
|
+
const req = buildSlackInboundRequest();
|
|
158
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
159
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
160
|
+
|
|
161
|
+
expect(json.denied).toBe(true);
|
|
162
|
+
expect(json.reason).toBe("verification_challenge_sent");
|
|
163
|
+
expect(json.verificationSessionId).toBeDefined();
|
|
164
|
+
|
|
165
|
+
// Verification code is NOT sent to the requester — only the guardian
|
|
166
|
+
// receives it via the access request notification flow
|
|
167
|
+
|
|
168
|
+
// Channel reply tells user the owner has been notified
|
|
169
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
170
|
+
expect(
|
|
171
|
+
(deliverReplyCalls[0].payload as Record<string, unknown>).text,
|
|
172
|
+
).toContain("notified the owner");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("verification session is identity-bound to the Slack user", async () => {
|
|
176
|
+
const req = buildSlackInboundRequest();
|
|
177
|
+
await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
178
|
+
|
|
179
|
+
// An active outbound session should exist for the slack channel
|
|
180
|
+
const session = findActiveSession("self", "slack");
|
|
181
|
+
expect(session).not.toBeNull();
|
|
182
|
+
expect(session!.expectedExternalUserId).toBe("U0123UNKNOWN");
|
|
183
|
+
expect(session!.expectedChatId).toBe("U0123UNKNOWN");
|
|
184
|
+
expect(session!.identityBindingStatus).toBe("bound");
|
|
185
|
+
expect(session!.verificationPurpose).toBe("trusted_contact");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("guardian is notified of the access attempt alongside verification", async () => {
|
|
189
|
+
// Set up a guardian binding so the notification can target it
|
|
190
|
+
createGuardianBinding({
|
|
191
|
+
assistantId: "self",
|
|
192
|
+
channel: "slack",
|
|
193
|
+
guardianExternalUserId: "U_GUARDIAN",
|
|
194
|
+
guardianDeliveryChatId: "D_GUARDIAN_DM",
|
|
195
|
+
guardianPrincipalId: "guardian-principal",
|
|
196
|
+
verifiedVia: "test",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const req = buildSlackInboundRequest();
|
|
200
|
+
await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
201
|
+
|
|
202
|
+
// Guardian should have been notified
|
|
203
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
204
|
+
expect(emitSignalCalls[0].sourceEventName).toBe("ingress.access_request");
|
|
205
|
+
expect(emitSignalCalls[0].sourceChannel).toBe("slack");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("duplicate challenge is not sent when session already exists", async () => {
|
|
209
|
+
// First message creates the session
|
|
210
|
+
const req1 = buildSlackInboundRequest();
|
|
211
|
+
const resp1 = await handleChannelInbound(
|
|
212
|
+
req1,
|
|
213
|
+
undefined,
|
|
214
|
+
TEST_BEARER_TOKEN,
|
|
215
|
+
);
|
|
216
|
+
const json1 = (await resp1.json()) as Record<string, unknown>;
|
|
217
|
+
expect(json1.reason).toBe("verification_challenge_sent");
|
|
218
|
+
|
|
219
|
+
// Second message from the same user — session already exists, so
|
|
220
|
+
// falls through to standard deny path
|
|
221
|
+
const req2 = buildSlackInboundRequest({
|
|
222
|
+
externalMessageId: `msg-${Date.now()}-second`,
|
|
223
|
+
});
|
|
224
|
+
const resp2 = await handleChannelInbound(
|
|
225
|
+
req2,
|
|
226
|
+
undefined,
|
|
227
|
+
TEST_BEARER_TOKEN,
|
|
228
|
+
);
|
|
229
|
+
const json2 = (await resp2.json()) as Record<string, unknown>;
|
|
230
|
+
expect(json2.denied).toBe(true);
|
|
231
|
+
expect(json2.reason).toBe("not_a_member");
|
|
232
|
+
|
|
233
|
+
// No DM was sent at all
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("different Slack user is not suppressed by existing session for another user", async () => {
|
|
237
|
+
// First message from user A creates a session
|
|
238
|
+
const req1 = buildSlackInboundRequest({
|
|
239
|
+
actorExternalId: "U_USER_A",
|
|
240
|
+
actorDisplayName: "User A",
|
|
241
|
+
});
|
|
242
|
+
const resp1 = await handleChannelInbound(
|
|
243
|
+
req1,
|
|
244
|
+
undefined,
|
|
245
|
+
TEST_BEARER_TOKEN,
|
|
246
|
+
);
|
|
247
|
+
const json1 = (await resp1.json()) as Record<string, unknown>;
|
|
248
|
+
expect(json1.reason).toBe("verification_challenge_sent");
|
|
249
|
+
|
|
250
|
+
// Second message from user B — should get their own challenge
|
|
251
|
+
const req2 = buildSlackInboundRequest({
|
|
252
|
+
actorExternalId: "U_USER_B",
|
|
253
|
+
actorDisplayName: "User B",
|
|
254
|
+
externalMessageId: `msg-${Date.now()}-user-b`,
|
|
255
|
+
});
|
|
256
|
+
const resp2 = await handleChannelInbound(
|
|
257
|
+
req2,
|
|
258
|
+
undefined,
|
|
259
|
+
TEST_BEARER_TOKEN,
|
|
260
|
+
);
|
|
261
|
+
const json2 = (await resp2.json()) as Record<string, unknown>;
|
|
262
|
+
expect(json2.reason).toBe("verification_challenge_sent");
|
|
263
|
+
expect(json2.verificationSessionId).toBeDefined();
|
|
264
|
+
|
|
265
|
+
// No DMs sent to requesters — guardian gets code via notification flow
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("non-Slack channels still use standard access request flow", async () => {
|
|
269
|
+
const req = buildSlackInboundRequest({
|
|
270
|
+
sourceChannel: "telegram",
|
|
271
|
+
interface: "telegram",
|
|
272
|
+
replyCallbackUrl: "http://localhost:7830/deliver/telegram",
|
|
273
|
+
});
|
|
274
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
275
|
+
const json = (await resp.json()) as Record<string, unknown>;
|
|
276
|
+
|
|
277
|
+
// Standard deny path — no verification challenge
|
|
278
|
+
expect(json.denied).toBe(true);
|
|
279
|
+
expect(json.reason).toBe("not_a_member");
|
|
280
|
+
|
|
281
|
+
// No Slack DM was sent
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("user can verify by replying with the code in the DM", async () => {
|
|
285
|
+
// Step 1: Unknown user sends a message, gets verification challenge
|
|
286
|
+
const req = buildSlackInboundRequest();
|
|
287
|
+
await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
288
|
+
|
|
289
|
+
const session = findActiveSession("self", "slack");
|
|
290
|
+
expect(session).not.toBeNull();
|
|
291
|
+
|
|
292
|
+
// The challenge hash is stored in the session — extract the secret
|
|
293
|
+
// from the DM text sent to the user. The code is embedded in the
|
|
294
|
+
// template text. Since we're using a 6-digit code for identity-bound
|
|
295
|
+
// sessions, extract it from the session's challengeHash by consuming
|
|
296
|
+
// the challenge directly.
|
|
297
|
+
// The session was created with createOutboundSession which generates
|
|
298
|
+
// a 6-digit code. We can validate by calling validateAndConsumeChallenge
|
|
299
|
+
// with the correct secret. Since the mock captures the DM text, we
|
|
300
|
+
// can extract the code indirectly. But for testing, we just verify
|
|
301
|
+
// the session properties and that validateAndConsumeChallenge works
|
|
302
|
+
// with the correct identity.
|
|
303
|
+
|
|
304
|
+
// The actual secret was sent in the DM. For this test, let's use the
|
|
305
|
+
// session directly via the channel-guardian-service to verify the
|
|
306
|
+
// consume path works.
|
|
307
|
+
// The DM text contains the verification code implicitly (it's in the
|
|
308
|
+
// template message). Since we need to test the full round-trip, let's
|
|
309
|
+
// verify via the inbound handler by sending the code as a message.
|
|
310
|
+
|
|
311
|
+
// Extract the session's challenge hash and verify that submitting the
|
|
312
|
+
// correct code works. We create a fresh session with a known secret for
|
|
313
|
+
// this part of the test.
|
|
314
|
+
resetState();
|
|
315
|
+
|
|
316
|
+
// Create a verification session manually to test the consume path
|
|
317
|
+
const { createOutboundSession } =
|
|
318
|
+
await import("../runtime/channel-guardian-service.js");
|
|
319
|
+
|
|
320
|
+
const outboundSession = createOutboundSession({
|
|
321
|
+
assistantId: "self",
|
|
322
|
+
channel: "slack",
|
|
323
|
+
expectedExternalUserId: "U0123UNKNOWN",
|
|
324
|
+
expectedChatId: "U0123UNKNOWN",
|
|
325
|
+
identityBindingStatus: "bound",
|
|
326
|
+
destinationAddress: "U0123UNKNOWN",
|
|
327
|
+
verificationPurpose: "trusted_contact",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// User replies with the code in the DM
|
|
331
|
+
const verifyReq = buildSlackInboundRequest({
|
|
332
|
+
conversationExternalId: "U0123UNKNOWN",
|
|
333
|
+
content: outboundSession.secret,
|
|
334
|
+
externalMessageId: `msg-verify-${Date.now()}`,
|
|
335
|
+
});
|
|
336
|
+
const verifyResp = await handleChannelInbound(
|
|
337
|
+
verifyReq,
|
|
338
|
+
undefined,
|
|
339
|
+
TEST_BEARER_TOKEN,
|
|
340
|
+
);
|
|
341
|
+
const verifyJson = (await verifyResp.json()) as Record<string, unknown>;
|
|
342
|
+
|
|
343
|
+
expect(verifyJson.accepted).toBe(true);
|
|
344
|
+
expect(verifyJson.guardianVerification).toBe("verified");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { parseReactionCallbackData } from "../runtime/routes/channel-route-shared.js";
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// parseReactionCallbackData
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
describe("parseReactionCallbackData", () => {
|
|
10
|
+
test("maps +1 emoji to approve_once", () => {
|
|
11
|
+
const result = parseReactionCallbackData("reaction:+1");
|
|
12
|
+
expect(result).toEqual({
|
|
13
|
+
action: "approve_once",
|
|
14
|
+
source: "slack_reaction",
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("maps thumbsup emoji to approve_once", () => {
|
|
19
|
+
const result = parseReactionCallbackData("reaction:thumbsup");
|
|
20
|
+
expect(result).toEqual({
|
|
21
|
+
action: "approve_once",
|
|
22
|
+
source: "slack_reaction",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("maps -1 emoji to reject", () => {
|
|
27
|
+
const result = parseReactionCallbackData("reaction:-1");
|
|
28
|
+
expect(result).toEqual({
|
|
29
|
+
action: "reject",
|
|
30
|
+
source: "slack_reaction",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("maps thumbsdown emoji to reject", () => {
|
|
35
|
+
const result = parseReactionCallbackData("reaction:thumbsdown");
|
|
36
|
+
expect(result).toEqual({
|
|
37
|
+
action: "reject",
|
|
38
|
+
source: "slack_reaction",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("maps alarm_clock emoji to approve_10m", () => {
|
|
43
|
+
const result = parseReactionCallbackData("reaction:alarm_clock");
|
|
44
|
+
expect(result).toEqual({
|
|
45
|
+
action: "approve_10m",
|
|
46
|
+
source: "slack_reaction",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("maps white_check_mark emoji to approve_always", () => {
|
|
51
|
+
const result = parseReactionCallbackData("reaction:white_check_mark");
|
|
52
|
+
expect(result).toEqual({
|
|
53
|
+
action: "approve_always",
|
|
54
|
+
source: "slack_reaction",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns null for unknown emoji", () => {
|
|
59
|
+
const result = parseReactionCallbackData("reaction:tada");
|
|
60
|
+
expect(result).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns null for empty emoji name", () => {
|
|
64
|
+
const result = parseReactionCallbackData("reaction:");
|
|
65
|
+
expect(result).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns null for non-reaction callback data", () => {
|
|
69
|
+
const result = parseReactionCallbackData("apr:req-1:approve_once");
|
|
70
|
+
expect(result).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns null for plain text", () => {
|
|
74
|
+
const result = parseReactionCallbackData("yes");
|
|
75
|
+
expect(result).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -64,10 +64,11 @@ describe("slack skill TOOLS.json", () => {
|
|
|
64
64
|
expect(names).toContain("slack_edit_message");
|
|
65
65
|
expect(names).toContain("slack_delete_message");
|
|
66
66
|
expect(names).toContain("slack_leave_channel");
|
|
67
|
+
expect(names).toContain("slack_channel_permissions");
|
|
67
68
|
});
|
|
68
69
|
|
|
69
|
-
test("has
|
|
70
|
-
expect(toolsJson.tools.length).toBe(
|
|
70
|
+
test("has 8 tools total", () => {
|
|
71
|
+
expect(toolsJson.tools.length).toBe(8);
|
|
71
72
|
});
|
|
72
73
|
|
|
73
74
|
test("all tools have required fields", () => {
|
|
@@ -449,7 +449,9 @@ describe("trusted contact verification → member activation", () => {
|
|
|
449
449
|
);
|
|
450
450
|
|
|
451
451
|
expect(result.success).toBe(true);
|
|
452
|
-
|
|
452
|
+
if (result.success) {
|
|
453
|
+
expect(result.verificationType).toBe("guardian");
|
|
454
|
+
}
|
|
453
455
|
|
|
454
456
|
const guardianResult = findGuardianForChannel("telegram", "self");
|
|
455
457
|
expect(guardianResult).toBeNull();
|
|
@@ -26,7 +26,7 @@ mock.module("../util/logger.js", () => ({
|
|
|
26
26
|
|
|
27
27
|
import { upsertMember } from "../contacts/contacts-write.js";
|
|
28
28
|
import { getSqlite, initializeDb, resetDb } from "../memory/db.js";
|
|
29
|
-
import { createInvite, revokeInvite } from "../memory/
|
|
29
|
+
import { createInvite, revokeInvite } from "../memory/invite-store.js";
|
|
30
30
|
import { redeemVoiceInviteCode } from "../runtime/invite-redemption-service.js";
|
|
31
31
|
import { generateVoiceCode, hashVoiceCode } from "../util/voice-code.js";
|
|
32
32
|
|
package/src/amazon/client.ts
CHANGED
|
@@ -53,11 +53,8 @@ import type {
|
|
|
53
53
|
ExtensionResponse,
|
|
54
54
|
} from "../browser-extension-relay/protocol.js";
|
|
55
55
|
import { extensionRelayServer } from "../browser-extension-relay/server.js";
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
isSigningKeyInitialized,
|
|
59
|
-
mintEdgeRelayToken,
|
|
60
|
-
} from "../runtime/auth/token-service.js";
|
|
56
|
+
import { isSigningKeyInitialized } from "../runtime/auth/token-service.js";
|
|
57
|
+
import { gatewayPost } from "../runtime/gateway-internal-client.js";
|
|
61
58
|
import type { ExtractedCredential } from "../tools/browser/network-recording-types.js";
|
|
62
59
|
import { type AmazonSession, loadSession } from "./session.js";
|
|
63
60
|
|
|
@@ -91,26 +88,12 @@ export async function sendRelayCommand(
|
|
|
91
88
|
"Auth signing key not initialized — browser-relay commands require the daemon to be running",
|
|
92
89
|
);
|
|
93
90
|
}
|
|
94
|
-
const token = mintEdgeRelayToken();
|
|
95
|
-
|
|
96
|
-
const resp = await fetch(
|
|
97
|
-
`${getGatewayInternalBaseUrl()}/v1/browser-relay/command`,
|
|
98
|
-
{
|
|
99
|
-
method: "POST",
|
|
100
|
-
headers: {
|
|
101
|
-
"Content-Type": "application/json",
|
|
102
|
-
Authorization: `Bearer ${token}`,
|
|
103
|
-
},
|
|
104
|
-
body: JSON.stringify(command),
|
|
105
|
-
},
|
|
106
|
-
);
|
|
107
91
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return (await resp.json()) as ExtensionResponse;
|
|
92
|
+
const { data } = await gatewayPost<ExtensionResponse>(
|
|
93
|
+
"/v1/browser-relay/command",
|
|
94
|
+
command,
|
|
95
|
+
);
|
|
96
|
+
return data;
|
|
114
97
|
}
|
|
115
98
|
|
|
116
99
|
/** Thrown when the session is missing or expired. The CLI handles this specially. */
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
import { getAssistantName } from "../daemon/identity-helpers.js";
|
|
26
26
|
import { getCanonicalGuardianRequest } from "../memory/canonical-guardian-store.js";
|
|
27
27
|
import * as conversationStore from "../memory/conversation-store.js";
|
|
28
|
-
import { findActiveVoiceInvites } from "../memory/
|
|
28
|
+
import { findActiveVoiceInvites } from "../memory/invite-store.js";
|
|
29
29
|
import { revokeScopedApprovalGrantsForContext } from "../memory/scoped-approval-grants.js";
|
|
30
30
|
import { emitNotificationSignal } from "../notifications/emit-signal.js";
|
|
31
31
|
import { notifyGuardianOfAccessRequest } from "../runtime/access-request-helper.js";
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
composeVerificationVoice,
|
|
44
44
|
GUARDIAN_VERIFY_TEMPLATE_KEYS,
|
|
45
45
|
} from "../runtime/guardian-verification-templates.js";
|
|
46
|
-
import { redeemVoiceInviteCode } from "../runtime/
|
|
46
|
+
import { redeemVoiceInviteCode } from "../runtime/invite-service.js";
|
|
47
47
|
import { parseJsonSafe } from "../util/json.js";
|
|
48
48
|
import { getLogger } from "../util/logger.js";
|
|
49
49
|
import {
|
|
@@ -1213,15 +1213,43 @@ export class RelayConnection {
|
|
|
1213
1213
|
|
|
1214
1214
|
// Create the guardian binding now that verification succeeded.
|
|
1215
1215
|
if (result.verificationType === "guardian") {
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1216
|
+
const existingBinding = getGuardianBinding(
|
|
1217
|
+
this.guardianChallengeAssistantId,
|
|
1218
|
+
"voice",
|
|
1219
|
+
);
|
|
1220
|
+
if (
|
|
1221
|
+
existingBinding &&
|
|
1222
|
+
existingBinding.guardianExternalUserId !==
|
|
1223
|
+
this.guardianVerificationFromNumber
|
|
1224
|
+
) {
|
|
1225
|
+
log.warn(
|
|
1226
|
+
{
|
|
1227
|
+
callSessionId: this.callSessionId,
|
|
1228
|
+
existingGuardian: existingBinding.guardianExternalUserId,
|
|
1229
|
+
},
|
|
1230
|
+
"Guardian binding conflict: another user already holds the voice binding",
|
|
1231
|
+
);
|
|
1232
|
+
} else {
|
|
1233
|
+
revokeGuardianBinding(this.guardianChallengeAssistantId, "voice");
|
|
1234
|
+
|
|
1235
|
+
// Unify all channel bindings onto the canonical (vellum) principal
|
|
1236
|
+
const vellumBinding = getGuardianBinding(
|
|
1237
|
+
this.guardianChallengeAssistantId,
|
|
1238
|
+
"vellum",
|
|
1239
|
+
);
|
|
1240
|
+
const canonicalPrincipal =
|
|
1241
|
+
vellumBinding?.guardianPrincipalId ??
|
|
1242
|
+
this.guardianVerificationFromNumber;
|
|
1243
|
+
|
|
1244
|
+
createGuardianBinding({
|
|
1245
|
+
assistantId: this.guardianChallengeAssistantId,
|
|
1246
|
+
channel: "voice",
|
|
1247
|
+
guardianExternalUserId: this.guardianVerificationFromNumber,
|
|
1248
|
+
guardianDeliveryChatId: this.guardianVerificationFromNumber,
|
|
1249
|
+
guardianPrincipalId: canonicalPrincipal,
|
|
1250
|
+
verifiedVia: "challenge",
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1225
1253
|
}
|
|
1226
1254
|
|
|
1227
1255
|
if (isOutbound) {
|