@vellumai/assistant 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +84 -7
- package/bun.lock +0 -83
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +2 -3
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/channel-approval-routes.test.ts +55 -5
- package/src/__tests__/channel-guardian.test.ts +6 -5
- package/src/__tests__/config-schema.test.ts +2 -0
- package/src/__tests__/daemon-server-session-init.test.ts +54 -1
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
- package/src/__tests__/guardian-outbound-http.test.ts +0 -1
- package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/non-member-access-request.test.ts +28 -1
- package/src/__tests__/notification-decision-strategy.test.ts +44 -0
- package/src/__tests__/relay-server.test.ts +644 -4
- package/src/__tests__/send-endpoint-busy.test.ts +129 -3
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +43 -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-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/approvals/guardian-decision-primitive.ts +24 -2
- package/src/approvals/guardian-request-resolvers.ts +42 -3
- package/src/calls/call-constants.ts +8 -0
- package/src/calls/call-controller.ts +2 -1
- package/src/calls/call-domain.ts +5 -4
- package/src/calls/relay-server.ts +513 -116
- package/src/calls/twilio-routes.ts +3 -5
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
- package/src/config/calls-schema.ts +12 -0
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
- package/src/daemon/handlers/config-channels.ts +5 -7
- package/src/daemon/handlers/config-inbox.ts +2 -0
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +136 -13
- 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 +3 -1
- package/src/daemon/server.ts +19 -3
- package/src/daemon/session-agent-loop.ts +35 -23
- package/src/daemon/session-runtime-assembly.ts +3 -1
- package/src/daemon/session-surfaces.ts +29 -1
- package/src/memory/app-store.ts +6 -0
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/delivery-crud.ts +2 -1
- package/src/memory/embedding-local.ts +25 -13
- package/src/memory/embedding-runtime-manager.ts +24 -6
- 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/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +10 -5
- package/src/notifications/copy-composer.ts +11 -1
- package/src/notifications/emit-signal.ts +2 -1
- package/src/runtime/access-request-helper.ts +11 -3
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/guardian-context-resolver.ts +5 -1
- package/src/runtime/guardian-outbound-actions.ts +5 -4
- package/src/runtime/guardian-reply-router.ts +12 -0
- package/src/runtime/http-server.ts +12 -20
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +2 -1
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/call-routes.ts +2 -1
- 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 +33 -11
- package/src/runtime/routes/events-routes.ts +2 -3
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +16 -4
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/tools/apps/executors.ts +15 -0
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +2 -1
- package/src/workspace/git-service.ts +19 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Guard tests for the assistant identity boundary.
|
|
11
|
+
*
|
|
12
|
+
* The daemon uses a fixed internal scope constant (`DAEMON_INTERNAL_ASSISTANT_ID`)
|
|
13
|
+
* for all assistant-scoped storage. Public assistant IDs are an edge concern
|
|
14
|
+
* handled by the gateway/platform layer — they must not leak into daemon
|
|
15
|
+
* scoping logic.
|
|
16
|
+
*
|
|
17
|
+
* These tests prevent regressions by scanning source files for banned patterns:
|
|
18
|
+
* - No `normalizeAssistantId` usage in daemon/runtime scoping modules
|
|
19
|
+
* - No assistant-scoped route handlers in the daemon HTTP server
|
|
20
|
+
* - No hardcoded `'self'` string for assistant scoping (use the constant)
|
|
21
|
+
* - The constant itself equals `'self'`
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** Resolve repo root (tests run from assistant/). */
|
|
29
|
+
function getRepoRoot(): string {
|
|
30
|
+
return join(process.cwd(), '..');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Directories containing daemon/runtime source files that must not reference
|
|
35
|
+
* `normalizeAssistantId` or hardcode assistant scope strings.
|
|
36
|
+
*
|
|
37
|
+
* Each directory gets both a `*.ts` glob (top-level files) and a `**\/*.ts`
|
|
38
|
+
* glob (nested files) so that `git grep` matches at all directory depths.
|
|
39
|
+
*/
|
|
40
|
+
const SCANNED_DIRS = [
|
|
41
|
+
'assistant/src/runtime',
|
|
42
|
+
'assistant/src/daemon',
|
|
43
|
+
'assistant/src/memory',
|
|
44
|
+
'assistant/src/approvals',
|
|
45
|
+
'assistant/src/calls',
|
|
46
|
+
'assistant/src/tools',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const SCANNED_DIR_GLOBS = SCANNED_DIRS.flatMap((dir) => [`${dir}/*.ts`, `${dir}/**/*.ts`]);
|
|
50
|
+
|
|
51
|
+
function isTestFile(filePath: string): boolean {
|
|
52
|
+
return (
|
|
53
|
+
filePath.includes('/__tests__/') ||
|
|
54
|
+
filePath.endsWith('.test.ts') ||
|
|
55
|
+
filePath.endsWith('.test.js') ||
|
|
56
|
+
filePath.endsWith('.spec.ts') ||
|
|
57
|
+
filePath.endsWith('.spec.js')
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isMigrationFile(filePath: string): boolean {
|
|
62
|
+
return filePath.includes('/migrations/');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Tests
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('assistant ID boundary', () => {
|
|
70
|
+
// -------------------------------------------------------------------------
|
|
71
|
+
// Rule (d): The DAEMON_INTERNAL_ASSISTANT_ID constant equals 'self'
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
test('DAEMON_INTERNAL_ASSISTANT_ID equals "self"', () => {
|
|
75
|
+
expect(DAEMON_INTERNAL_ASSISTANT_ID).toBe('self');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// -------------------------------------------------------------------------
|
|
79
|
+
// Rule (a): No normalizeAssistantId in daemon scoping paths — spot check
|
|
80
|
+
// -------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
test('no normalizeAssistantId imports in daemon scoping paths', () => {
|
|
83
|
+
// Key daemon/runtime files that previously used normalizeAssistantId
|
|
84
|
+
// should now use DAEMON_INTERNAL_ASSISTANT_ID instead.
|
|
85
|
+
const daemonScopingFiles = [
|
|
86
|
+
'runtime/actor-trust-resolver.ts',
|
|
87
|
+
'runtime/guardian-outbound-actions.ts',
|
|
88
|
+
'daemon/handlers/config-channels.ts',
|
|
89
|
+
'runtime/routes/channel-route-shared.ts',
|
|
90
|
+
'calls/relay-server.ts',
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const srcDir = join(import.meta.dir, '..');
|
|
94
|
+
for (const relPath of daemonScopingFiles) {
|
|
95
|
+
const content = readFileSync(join(srcDir, relPath), 'utf-8');
|
|
96
|
+
expect(content).not.toContain("import { normalizeAssistantId }");
|
|
97
|
+
expect(content).not.toContain("import { normalizeAssistantId,");
|
|
98
|
+
expect(content).not.toContain("normalizeAssistantId(");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
// Rule (a): No normalizeAssistantId in daemon/runtime directories — broad scan
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
test('no normalizeAssistantId usage across daemon/runtime source directories', () => {
|
|
107
|
+
const repoRoot = getRepoRoot();
|
|
108
|
+
|
|
109
|
+
// Scan all daemon/runtime source directories for any reference to
|
|
110
|
+
// normalizeAssistantId. The function is defined in util/platform.ts for
|
|
111
|
+
// gateway use — it must not appear in daemon scoping modules.
|
|
112
|
+
let grepOutput = '';
|
|
113
|
+
try {
|
|
114
|
+
grepOutput = execFileSync(
|
|
115
|
+
'git',
|
|
116
|
+
['grep', '-lE', 'normalizeAssistantId', '--', ...SCANNED_DIR_GLOBS],
|
|
117
|
+
{ encoding: 'utf-8', cwd: repoRoot },
|
|
118
|
+
).trim();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Exit code 1 means no matches — happy path
|
|
121
|
+
if ((err as { status?: number }).status === 1) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const files = grepOutput.split('\n').filter((f) => f.length > 0);
|
|
128
|
+
const violations = files.filter((f) => !isTestFile(f));
|
|
129
|
+
|
|
130
|
+
if (violations.length > 0) {
|
|
131
|
+
const message = [
|
|
132
|
+
'Found daemon/runtime source files that reference `normalizeAssistantId`.',
|
|
133
|
+
'Daemon code should use the `DAEMON_INTERNAL_ASSISTANT_ID` constant instead.',
|
|
134
|
+
'The `normalizeAssistantId` function is for gateway/platform use only (defined in util/platform.ts).',
|
|
135
|
+
'',
|
|
136
|
+
'Violations:',
|
|
137
|
+
...violations.map((f) => ` - ${f}`),
|
|
138
|
+
].join('\n');
|
|
139
|
+
|
|
140
|
+
expect(violations, message).toEqual([]);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
// Rule (b): No assistant-scoped route registration in daemon HTTP server
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
test('no /v1/assistants/:assistantId/ route handler registration in daemon HTTP server', () => {
|
|
149
|
+
const httpServerPath = join(import.meta.dir, '..', 'runtime', 'http-server.ts');
|
|
150
|
+
const content = readFileSync(httpServerPath, 'utf-8');
|
|
151
|
+
|
|
152
|
+
// The daemon HTTP server must not contain any assistant-scoped route
|
|
153
|
+
// patterns. All routes use flat /v1/<endpoint> paths; the gateway handles
|
|
154
|
+
// legacy assistant-scoped URL rewriting in its runtime proxy layer.
|
|
155
|
+
|
|
156
|
+
// Check that there's no regex extracting assistantId from a /v1/assistants/ path.
|
|
157
|
+
// Match both literal slashes (/v1/assistants/([) and escaped slashes in regex
|
|
158
|
+
// literals (\/v1\/assistants\/([) so we catch patterns like:
|
|
159
|
+
// endpoint.match(/^\/v1\/assistants\/([^/]+)\/(.+)$/)
|
|
160
|
+
const routeHandlerRegex = /\\?\/v1\\?\/assistants\\?\/\(\[/;
|
|
161
|
+
const match = content.match(routeHandlerRegex);
|
|
162
|
+
expect(
|
|
163
|
+
match,
|
|
164
|
+
'Found a route pattern matching /v1/assistants/([^/]+)/... that extracts an assistantId. ' +
|
|
165
|
+
'The daemon HTTP server should not have assistant-scoped route handlers — ' +
|
|
166
|
+
'use flat /v1/<endpoint> paths instead.',
|
|
167
|
+
).toBeNull();
|
|
168
|
+
|
|
169
|
+
// Scan the entire file for assistant-scoped path literals. No references
|
|
170
|
+
// to /v1/assistants/ should exist — the daemon uses flat paths only.
|
|
171
|
+
const lines = content.split('\n');
|
|
172
|
+
const violations: string[] = [];
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < lines.length; i++) {
|
|
175
|
+
const line = lines[i];
|
|
176
|
+
// Match both literal /v1/assistants/ and escaped \/v1\/assistants\/
|
|
177
|
+
if (line.includes('/v1/assistants/') || line.includes('\\/v1\\/assistants\\/')) {
|
|
178
|
+
violations.push(` line ${i + 1}: ${line.trim()}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
violations,
|
|
184
|
+
'Found /v1/assistants/ references in the daemon HTTP server — ' +
|
|
185
|
+
'the daemon should not have assistant-scoped path literals.\n' +
|
|
186
|
+
violations.join('\n'),
|
|
187
|
+
).toEqual([]);
|
|
188
|
+
|
|
189
|
+
// Guard against prefix-less assistants/ route patterns that extract an
|
|
190
|
+
// assistantId. dispatchEndpoint receives the endpoint *after* the /v1/
|
|
191
|
+
// prefix has been stripped, so a regex like `assistants\/([^/]+)` would
|
|
192
|
+
// capture an external assistant ID from the path — violating the
|
|
193
|
+
// assistant-scoping boundary.
|
|
194
|
+
const prefixLessViolations: string[] = [];
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
196
|
+
const line = lines[i];
|
|
197
|
+
// Match regex patterns like assistants\/([^/]+) that capture the ID
|
|
198
|
+
// segment. We look for the escaped-slash form used inside JS regex
|
|
199
|
+
// literals (e.g. /^assistants\/([^/]+)\//).
|
|
200
|
+
if (/assistants\\\/\(\[/.test(line)) {
|
|
201
|
+
prefixLessViolations.push(` line ${i + 1}: ${line.trim()}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
expect(
|
|
206
|
+
prefixLessViolations,
|
|
207
|
+
'Found prefix-less assistants/([^/]+) route pattern that extracts an assistantId. ' +
|
|
208
|
+
'The daemon should not parse assistant IDs from URL paths — use ' +
|
|
209
|
+
'DAEMON_INTERNAL_ASSISTANT_ID instead.\n' +
|
|
210
|
+
prefixLessViolations.join('\n'),
|
|
211
|
+
).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// -------------------------------------------------------------------------
|
|
215
|
+
// Rule (c): No hardcoded 'self' for assistant scoping in daemon files
|
|
216
|
+
// -------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
test('no hardcoded \'self\' string for assistant scoping in daemon source files', () => {
|
|
219
|
+
const repoRoot = getRepoRoot();
|
|
220
|
+
|
|
221
|
+
// Search for patterns where 'self' is used as an assistant ID value.
|
|
222
|
+
// We look for assignment / default / comparison patterns that suggest
|
|
223
|
+
// using the raw string instead of the DAEMON_INTERNAL_ASSISTANT_ID constant.
|
|
224
|
+
//
|
|
225
|
+
// Patterns matched:
|
|
226
|
+
// assistantId: 'self'
|
|
227
|
+
// assistantId = 'self'
|
|
228
|
+
// assistantId ?? 'self'
|
|
229
|
+
// ?? 'self' (fallback to self)
|
|
230
|
+
// || 'self' (fallback to self)
|
|
231
|
+
//
|
|
232
|
+
// Excluded:
|
|
233
|
+
// - Test files (they may legitimately assert against the value)
|
|
234
|
+
// - Migration files (SQL literals like DEFAULT 'self' are fine)
|
|
235
|
+
// - IPC contract files (comments documenting default values are fine)
|
|
236
|
+
// - CSP headers ('self' in Content-Security-Policy has nothing to do with assistant IDs)
|
|
237
|
+
const pattern = `(assistantId|assistant_id).*['"]self['"]`;
|
|
238
|
+
|
|
239
|
+
let grepOutput = '';
|
|
240
|
+
try {
|
|
241
|
+
grepOutput = execFileSync(
|
|
242
|
+
'git',
|
|
243
|
+
['grep', '-nE', pattern, '--', ...SCANNED_DIR_GLOBS],
|
|
244
|
+
{ encoding: 'utf-8', cwd: repoRoot },
|
|
245
|
+
).trim();
|
|
246
|
+
} catch (err) {
|
|
247
|
+
// Exit code 1 means no matches — happy path
|
|
248
|
+
if ((err as { status?: number }).status === 1) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const lines = grepOutput.split('\n').filter((l) => l.length > 0);
|
|
255
|
+
const violations = lines.filter((line) => {
|
|
256
|
+
const filePath = line.split(':')[0];
|
|
257
|
+
if (isTestFile(filePath)) return false;
|
|
258
|
+
if (isMigrationFile(filePath)) return false;
|
|
259
|
+
|
|
260
|
+
// Allow comments (lines where the code portion starts with //)
|
|
261
|
+
const parts = line.split(':');
|
|
262
|
+
// parts[0] = file, parts[1] = line number, rest = content
|
|
263
|
+
const content = parts.slice(2).join(':').trim();
|
|
264
|
+
if (content.startsWith('//') || content.startsWith('*') || content.startsWith('/*')) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return true;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (violations.length > 0) {
|
|
272
|
+
const message = [
|
|
273
|
+
"Found daemon/runtime source files with hardcoded 'self' for assistant scoping.",
|
|
274
|
+
'Use the `DAEMON_INTERNAL_ASSISTANT_ID` constant from `runtime/assistant-scope.ts` instead.',
|
|
275
|
+
'',
|
|
276
|
+
'Violations:',
|
|
277
|
+
...violations.map((v) => ` - ${v}`),
|
|
278
|
+
].join('\n');
|
|
279
|
+
|
|
280
|
+
expect(violations, message).toEqual([]);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// -------------------------------------------------------------------------
|
|
285
|
+
// Rule (d): Daemon storage keys don't contain external assistant IDs
|
|
286
|
+
// (verified by the constant value test above — if the constant is 'self',
|
|
287
|
+
// all daemon storage keyed by DAEMON_INTERNAL_ASSISTANT_ID uses the fixed
|
|
288
|
+
// internal value rather than externally-provided IDs).
|
|
289
|
+
// -------------------------------------------------------------------------
|
|
290
|
+
});
|
|
@@ -177,10 +177,6 @@ describe('runtime call routes — HTTP layer', () => {
|
|
|
177
177
|
return `http://127.0.0.1:${port}/v1/calls${path}`;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
function assistantCallsUrl(assistantId: string, path = ''): string {
|
|
181
|
-
return `http://127.0.0.1:${port}/v1/assistants/${assistantId}/calls${path}`;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
180
|
// ── POST /v1/calls/start ────────────────────────────────────────────
|
|
185
181
|
|
|
186
182
|
test('POST /v1/calls/start returns 201 with call session', async () => {
|
|
@@ -235,27 +231,6 @@ describe('runtime call routes — HTTP layer', () => {
|
|
|
235
231
|
await stopServer();
|
|
236
232
|
});
|
|
237
233
|
|
|
238
|
-
test('POST /v1/assistants/:assistantId/calls/start uses assistant-scoped caller number', async () => {
|
|
239
|
-
await startServer();
|
|
240
|
-
ensureConversation('conv-start-scoped-1');
|
|
241
|
-
|
|
242
|
-
const res = await fetch(assistantCallsUrl('asst-alpha', '/start'), {
|
|
243
|
-
method: 'POST',
|
|
244
|
-
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
245
|
-
body: JSON.stringify({
|
|
246
|
-
phoneNumber: '+15559997777',
|
|
247
|
-
task: 'Check order status',
|
|
248
|
-
conversationId: 'conv-start-scoped-1',
|
|
249
|
-
}),
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
expect(res.status).toBe(201);
|
|
253
|
-
const body = await res.json() as { fromNumber: string };
|
|
254
|
-
expect(body.fromNumber).toBe('+15550009999');
|
|
255
|
-
|
|
256
|
-
await stopServer();
|
|
257
|
-
});
|
|
258
|
-
|
|
259
234
|
test('POST /v1/calls/start returns 400 for invalid phone number', async () => {
|
|
260
235
|
await startServer();
|
|
261
236
|
ensureConversation('conv-start-2');
|
|
@@ -2615,6 +2615,56 @@ describe('background channel processing approval prompts', () => {
|
|
|
2615
2615
|
deliverPromptSpy.mockRestore();
|
|
2616
2616
|
});
|
|
2617
2617
|
|
|
2618
|
+
test('guardian prompt delivery still works when binding ID formatting differs from sender ID', async () => {
|
|
2619
|
+
// Guardian binding includes extra whitespace; trust resolution canonicalizes
|
|
2620
|
+
// identity and prompt delivery should still treat this sender as the guardian.
|
|
2621
|
+
createBinding({
|
|
2622
|
+
assistantId: 'self',
|
|
2623
|
+
channel: 'telegram',
|
|
2624
|
+
guardianExternalUserId: ' telegram-user-default ',
|
|
2625
|
+
guardianDeliveryChatId: 'chat-123',
|
|
2626
|
+
});
|
|
2627
|
+
|
|
2628
|
+
const deliverPromptSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2629
|
+
const processCalls: Array<{ options?: Record<string, unknown> }> = [];
|
|
2630
|
+
|
|
2631
|
+
const processMessage = mock(async (
|
|
2632
|
+
conversationId: string,
|
|
2633
|
+
_content: string,
|
|
2634
|
+
_attachmentIds?: string[],
|
|
2635
|
+
options?: Record<string, unknown>,
|
|
2636
|
+
) => {
|
|
2637
|
+
processCalls.push({ options });
|
|
2638
|
+
|
|
2639
|
+
registerPendingInteraction('req-bg-format-1', conversationId, 'host_bash', {
|
|
2640
|
+
input: { command: 'ls -la' },
|
|
2641
|
+
riskLevel: 'medium',
|
|
2642
|
+
});
|
|
2643
|
+
|
|
2644
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
2645
|
+
return { messageId: 'msg-bg-format-1' };
|
|
2646
|
+
});
|
|
2647
|
+
|
|
2648
|
+
const req = makeInboundRequest({
|
|
2649
|
+
content: 'run ls',
|
|
2650
|
+
sourceChannel: 'telegram',
|
|
2651
|
+
replyCallbackUrl: 'https://gateway.test/deliver/telegram',
|
|
2652
|
+
externalMessageId: 'msg-bg-format-1',
|
|
2653
|
+
});
|
|
2654
|
+
|
|
2655
|
+
const res = await handleChannelInbound(req, processMessage as unknown as typeof noopProcessMessage, 'token');
|
|
2656
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2657
|
+
expect(body.accepted).toBe(true);
|
|
2658
|
+
|
|
2659
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
2660
|
+
|
|
2661
|
+
expect(processCalls.length).toBeGreaterThan(0);
|
|
2662
|
+
expect(processCalls[0].options?.isInteractive).toBe(true);
|
|
2663
|
+
expect(deliverPromptSpy).toHaveBeenCalled();
|
|
2664
|
+
|
|
2665
|
+
deliverPromptSpy.mockRestore();
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2618
2668
|
test('non-guardian channel turns are not interactive to prevent self-approval', async () => {
|
|
2619
2669
|
// Set up a guardian binding for a DIFFERENT user so the sender is non-guardian
|
|
2620
2670
|
createBinding({
|
|
@@ -2709,8 +2759,8 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
|
|
|
2709
2759
|
noopProcessMessage.mockClear();
|
|
2710
2760
|
});
|
|
2711
2761
|
|
|
2712
|
-
test('guardian plain-text "yes"
|
|
2713
|
-
// Simulate a voice-originated
|
|
2762
|
+
test('guardian plain-text "yes" fails closed for tool_approval with no guardianExternalUserId', async () => {
|
|
2763
|
+
// Simulate a voice-originated tool approval without guardianExternalUserId
|
|
2714
2764
|
const guardianChatId = 'guardian-chat-nl-1';
|
|
2715
2765
|
const guardianUserId = 'guardian-user-nl-1';
|
|
2716
2766
|
|
|
@@ -2759,12 +2809,12 @@ describe('NL approval routing via destination-scoped canonical requests', () =>
|
|
|
2759
2809
|
const body = await res.json() as Record<string, unknown>;
|
|
2760
2810
|
|
|
2761
2811
|
expect(body.accepted).toBe(true);
|
|
2762
|
-
expect(body.canonicalRouter).toBe('
|
|
2812
|
+
expect(body.canonicalRouter).toBe('canonical_decision_stale');
|
|
2763
2813
|
|
|
2764
|
-
// Verify the request
|
|
2814
|
+
// Verify the request remains pending (identity-bound fail-closed).
|
|
2765
2815
|
const resolved = getCanonicalGuardianRequest(canonicalReq.id);
|
|
2766
2816
|
expect(resolved).not.toBeNull();
|
|
2767
|
-
expect(resolved!.status).toBe('
|
|
2817
|
+
expect(resolved!.status).toBe('pending');
|
|
2768
2818
|
});
|
|
2769
2819
|
|
|
2770
2820
|
test('inbound from different chat ID does not auto-match delivery-scoped canonical request', async () => {
|
|
@@ -22,7 +22,6 @@ mock.module('../util/platform.js', () => ({
|
|
|
22
22
|
getDbPath: () => join(testDir, 'test.db'),
|
|
23
23
|
getLogPath: () => join(testDir, 'test.log'),
|
|
24
24
|
ensureDataDir: () => {},
|
|
25
|
-
normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
|
|
26
25
|
readHttpToken: () => 'test-bearer-token',
|
|
27
26
|
}));
|
|
28
27
|
|
|
@@ -1458,9 +1457,11 @@ describe('IPC handler channel-aware guardian status', () => {
|
|
|
1458
1457
|
expect(resp!.channel).toBe('sms');
|
|
1459
1458
|
});
|
|
1460
1459
|
|
|
1461
|
-
test('status action with custom assistantId
|
|
1460
|
+
test('status action with custom assistantId is ignored (daemon uses internal scope)', () => {
|
|
1461
|
+
// Create binding under the internal scope constant — the handler always
|
|
1462
|
+
// uses DAEMON_INTERNAL_ASSISTANT_ID regardless of what the caller passes.
|
|
1462
1463
|
createBinding({
|
|
1463
|
-
assistantId: '
|
|
1464
|
+
assistantId: 'self',
|
|
1464
1465
|
channel: 'telegram',
|
|
1465
1466
|
guardianExternalUserId: 'user-77',
|
|
1466
1467
|
guardianDeliveryChatId: 'chat-77',
|
|
@@ -1471,7 +1472,7 @@ describe('IPC handler channel-aware guardian status', () => {
|
|
|
1471
1472
|
type: 'guardian_verification',
|
|
1472
1473
|
action: 'status',
|
|
1473
1474
|
channel: 'telegram',
|
|
1474
|
-
assistantId: 'asst-custom',
|
|
1475
|
+
assistantId: 'asst-custom', // ignored by handler
|
|
1475
1476
|
};
|
|
1476
1477
|
|
|
1477
1478
|
handleGuardianVerification(msg, mockSocket, ctx);
|
|
@@ -1480,7 +1481,7 @@ describe('IPC handler channel-aware guardian status', () => {
|
|
|
1480
1481
|
expect(resp).not.toBeNull();
|
|
1481
1482
|
expect(resp!.success).toBe(true);
|
|
1482
1483
|
expect(resp!.bound).toBe(true);
|
|
1483
|
-
expect(resp!.assistantId).toBe('
|
|
1484
|
+
expect(resp!.assistantId).toBe('self');
|
|
1484
1485
|
expect(resp!.channel).toBe('telegram');
|
|
1485
1486
|
expect(resp!.guardianExternalUserId).toBe('user-77');
|
|
1486
1487
|
expect(resp!.guardianDeliveryChatId).toBe('chat-77');
|
|
@@ -581,6 +581,8 @@ describe('AssistantConfigSchema', () => {
|
|
|
581
581
|
provider: 'twilio',
|
|
582
582
|
maxDurationSeconds: 3600,
|
|
583
583
|
userConsultTimeoutSeconds: 120,
|
|
584
|
+
ttsPlaybackDelayMs: 3000,
|
|
585
|
+
accessRequestPollIntervalMs: 500,
|
|
584
586
|
disclosure: {
|
|
585
587
|
enabled: true,
|
|
586
588
|
text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".',
|
|
@@ -36,6 +36,7 @@ let lastCreateConversationArgs: unknown;
|
|
|
36
36
|
// field declarations create own-properties that mask prototype assignments.
|
|
37
37
|
let mockConfirmationToEmitDuringLoop: Record<string, unknown> | undefined;
|
|
38
38
|
let mockMidLoopCallback: ((session: MockSession) => void) | undefined;
|
|
39
|
+
let lastCanonicalGuardianCreateParams: Record<string, unknown> | undefined;
|
|
39
40
|
|
|
40
41
|
class MockSession {
|
|
41
42
|
public readonly conversationId: string;
|
|
@@ -253,7 +254,10 @@ mock.module('../memory/conversation-attention-store.js', () => ({
|
|
|
253
254
|
|
|
254
255
|
mock.module('../memory/canonical-guardian-store.js', () => ({
|
|
255
256
|
generateCanonicalRequestCode: () => 'mock-code-0000',
|
|
256
|
-
createCanonicalGuardianRequest: (
|
|
257
|
+
createCanonicalGuardianRequest: (params: Record<string, unknown>) => {
|
|
258
|
+
lastCanonicalGuardianCreateParams = params;
|
|
259
|
+
return { requestCode: 'mock-code-0000', status: 'pending' };
|
|
260
|
+
},
|
|
257
261
|
submitCanonicalRequest: () => ({ requestCode: 'mock-code-0000', status: 'pending' }),
|
|
258
262
|
getCanonicalRequest: () => null,
|
|
259
263
|
resolveCanonicalRequest: () => false,
|
|
@@ -342,6 +346,7 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
342
346
|
lastCreatedWorkingDir = undefined;
|
|
343
347
|
lastCreatedMemoryPolicy = undefined;
|
|
344
348
|
lastCreateConversationArgs = undefined;
|
|
349
|
+
lastCanonicalGuardianCreateParams = undefined;
|
|
345
350
|
mockConfirmationToEmitDuringLoop = undefined;
|
|
346
351
|
mockMidLoopCallback = undefined;
|
|
347
352
|
pendingInteractions.clear();
|
|
@@ -686,6 +691,54 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
686
691
|
expect(interaction?.conversationId).toBe(conversation.id);
|
|
687
692
|
});
|
|
688
693
|
|
|
694
|
+
test('confirmation_request canonical records include bound guardian identity context', async () => {
|
|
695
|
+
const server = new DaemonServer();
|
|
696
|
+
|
|
697
|
+
mockConfirmationToEmitDuringLoop = {
|
|
698
|
+
type: 'confirmation_request',
|
|
699
|
+
requestId: 'req-bound-1',
|
|
700
|
+
toolName: 'host_bash',
|
|
701
|
+
input: { command: 'ls' },
|
|
702
|
+
riskLevel: 'high',
|
|
703
|
+
allowlistOptions: [{ label: 'host_bash:*', description: 'host_bash:*', pattern: 'host_bash:*' }],
|
|
704
|
+
scopeOptions: [{ label: 'everywhere', scope: 'everywhere' }],
|
|
705
|
+
persistentDecisionsAllowed: true,
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
await server.processMessage(
|
|
709
|
+
conversation.id,
|
|
710
|
+
'run ls',
|
|
711
|
+
undefined,
|
|
712
|
+
{
|
|
713
|
+
isInteractive: false,
|
|
714
|
+
guardianContext: {
|
|
715
|
+
sourceChannel: 'telegram',
|
|
716
|
+
trustClass: 'trusted_contact',
|
|
717
|
+
guardianExternalUserId: 'guardian-123',
|
|
718
|
+
requesterExternalUserId: 'trusted-456',
|
|
719
|
+
requesterChatId: 'chat-789',
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
'telegram',
|
|
723
|
+
'telegram',
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
expect(lastCanonicalGuardianCreateParams).toBeDefined();
|
|
727
|
+
expect(lastCanonicalGuardianCreateParams).toMatchObject({
|
|
728
|
+
id: 'req-bound-1',
|
|
729
|
+
kind: 'tool_approval',
|
|
730
|
+
sourceType: 'channel',
|
|
731
|
+
sourceChannel: 'telegram',
|
|
732
|
+
conversationId: conversation.id,
|
|
733
|
+
guardianExternalUserId: 'guardian-123',
|
|
734
|
+
requesterExternalUserId: 'trusted-456',
|
|
735
|
+
requesterChatId: 'chat-789',
|
|
736
|
+
toolName: 'host_bash',
|
|
737
|
+
status: 'pending',
|
|
738
|
+
requestCode: 'mock-code-0000',
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
689
742
|
test('finally block does not overwrite IPC client that connected during interactive agent loop (processMessage)', async () => {
|
|
690
743
|
const server = new DaemonServer();
|
|
691
744
|
const internal = asDaemonServerTestAccess(server);
|
|
@@ -32,7 +32,6 @@ mock.module('../util/platform.js', () => ({
|
|
|
32
32
|
getDbPath: () => join(testDir, 'test.db'),
|
|
33
33
|
getLogPath: () => join(testDir, 'test.log'),
|
|
34
34
|
ensureDataDir: () => {},
|
|
35
|
-
normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
|
|
36
35
|
readHttpToken: () => 'test-bearer-token',
|
|
37
36
|
}));
|
|
38
37
|
|
|
@@ -262,6 +262,27 @@ describe('HTTP handleGuardianActionDecision', () => {
|
|
|
262
262
|
expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
+
test('applies decision for voice access_request kind through canonical primitive', async () => {
|
|
266
|
+
createTestCanonicalRequest({
|
|
267
|
+
conversationId: 'conv-voice-access',
|
|
268
|
+
requestId: 'req-voice-access-1',
|
|
269
|
+
kind: 'access_request',
|
|
270
|
+
toolName: 'ingress_access_request',
|
|
271
|
+
guardianExternalUserId: 'guardian-voice-42',
|
|
272
|
+
});
|
|
273
|
+
mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-voice-access-1', grantMinted: false });
|
|
274
|
+
|
|
275
|
+
const req = new Request('http://localhost/v1/guardian-actions/decision', {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
body: JSON.stringify({ requestId: 'req-voice-access-1', action: 'approve_once' }),
|
|
278
|
+
});
|
|
279
|
+
const res = await handleGuardianActionDecision(req);
|
|
280
|
+
expect(res.status).toBe(200);
|
|
281
|
+
const body = await res.json();
|
|
282
|
+
expect(body.applied).toBe(true);
|
|
283
|
+
expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
265
286
|
test('returns stale reason from canonical decision primitive', async () => {
|
|
266
287
|
createTestCanonicalRequest({ conversationId: 'conv-stale', requestId: 'req-stale-1' });
|
|
267
288
|
mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: false, reason: 'already_resolved' });
|
|
@@ -248,7 +248,7 @@ describe('applyCanonicalGuardianDecision', () => {
|
|
|
248
248
|
expect(result.grantMinted).toBe(false);
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
test('
|
|
251
|
+
test('rejects non-trusted decision when tool approval has no guardian binding', async () => {
|
|
252
252
|
const req = createCanonicalGuardianRequest({
|
|
253
253
|
kind: 'tool_approval',
|
|
254
254
|
sourceType: 'channel',
|
|
@@ -263,7 +263,9 @@ describe('applyCanonicalGuardianDecision', () => {
|
|
|
263
263
|
actorContext: guardianActor({ externalUserId: 'anyone' }),
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
expect(result.applied).toBe(
|
|
266
|
+
expect(result.applied).toBe(false);
|
|
267
|
+
if (result.applied) return;
|
|
268
|
+
expect(result.reason).toBe('identity_mismatch');
|
|
267
269
|
});
|
|
268
270
|
|
|
269
271
|
// ── Stale / already-resolved (race condition) ──────────────────────
|
|
@@ -35,7 +35,6 @@ mock.module('../util/platform.js', () => ({
|
|
|
35
35
|
getDbPath: () => join(testDir, 'test.db'),
|
|
36
36
|
getLogPath: () => join(testDir, 'test.log'),
|
|
37
37
|
ensureDataDir: () => {},
|
|
38
|
-
normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
|
|
39
38
|
readHttpToken: () => 'test-bearer-token',
|
|
40
39
|
}));
|
|
41
40
|
|