@vellumai/assistant 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
- package/src/__tests__/approval-routes-http.test.ts +704 -0
- package/src/__tests__/call-controller.test.ts +835 -0
- package/src/__tests__/call-state.test.ts +24 -24
- package/src/__tests__/ipc-snapshot.test.ts +14 -0
- package/src/__tests__/relay-server.test.ts +9 -9
- package/src/__tests__/run-orchestrator.test.ts +399 -3
- package/src/__tests__/runtime-runs.test.ts +12 -4
- package/src/__tests__/send-endpoint-busy.test.ts +284 -0
- package/src/__tests__/session-init.benchmark.test.ts +3 -3
- package/src/__tests__/subagent-manager-notify.test.ts +3 -3
- package/src/__tests__/voice-session-bridge.test.ts +869 -0
- package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
- package/src/calls/call-domain.ts +21 -21
- package/src/calls/call-state.ts +12 -12
- package/src/calls/guardian-dispatch.ts +43 -3
- package/src/calls/relay-server.ts +34 -39
- package/src/calls/twilio-routes.ts +3 -3
- package/src/calls/voice-session-bridge.ts +244 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
- package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
- package/src/config/defaults.ts +5 -0
- package/src/config/notifications-schema.ts +15 -0
- package/src/config/schema.ts +13 -0
- package/src/config/types.ts +1 -0
- package/src/daemon/daemon-control.ts +13 -12
- package/src/daemon/handlers/subagents.ts +10 -3
- package/src/daemon/ipc-contract/notifications.ts +9 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +4 -1
- package/src/daemon/lifecycle.ts +100 -1
- package/src/daemon/server.ts +8 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +51 -0
- package/src/daemon/session-runtime-assembly.ts +32 -0
- package/src/daemon/session.ts +5 -0
- package/src/memory/db-init.ts +80 -0
- package/src/memory/guardian-action-store.ts +2 -2
- package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +59 -1
- package/src/notifications/README.md +134 -0
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/adapters/telegram.ts +65 -0
- package/src/notifications/broadcaster.ts +175 -0
- package/src/notifications/copy-composer.ts +118 -0
- package/src/notifications/decision-engine.ts +391 -0
- package/src/notifications/decisions-store.ts +158 -0
- package/src/notifications/deliveries-store.ts +130 -0
- package/src/notifications/destination-resolver.ts +54 -0
- package/src/notifications/deterministic-checks.ts +187 -0
- package/src/notifications/emit-signal.ts +191 -0
- package/src/notifications/events-store.ts +145 -0
- package/src/notifications/preference-extractor.ts +223 -0
- package/src/notifications/preference-summary.ts +110 -0
- package/src/notifications/preferences-store.ts +142 -0
- package/src/notifications/runtime-dispatch.ts +100 -0
- package/src/notifications/signal.ts +24 -0
- package/src/notifications/types.ts +75 -0
- package/src/runtime/http-server.ts +15 -0
- package/src/runtime/http-types.ts +22 -0
- package/src/runtime/pending-interactions.ts +73 -0
- package/src/runtime/routes/approval-routes.ts +179 -0
- package/src/runtime/routes/channel-inbound-routes.ts +39 -4
- package/src/runtime/routes/conversation-routes.ts +107 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/subagent/manager.ts +6 -6
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/tools/subagent/message.ts +9 -2
- package/src/__tests__/call-orchestrator.test.ts +0 -1496
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-layer integration tests for the standalone approval endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Tests POST /v1/confirm, POST /v1/secret, and POST /v1/trust-rules
|
|
5
|
+
* through RuntimeHttpServer with pending-interactions tracking.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
|
|
8
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
12
|
+
import type { Session } from '../daemon/session.js';
|
|
13
|
+
|
|
14
|
+
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'approval-routes-http-test-')));
|
|
15
|
+
|
|
16
|
+
mock.module('../util/platform.js', () => ({
|
|
17
|
+
getRootDir: () => testDir,
|
|
18
|
+
getDataDir: () => testDir,
|
|
19
|
+
isMacOS: () => process.platform === 'darwin',
|
|
20
|
+
isLinux: () => process.platform === 'linux',
|
|
21
|
+
isWindows: () => process.platform === 'win32',
|
|
22
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
23
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
24
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
25
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
26
|
+
ensureDataDir: () => {},
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
mock.module('../util/logger.js', () => ({
|
|
30
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
31
|
+
get: () => () => {},
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
mock.module('../config/loader.js', () => ({
|
|
36
|
+
getConfig: () => ({
|
|
37
|
+
model: 'test',
|
|
38
|
+
provider: 'test',
|
|
39
|
+
apiKeys: {},
|
|
40
|
+
memory: { enabled: false },
|
|
41
|
+
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
42
|
+
secretDetection: { enabled: false },
|
|
43
|
+
sandbox: { enabled: false },
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// Mock the trust store so addRule doesn't touch disk or require full config
|
|
48
|
+
mock.module('../permissions/trust-store.js', () => ({
|
|
49
|
+
addRule: () => ({ id: 'test-rule', tool: 'test', pattern: '*', scope: 'everywhere', decision: 'allow', priority: 100 }),
|
|
50
|
+
getRules: () => [],
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
54
|
+
import { RuntimeHttpServer } from '../runtime/http-server.js';
|
|
55
|
+
import { AssistantEventHub } from '../runtime/assistant-event-hub.js';
|
|
56
|
+
import * as pendingInteractions from '../runtime/pending-interactions.js';
|
|
57
|
+
|
|
58
|
+
initializeDb();
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Session helpers
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function makeIdleSession(opts?: {
|
|
65
|
+
onConfirmation?: (requestId: string, decision: string) => void;
|
|
66
|
+
onSecret?: (requestId: string, value?: string, delivery?: string) => void;
|
|
67
|
+
}): Session {
|
|
68
|
+
let processing = false;
|
|
69
|
+
return {
|
|
70
|
+
isProcessing: () => processing,
|
|
71
|
+
persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => {
|
|
72
|
+
processing = true;
|
|
73
|
+
return requestId ?? 'msg-1';
|
|
74
|
+
},
|
|
75
|
+
memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
|
|
76
|
+
setChannelCapabilities: () => {},
|
|
77
|
+
setAssistantId: () => {},
|
|
78
|
+
setGuardianContext: () => {},
|
|
79
|
+
setCommandIntent: () => {},
|
|
80
|
+
updateClient: () => {},
|
|
81
|
+
enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
|
|
82
|
+
runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
|
|
83
|
+
onEvent({ type: 'assistant_text_delta', text: 'Hello!' });
|
|
84
|
+
onEvent({ type: 'message_complete', sessionId: 'test-session' });
|
|
85
|
+
processing = false;
|
|
86
|
+
},
|
|
87
|
+
handleConfirmationResponse: (requestId: string, decision: string) => {
|
|
88
|
+
opts?.onConfirmation?.(requestId, decision);
|
|
89
|
+
},
|
|
90
|
+
handleSecretResponse: (requestId: string, value?: string, delivery?: string) => {
|
|
91
|
+
opts?.onSecret?.(requestId, value, delivery);
|
|
92
|
+
},
|
|
93
|
+
} as unknown as Session;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Session whose agent loop emits a confirmation_request, so the hub
|
|
98
|
+
* publisher registers a pending interaction automatically.
|
|
99
|
+
*/
|
|
100
|
+
function makeConfirmationEmittingSession(opts?: {
|
|
101
|
+
onConfirmation?: (requestId: string, decision: string) => void;
|
|
102
|
+
confirmRequestId?: string;
|
|
103
|
+
toolName?: string;
|
|
104
|
+
}): Session {
|
|
105
|
+
let processing = false;
|
|
106
|
+
const reqId = opts?.confirmRequestId ?? 'confirm-req-1';
|
|
107
|
+
const tool = opts?.toolName ?? 'shell_command';
|
|
108
|
+
return {
|
|
109
|
+
isProcessing: () => processing,
|
|
110
|
+
persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => {
|
|
111
|
+
processing = true;
|
|
112
|
+
return requestId ?? 'msg-1';
|
|
113
|
+
},
|
|
114
|
+
memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false },
|
|
115
|
+
setChannelCapabilities: () => {},
|
|
116
|
+
setAssistantId: () => {},
|
|
117
|
+
setGuardianContext: () => {},
|
|
118
|
+
setCommandIntent: () => {},
|
|
119
|
+
updateClient: () => {},
|
|
120
|
+
enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
|
|
121
|
+
runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => {
|
|
122
|
+
// Emit confirmation_request — this triggers the hub publisher to register
|
|
123
|
+
// the pending interaction
|
|
124
|
+
onEvent({
|
|
125
|
+
type: 'confirmation_request',
|
|
126
|
+
requestId: reqId,
|
|
127
|
+
toolName: tool,
|
|
128
|
+
input: { command: 'ls' },
|
|
129
|
+
riskLevel: 'medium',
|
|
130
|
+
allowlistOptions: [
|
|
131
|
+
{ label: 'Allow ls', description: 'Allow ls command', pattern: 'ls' },
|
|
132
|
+
],
|
|
133
|
+
scopeOptions: [
|
|
134
|
+
{ label: 'This session', scope: 'session' },
|
|
135
|
+
],
|
|
136
|
+
persistentDecisionsAllowed: true,
|
|
137
|
+
});
|
|
138
|
+
// Hang to simulate waiting for decision
|
|
139
|
+
await new Promise<void>(() => {});
|
|
140
|
+
},
|
|
141
|
+
handleConfirmationResponse: (requestId: string, decision: string) => {
|
|
142
|
+
opts?.onConfirmation?.(requestId, decision);
|
|
143
|
+
},
|
|
144
|
+
handleSecretResponse: () => {},
|
|
145
|
+
} as unknown as Session;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Tests
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const TEST_TOKEN = 'test-bearer-token-approvals';
|
|
153
|
+
const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
|
|
154
|
+
|
|
155
|
+
describe('standalone approval endpoints — HTTP layer', () => {
|
|
156
|
+
let server: RuntimeHttpServer;
|
|
157
|
+
let port: number;
|
|
158
|
+
let eventHub: AssistantEventHub;
|
|
159
|
+
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
const db = getDb();
|
|
162
|
+
db.run('DELETE FROM messages');
|
|
163
|
+
db.run('DELETE FROM conversations');
|
|
164
|
+
db.run('DELETE FROM conversation_keys');
|
|
165
|
+
pendingInteractions.clear();
|
|
166
|
+
eventHub = new AssistantEventHub();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterAll(() => {
|
|
170
|
+
resetDb();
|
|
171
|
+
try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
async function startServer(sessionFactory: () => Session): Promise<void> {
|
|
175
|
+
port = 20000 + Math.floor(Math.random() * 1000);
|
|
176
|
+
server = new RuntimeHttpServer({
|
|
177
|
+
port,
|
|
178
|
+
bearerToken: TEST_TOKEN,
|
|
179
|
+
sendMessageDeps: {
|
|
180
|
+
getOrCreateSession: async () => sessionFactory(),
|
|
181
|
+
assistantEventHub: eventHub,
|
|
182
|
+
resolveAttachments: () => [],
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
await server.start();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function stopServer(): Promise<void> {
|
|
189
|
+
await server?.stop();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function url(path: string): string {
|
|
193
|
+
return `http://127.0.0.1:${port}/v1/${path}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── POST /v1/confirm ─────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
describe('POST /v1/confirm', () => {
|
|
199
|
+
test('resolves a pending confirmation by requestId', async () => {
|
|
200
|
+
let confirmedRequestId: string | undefined;
|
|
201
|
+
let confirmedDecision: string | undefined;
|
|
202
|
+
|
|
203
|
+
const session = makeIdleSession({
|
|
204
|
+
onConfirmation: (reqId, dec) => {
|
|
205
|
+
confirmedRequestId = reqId;
|
|
206
|
+
confirmedDecision = dec;
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await startServer(() => session);
|
|
211
|
+
|
|
212
|
+
// Manually register a pending interaction
|
|
213
|
+
pendingInteractions.register('req-abc', {
|
|
214
|
+
session,
|
|
215
|
+
conversationId: 'conv-1',
|
|
216
|
+
kind: 'confirmation',
|
|
217
|
+
confirmationDetails: {
|
|
218
|
+
toolName: 'shell_command',
|
|
219
|
+
input: { command: 'ls' },
|
|
220
|
+
riskLevel: 'medium',
|
|
221
|
+
allowlistOptions: [],
|
|
222
|
+
scopeOptions: [],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const res = await fetch(url('confirm'), {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
229
|
+
body: JSON.stringify({ requestId: 'req-abc', decision: 'allow' }),
|
|
230
|
+
});
|
|
231
|
+
const body = await res.json() as { accepted: boolean };
|
|
232
|
+
|
|
233
|
+
expect(res.status).toBe(200);
|
|
234
|
+
expect(body.accepted).toBe(true);
|
|
235
|
+
expect(confirmedRequestId).toBe('req-abc');
|
|
236
|
+
expect(confirmedDecision).toBe('allow');
|
|
237
|
+
|
|
238
|
+
// Interaction should be removed after resolution
|
|
239
|
+
expect(pendingInteractions.get('req-abc')).toBeUndefined();
|
|
240
|
+
|
|
241
|
+
await stopServer();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('returns 404 for unknown requestId', async () => {
|
|
245
|
+
await startServer(() => makeIdleSession());
|
|
246
|
+
|
|
247
|
+
const res = await fetch(url('confirm'), {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
250
|
+
body: JSON.stringify({ requestId: 'nonexistent', decision: 'allow' }),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(res.status).toBe(404);
|
|
254
|
+
|
|
255
|
+
await stopServer();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('returns 404 for already-resolved requestId', async () => {
|
|
259
|
+
const session = makeIdleSession();
|
|
260
|
+
await startServer(() => session);
|
|
261
|
+
|
|
262
|
+
pendingInteractions.register('req-once', {
|
|
263
|
+
session,
|
|
264
|
+
conversationId: 'conv-1',
|
|
265
|
+
kind: 'confirmation',
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// First resolution succeeds
|
|
269
|
+
const res1 = await fetch(url('confirm'), {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
272
|
+
body: JSON.stringify({ requestId: 'req-once', decision: 'allow' }),
|
|
273
|
+
});
|
|
274
|
+
expect(res1.status).toBe(200);
|
|
275
|
+
|
|
276
|
+
// Second resolution fails (already consumed)
|
|
277
|
+
const res2 = await fetch(url('confirm'), {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
280
|
+
body: JSON.stringify({ requestId: 'req-once', decision: 'deny' }),
|
|
281
|
+
});
|
|
282
|
+
expect(res2.status).toBe(404);
|
|
283
|
+
|
|
284
|
+
await stopServer();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('returns 400 for missing requestId', async () => {
|
|
288
|
+
await startServer(() => makeIdleSession());
|
|
289
|
+
|
|
290
|
+
const res = await fetch(url('confirm'), {
|
|
291
|
+
method: 'POST',
|
|
292
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
293
|
+
body: JSON.stringify({ decision: 'allow' }),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(res.status).toBe(400);
|
|
297
|
+
|
|
298
|
+
await stopServer();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('returns 400 for invalid decision', async () => {
|
|
302
|
+
await startServer(() => makeIdleSession());
|
|
303
|
+
|
|
304
|
+
const res = await fetch(url('confirm'), {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
307
|
+
body: JSON.stringify({ requestId: 'req-1', decision: 'maybe' }),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(res.status).toBe(400);
|
|
311
|
+
|
|
312
|
+
await stopServer();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ── POST /v1/secret ──────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe('POST /v1/secret', () => {
|
|
319
|
+
test('resolves a pending secret request by requestId', async () => {
|
|
320
|
+
let secretRequestId: string | undefined;
|
|
321
|
+
let secretValue: string | undefined;
|
|
322
|
+
let secretDelivery: string | undefined;
|
|
323
|
+
|
|
324
|
+
const session = makeIdleSession({
|
|
325
|
+
onSecret: (reqId, val, del) => {
|
|
326
|
+
secretRequestId = reqId;
|
|
327
|
+
secretValue = val;
|
|
328
|
+
secretDelivery = del;
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
await startServer(() => session);
|
|
333
|
+
|
|
334
|
+
pendingInteractions.register('secret-req-1', {
|
|
335
|
+
session,
|
|
336
|
+
conversationId: 'conv-1',
|
|
337
|
+
kind: 'secret',
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const res = await fetch(url('secret'), {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
343
|
+
body: JSON.stringify({ requestId: 'secret-req-1', value: 'my-secret-key', delivery: 'store' }),
|
|
344
|
+
});
|
|
345
|
+
const body = await res.json() as { accepted: boolean };
|
|
346
|
+
|
|
347
|
+
expect(res.status).toBe(200);
|
|
348
|
+
expect(body.accepted).toBe(true);
|
|
349
|
+
expect(secretRequestId).toBe('secret-req-1');
|
|
350
|
+
expect(secretValue).toBe('my-secret-key');
|
|
351
|
+
expect(secretDelivery).toBe('store');
|
|
352
|
+
|
|
353
|
+
// Interaction should be removed after resolution
|
|
354
|
+
expect(pendingInteractions.get('secret-req-1')).toBeUndefined();
|
|
355
|
+
|
|
356
|
+
await stopServer();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('returns 404 for unknown requestId', async () => {
|
|
360
|
+
await startServer(() => makeIdleSession());
|
|
361
|
+
|
|
362
|
+
const res = await fetch(url('secret'), {
|
|
363
|
+
method: 'POST',
|
|
364
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
365
|
+
body: JSON.stringify({ requestId: 'nonexistent', value: 'test' }),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(res.status).toBe(404);
|
|
369
|
+
|
|
370
|
+
await stopServer();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('returns 400 for missing requestId', async () => {
|
|
374
|
+
await startServer(() => makeIdleSession());
|
|
375
|
+
|
|
376
|
+
const res = await fetch(url('secret'), {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
379
|
+
body: JSON.stringify({ value: 'test' }),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(res.status).toBe(400);
|
|
383
|
+
|
|
384
|
+
await stopServer();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('returns 400 for invalid delivery', async () => {
|
|
388
|
+
await startServer(() => makeIdleSession());
|
|
389
|
+
|
|
390
|
+
const res = await fetch(url('secret'), {
|
|
391
|
+
method: 'POST',
|
|
392
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
393
|
+
body: JSON.stringify({ requestId: 'req-1', value: 'test', delivery: 'invalid' }),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(res.status).toBe(400);
|
|
397
|
+
|
|
398
|
+
await stopServer();
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ── POST /v1/trust-rules ─────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
describe('POST /v1/trust-rules', () => {
|
|
405
|
+
test('returns 404 for unknown requestId', async () => {
|
|
406
|
+
await startServer(() => makeIdleSession());
|
|
407
|
+
|
|
408
|
+
const res = await fetch(url('trust-rules'), {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
411
|
+
body: JSON.stringify({ requestId: 'nonexistent', pattern: 'ls', scope: 'session', decision: 'allow' }),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(res.status).toBe(404);
|
|
415
|
+
|
|
416
|
+
await stopServer();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('returns 400 for missing requestId', async () => {
|
|
420
|
+
await startServer(() => makeIdleSession());
|
|
421
|
+
|
|
422
|
+
const res = await fetch(url('trust-rules'), {
|
|
423
|
+
method: 'POST',
|
|
424
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
425
|
+
body: JSON.stringify({ pattern: 'ls', scope: 'session', decision: 'allow' }),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(res.status).toBe(400);
|
|
429
|
+
|
|
430
|
+
await stopServer();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('returns 400 for missing pattern', async () => {
|
|
434
|
+
await startServer(() => makeIdleSession());
|
|
435
|
+
|
|
436
|
+
const res = await fetch(url('trust-rules'), {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
439
|
+
body: JSON.stringify({ requestId: 'req-1', scope: 'session', decision: 'allow' }),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
expect(res.status).toBe(400);
|
|
443
|
+
|
|
444
|
+
await stopServer();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('returns 400 for missing scope', async () => {
|
|
448
|
+
await startServer(() => makeIdleSession());
|
|
449
|
+
|
|
450
|
+
const res = await fetch(url('trust-rules'), {
|
|
451
|
+
method: 'POST',
|
|
452
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
453
|
+
body: JSON.stringify({ requestId: 'req-1', pattern: 'ls', decision: 'allow' }),
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(res.status).toBe(400);
|
|
457
|
+
|
|
458
|
+
await stopServer();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('returns 400 for invalid decision', async () => {
|
|
462
|
+
await startServer(() => makeIdleSession());
|
|
463
|
+
|
|
464
|
+
const res = await fetch(url('trust-rules'), {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
467
|
+
body: JSON.stringify({ requestId: 'req-1', pattern: 'ls', scope: 'session', decision: 'maybe' }),
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
expect(res.status).toBe(400);
|
|
471
|
+
|
|
472
|
+
await stopServer();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('returns 409 when no confirmation details available', async () => {
|
|
476
|
+
const session = makeIdleSession();
|
|
477
|
+
await startServer(() => session);
|
|
478
|
+
|
|
479
|
+
// Register without confirmationDetails
|
|
480
|
+
pendingInteractions.register('req-no-details', {
|
|
481
|
+
session,
|
|
482
|
+
conversationId: 'conv-1',
|
|
483
|
+
kind: 'secret',
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const res = await fetch(url('trust-rules'), {
|
|
487
|
+
method: 'POST',
|
|
488
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
489
|
+
body: JSON.stringify({ requestId: 'req-no-details', pattern: 'ls', scope: 'session', decision: 'allow' }),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
expect(res.status).toBe(409);
|
|
493
|
+
|
|
494
|
+
await stopServer();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('returns 403 when persistent decisions are not allowed', async () => {
|
|
498
|
+
const session = makeIdleSession();
|
|
499
|
+
await startServer(() => session);
|
|
500
|
+
|
|
501
|
+
pendingInteractions.register('req-no-persist', {
|
|
502
|
+
session,
|
|
503
|
+
conversationId: 'conv-1',
|
|
504
|
+
kind: 'confirmation',
|
|
505
|
+
confirmationDetails: {
|
|
506
|
+
toolName: 'shell_command',
|
|
507
|
+
input: { command: 'rm -rf' },
|
|
508
|
+
riskLevel: 'high',
|
|
509
|
+
allowlistOptions: [{ label: 'Allow', description: 'test', pattern: 'rm' }],
|
|
510
|
+
scopeOptions: [{ label: 'Session', scope: 'session' }],
|
|
511
|
+
persistentDecisionsAllowed: false,
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const res = await fetch(url('trust-rules'), {
|
|
516
|
+
method: 'POST',
|
|
517
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
518
|
+
body: JSON.stringify({ requestId: 'req-no-persist', pattern: 'rm', scope: 'session', decision: 'allow' }),
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(res.status).toBe(403);
|
|
522
|
+
|
|
523
|
+
await stopServer();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('returns 403 when pattern does not match allowlist', async () => {
|
|
527
|
+
const session = makeIdleSession();
|
|
528
|
+
await startServer(() => session);
|
|
529
|
+
|
|
530
|
+
pendingInteractions.register('req-bad-pattern', {
|
|
531
|
+
session,
|
|
532
|
+
conversationId: 'conv-1',
|
|
533
|
+
kind: 'confirmation',
|
|
534
|
+
confirmationDetails: {
|
|
535
|
+
toolName: 'shell_command',
|
|
536
|
+
input: { command: 'ls' },
|
|
537
|
+
riskLevel: 'medium',
|
|
538
|
+
allowlistOptions: [{ label: 'Allow ls', description: 'test', pattern: 'ls' }],
|
|
539
|
+
scopeOptions: [{ label: 'Session', scope: 'session' }],
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const res = await fetch(url('trust-rules'), {
|
|
544
|
+
method: 'POST',
|
|
545
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
546
|
+
body: JSON.stringify({ requestId: 'req-bad-pattern', pattern: 'rm', scope: 'session', decision: 'allow' }),
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
expect(res.status).toBe(403);
|
|
550
|
+
const body = await res.json() as { error: string };
|
|
551
|
+
expect(body.error).toContain('pattern');
|
|
552
|
+
|
|
553
|
+
await stopServer();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('returns 403 when scope does not match scope options', async () => {
|
|
557
|
+
const session = makeIdleSession();
|
|
558
|
+
await startServer(() => session);
|
|
559
|
+
|
|
560
|
+
pendingInteractions.register('req-bad-scope', {
|
|
561
|
+
session,
|
|
562
|
+
conversationId: 'conv-1',
|
|
563
|
+
kind: 'confirmation',
|
|
564
|
+
confirmationDetails: {
|
|
565
|
+
toolName: 'shell_command',
|
|
566
|
+
input: { command: 'ls' },
|
|
567
|
+
riskLevel: 'medium',
|
|
568
|
+
allowlistOptions: [{ label: 'Allow ls', description: 'test', pattern: 'ls' }],
|
|
569
|
+
scopeOptions: [{ label: 'Session', scope: 'session' }],
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const res = await fetch(url('trust-rules'), {
|
|
574
|
+
method: 'POST',
|
|
575
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
576
|
+
body: JSON.stringify({ requestId: 'req-bad-scope', pattern: 'ls', scope: 'global', decision: 'allow' }),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(res.status).toBe(403);
|
|
580
|
+
const body = await res.json() as { error: string };
|
|
581
|
+
expect(body.error).toContain('scope');
|
|
582
|
+
|
|
583
|
+
await stopServer();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('does not remove the pending interaction after adding trust rule', async () => {
|
|
587
|
+
const session = makeIdleSession();
|
|
588
|
+
await startServer(() => session);
|
|
589
|
+
|
|
590
|
+
pendingInteractions.register('req-keep', {
|
|
591
|
+
session,
|
|
592
|
+
conversationId: 'conv-1',
|
|
593
|
+
kind: 'confirmation',
|
|
594
|
+
confirmationDetails: {
|
|
595
|
+
toolName: 'shell_command',
|
|
596
|
+
input: { command: 'ls' },
|
|
597
|
+
riskLevel: 'medium',
|
|
598
|
+
allowlistOptions: [{ label: 'Allow ls', description: 'test', pattern: 'ls' }],
|
|
599
|
+
scopeOptions: [{ label: 'Session', scope: 'session' }],
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const res = await fetch(url('trust-rules'), {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
606
|
+
body: JSON.stringify({ requestId: 'req-keep', pattern: 'ls', scope: 'session', decision: 'allow' }),
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
expect(res.status).toBe(200);
|
|
610
|
+
|
|
611
|
+
// Interaction should still be present (not consumed)
|
|
612
|
+
expect(pendingInteractions.get('req-keep')).toBeDefined();
|
|
613
|
+
|
|
614
|
+
await stopServer();
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// ── Hub publisher integration ────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
describe('hub publisher registers pending interactions', () => {
|
|
621
|
+
test('confirmation_request events register pending interactions', async () => {
|
|
622
|
+
const confirmReceived: Array<{ requestId: string; decision: string }> = [];
|
|
623
|
+
|
|
624
|
+
const session = makeConfirmationEmittingSession({
|
|
625
|
+
confirmRequestId: 'auto-req-1',
|
|
626
|
+
toolName: 'shell_command',
|
|
627
|
+
onConfirmation: (reqId, dec) => {
|
|
628
|
+
confirmReceived.push({ requestId: reqId, decision: dec });
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
await startServer(() => session);
|
|
633
|
+
|
|
634
|
+
// Send a message that triggers a confirmation_request
|
|
635
|
+
const res = await fetch(url('messages'), {
|
|
636
|
+
method: 'POST',
|
|
637
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
638
|
+
body: JSON.stringify({ conversationKey: 'conv-auto', content: 'Run ls', sourceChannel: 'macos' }),
|
|
639
|
+
});
|
|
640
|
+
expect(res.status).toBe(202);
|
|
641
|
+
|
|
642
|
+
// Wait for the agent loop to emit the confirmation_request
|
|
643
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
644
|
+
|
|
645
|
+
// The pending interaction should have been auto-registered
|
|
646
|
+
const interaction = pendingInteractions.get('auto-req-1');
|
|
647
|
+
expect(interaction).toBeDefined();
|
|
648
|
+
expect(interaction!.kind).toBe('confirmation');
|
|
649
|
+
expect(interaction!.confirmationDetails?.toolName).toBe('shell_command');
|
|
650
|
+
|
|
651
|
+
// Now resolve it via the confirm endpoint
|
|
652
|
+
const confirmRes = await fetch(url('confirm'), {
|
|
653
|
+
method: 'POST',
|
|
654
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
655
|
+
body: JSON.stringify({ requestId: 'auto-req-1', decision: 'allow' }),
|
|
656
|
+
});
|
|
657
|
+
expect(confirmRes.status).toBe(200);
|
|
658
|
+
|
|
659
|
+
expect(confirmReceived).toHaveLength(1);
|
|
660
|
+
expect(confirmReceived[0].requestId).toBe('auto-req-1');
|
|
661
|
+
expect(confirmReceived[0].decision).toBe('allow');
|
|
662
|
+
|
|
663
|
+
await stopServer();
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// ── getByConversation ────────────────────────────────────────────────
|
|
668
|
+
|
|
669
|
+
describe('getByConversation', () => {
|
|
670
|
+
test('returns all pending interactions for a conversation', async () => {
|
|
671
|
+
const session = makeIdleSession();
|
|
672
|
+
await startServer(() => session);
|
|
673
|
+
|
|
674
|
+
pendingInteractions.register('req-a', {
|
|
675
|
+
session,
|
|
676
|
+
conversationId: 'conv-x',
|
|
677
|
+
kind: 'confirmation',
|
|
678
|
+
});
|
|
679
|
+
pendingInteractions.register('req-b', {
|
|
680
|
+
session,
|
|
681
|
+
conversationId: 'conv-x',
|
|
682
|
+
kind: 'secret',
|
|
683
|
+
});
|
|
684
|
+
pendingInteractions.register('req-c', {
|
|
685
|
+
session,
|
|
686
|
+
conversationId: 'conv-y',
|
|
687
|
+
kind: 'confirmation',
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const results = pendingInteractions.getByConversation('conv-x');
|
|
691
|
+
expect(results).toHaveLength(2);
|
|
692
|
+
expect(results.map((r) => r.requestId).sort()).toEqual(['req-a', 'req-b']);
|
|
693
|
+
|
|
694
|
+
const resultsY = pendingInteractions.getByConversation('conv-y');
|
|
695
|
+
expect(resultsY).toHaveLength(1);
|
|
696
|
+
expect(resultsY[0].requestId).toBe('req-c');
|
|
697
|
+
|
|
698
|
+
const resultsZ = pendingInteractions.getByConversation('conv-z');
|
|
699
|
+
expect(resultsZ).toHaveLength(0);
|
|
700
|
+
|
|
701
|
+
await stopServer();
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
});
|