vellum 0.2.2 → 0.2.8
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/bun.lock +68 -100
- package/package.json +3 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
- package/src/__tests__/handlers-twilio-config.test.ts +221 -0
- package/src/__tests__/ipc-snapshot.test.ts +20 -0
- package/src/__tests__/memory-regressions.test.ts +100 -2
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
- package/src/__tests__/public-ingress-urls.test.ts +206 -0
- package/src/__tests__/session-conflict-gate.test.ts +28 -25
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
- package/src/calls/call-domain.ts +3 -3
- package/src/calls/twilio-config.ts +25 -9
- package/src/calls/twilio-provider.ts +4 -4
- package/src/calls/twilio-routes.ts +10 -2
- package/src/calls/twilio-webhook-urls.ts +47 -0
- package/src/cli/map.ts +30 -6
- package/src/config/defaults.ts +5 -0
- package/src/config/schema.ts +34 -2
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +95 -4
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -0
- package/src/daemon/ipc-contract.ts +39 -1
- package/src/daemon/ride-shotgun-handler.ts +2 -1
- package/src/daemon/session-agent-loop.ts +37 -2
- package/src/daemon/session-conflict-gate.ts +18 -109
- package/src/daemon/session-tool-setup.ts +7 -0
- package/src/inbound/public-ingress-urls.ts +106 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conflict-intent.ts +114 -0
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +346 -149
- package/src/memory/job-handlers/conflict.ts +23 -1
- package/src/memory/runs-store.ts +0 -3
- package/src/memory/schema.ts +0 -4
- package/src/runtime/gateway-client.ts +36 -0
- package/src/runtime/http-server.ts +140 -2
- package/src/runtime/routes/channel-routes.ts +121 -79
- package/src/security/oauth-callback-registry.ts +56 -0
- package/src/security/oauth2.ts +174 -58
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/browser/api-map.ts +123 -50
- package/src/tools/claude-code/claude-code.ts +131 -1
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/workspace/commit-message-enrichment-service.ts +3 -3
- package/src/workspace/provider-commit-message-generator.ts +57 -14
- package/src/workspace/turn-commit.ts +6 -2
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
|
2
|
+
import { mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import * as net from 'node:net';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'handlers-twilio-cfg-test-'));
|
|
8
|
+
|
|
9
|
+
let rawConfigStore: Record<string, unknown> = {};
|
|
10
|
+
const saveRawConfigCalls: Record<string, unknown>[] = [];
|
|
11
|
+
|
|
12
|
+
mock.module('../config/loader.js', () => ({
|
|
13
|
+
getConfig: () => ({}),
|
|
14
|
+
loadConfig: () => ({}),
|
|
15
|
+
loadRawConfig: () => ({ ...rawConfigStore }),
|
|
16
|
+
saveRawConfig: (cfg: Record<string, unknown>) => {
|
|
17
|
+
saveRawConfigCalls.push(cfg);
|
|
18
|
+
rawConfigStore = { ...cfg };
|
|
19
|
+
},
|
|
20
|
+
saveConfig: () => {},
|
|
21
|
+
invalidateConfigCache: () => {},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module('../util/platform.js', () => ({
|
|
25
|
+
getRootDir: () => testDir,
|
|
26
|
+
getDataDir: () => testDir,
|
|
27
|
+
getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
|
|
28
|
+
isMacOS: () => process.platform === 'darwin',
|
|
29
|
+
isLinux: () => process.platform === 'linux',
|
|
30
|
+
isWindows: () => process.platform === 'win32',
|
|
31
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
32
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
33
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
34
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
35
|
+
ensureDataDir: () => {},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
mock.module('../util/logger.js', () => ({
|
|
39
|
+
getLogger: () => ({
|
|
40
|
+
info: () => {},
|
|
41
|
+
warn: () => {},
|
|
42
|
+
error: () => {},
|
|
43
|
+
debug: () => {},
|
|
44
|
+
trace: () => {},
|
|
45
|
+
fatal: () => {},
|
|
46
|
+
child: () => ({
|
|
47
|
+
info: () => {},
|
|
48
|
+
warn: () => {},
|
|
49
|
+
error: () => {},
|
|
50
|
+
debug: () => {},
|
|
51
|
+
}),
|
|
52
|
+
}),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
mock.module('../memory/app-store.js', () => ({
|
|
56
|
+
queryAppRecords: () => [],
|
|
57
|
+
createAppRecord: () => {},
|
|
58
|
+
updateAppRecord: () => {},
|
|
59
|
+
deleteAppRecord: () => {},
|
|
60
|
+
listApps: () => [],
|
|
61
|
+
getApp: () => undefined,
|
|
62
|
+
createApp: () => {},
|
|
63
|
+
updateApp: () => {},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
mock.module('../slack/slack-webhook.js', () => ({
|
|
67
|
+
postToSlackWebhook: async () => {},
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
import { handleMessage, type HandlerContext } from '../daemon/handlers.js';
|
|
71
|
+
import type {
|
|
72
|
+
TwilioWebhookConfigRequest,
|
|
73
|
+
ServerMessage,
|
|
74
|
+
} from '../daemon/ipc-contract.js';
|
|
75
|
+
|
|
76
|
+
function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
|
|
77
|
+
const sent: ServerMessage[] = [];
|
|
78
|
+
const ctx: HandlerContext = {
|
|
79
|
+
sessions: new Map(),
|
|
80
|
+
socketToSession: new Map(),
|
|
81
|
+
cuSessions: new Map(),
|
|
82
|
+
socketToCuSession: new Map(),
|
|
83
|
+
cuObservationParseSequence: new Map(),
|
|
84
|
+
socketSandboxOverride: new Map(),
|
|
85
|
+
sharedRequestTimestamps: [],
|
|
86
|
+
debounceTimers: new Map(),
|
|
87
|
+
suppressConfigReload: false,
|
|
88
|
+
setSuppressConfigReload: () => {},
|
|
89
|
+
updateConfigFingerprint: () => {},
|
|
90
|
+
send: (_socket, msg) => { sent.push(msg); },
|
|
91
|
+
broadcast: () => {},
|
|
92
|
+
clearAllSessions: () => 0,
|
|
93
|
+
getOrCreateSession: () => { throw new Error('not implemented'); },
|
|
94
|
+
touchSession: () => {},
|
|
95
|
+
};
|
|
96
|
+
return { ctx, sent };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('Twilio webhook config handler', () => {
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
rawConfigStore = {};
|
|
102
|
+
saveRawConfigCalls.length = 0;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('get returns empty string when no config set', () => {
|
|
106
|
+
rawConfigStore = {};
|
|
107
|
+
|
|
108
|
+
const msg: TwilioWebhookConfigRequest = {
|
|
109
|
+
type: 'twilio_webhook_config',
|
|
110
|
+
action: 'get',
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const { ctx, sent } = createTestContext();
|
|
114
|
+
handleMessage(msg, {} as net.Socket, ctx);
|
|
115
|
+
|
|
116
|
+
expect(sent).toHaveLength(1);
|
|
117
|
+
const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
118
|
+
expect(res.type).toBe('twilio_webhook_config_response');
|
|
119
|
+
expect(res.success).toBe(true);
|
|
120
|
+
expect(res.webhookBaseUrl).toBe('');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('set persists value and returns it', () => {
|
|
124
|
+
rawConfigStore = {};
|
|
125
|
+
|
|
126
|
+
const msg: TwilioWebhookConfigRequest = {
|
|
127
|
+
type: 'twilio_webhook_config',
|
|
128
|
+
action: 'set',
|
|
129
|
+
webhookBaseUrl: 'https://example.com/twilio',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const { ctx, sent } = createTestContext();
|
|
133
|
+
handleMessage(msg, {} as net.Socket, ctx);
|
|
134
|
+
|
|
135
|
+
expect(sent).toHaveLength(1);
|
|
136
|
+
const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
137
|
+
expect(res.type).toBe('twilio_webhook_config_response');
|
|
138
|
+
expect(res.success).toBe(true);
|
|
139
|
+
expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
|
|
140
|
+
|
|
141
|
+
expect(saveRawConfigCalls).toHaveLength(1);
|
|
142
|
+
const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
|
|
143
|
+
expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('set normalizes trailing slashes', () => {
|
|
147
|
+
rawConfigStore = {};
|
|
148
|
+
|
|
149
|
+
const msg: TwilioWebhookConfigRequest = {
|
|
150
|
+
type: 'twilio_webhook_config',
|
|
151
|
+
action: 'set',
|
|
152
|
+
webhookBaseUrl: 'https://example.com/twilio///',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const { ctx, sent } = createTestContext();
|
|
156
|
+
handleMessage(msg, {} as net.Socket, ctx);
|
|
157
|
+
|
|
158
|
+
expect(sent).toHaveLength(1);
|
|
159
|
+
const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
160
|
+
expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
|
|
161
|
+
|
|
162
|
+
const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
|
|
163
|
+
expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('set treats empty string as unset', () => {
|
|
167
|
+
rawConfigStore = { calls: { webhookBaseUrl: 'https://example.com/twilio' } };
|
|
168
|
+
saveRawConfigCalls.length = 0;
|
|
169
|
+
|
|
170
|
+
const msg: TwilioWebhookConfigRequest = {
|
|
171
|
+
type: 'twilio_webhook_config',
|
|
172
|
+
action: 'set',
|
|
173
|
+
webhookBaseUrl: '',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const { ctx, sent } = createTestContext();
|
|
177
|
+
handleMessage(msg, {} as net.Socket, ctx);
|
|
178
|
+
|
|
179
|
+
expect(sent).toHaveLength(1);
|
|
180
|
+
const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
181
|
+
expect(res.success).toBe(true);
|
|
182
|
+
expect(res.webhookBaseUrl).toBe('');
|
|
183
|
+
|
|
184
|
+
const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
|
|
185
|
+
expect(saved.calls?.webhookBaseUrl).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('get after set roundtrip works', () => {
|
|
189
|
+
rawConfigStore = {};
|
|
190
|
+
|
|
191
|
+
// Set
|
|
192
|
+
const setMsg: TwilioWebhookConfigRequest = {
|
|
193
|
+
type: 'twilio_webhook_config',
|
|
194
|
+
action: 'set',
|
|
195
|
+
webhookBaseUrl: 'https://my-server.ngrok.io',
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const { ctx: setCtx, sent: setSent } = createTestContext();
|
|
199
|
+
handleMessage(setMsg, {} as net.Socket, setCtx);
|
|
200
|
+
|
|
201
|
+
expect(setSent).toHaveLength(1);
|
|
202
|
+
const setRes = setSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
203
|
+
expect(setRes.success).toBe(true);
|
|
204
|
+
expect(setRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
|
|
205
|
+
|
|
206
|
+
// Get (rawConfigStore was updated by the mock saveRawConfig)
|
|
207
|
+
const getMsg: TwilioWebhookConfigRequest = {
|
|
208
|
+
type: 'twilio_webhook_config',
|
|
209
|
+
action: 'get',
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const { ctx: getCtx, sent: getSent } = createTestContext();
|
|
213
|
+
handleMessage(getMsg, {} as net.Socket, getCtx);
|
|
214
|
+
|
|
215
|
+
expect(getSent).toHaveLength(1);
|
|
216
|
+
const getRes = getSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
217
|
+
expect(getRes.type).toBe('twilio_webhook_config_response');
|
|
218
|
+
expect(getRes.success).toBe(true);
|
|
219
|
+
expect(getRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -339,6 +339,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
|
|
|
339
339
|
type: 'slack_webhook_config',
|
|
340
340
|
action: 'get',
|
|
341
341
|
},
|
|
342
|
+
twilio_webhook_config: {
|
|
343
|
+
type: 'twilio_webhook_config',
|
|
344
|
+
action: 'get',
|
|
345
|
+
},
|
|
342
346
|
vercel_api_config: {
|
|
343
347
|
type: 'vercel_api_config',
|
|
344
348
|
action: 'get',
|
|
@@ -1125,6 +1129,11 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
1125
1129
|
webhookUrl: 'https://hooks.slack.com/services/T00/B00/xxx',
|
|
1126
1130
|
success: true,
|
|
1127
1131
|
},
|
|
1132
|
+
twilio_webhook_config_response: {
|
|
1133
|
+
type: 'twilio_webhook_config_response',
|
|
1134
|
+
webhookBaseUrl: 'https://example.com/twilio',
|
|
1135
|
+
success: true,
|
|
1136
|
+
},
|
|
1128
1137
|
vercel_api_config_response: {
|
|
1129
1138
|
type: 'vercel_api_config_response',
|
|
1130
1139
|
hasToken: true,
|
|
@@ -1420,6 +1429,17 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
1420
1429
|
sessionId: 'sub-sess-001',
|
|
1421
1430
|
},
|
|
1422
1431
|
},
|
|
1432
|
+
agent_heartbeat_alert: {
|
|
1433
|
+
type: 'agent_heartbeat_alert',
|
|
1434
|
+
title: 'Agent unresponsive',
|
|
1435
|
+
body: 'The agent has not responded for 5 minutes.',
|
|
1436
|
+
},
|
|
1437
|
+
task_run_thread_created: {
|
|
1438
|
+
type: 'task_run_thread_created',
|
|
1439
|
+
conversationId: 'conv-task-001',
|
|
1440
|
+
workItemId: 'work-item-001',
|
|
1441
|
+
title: 'Task run thread',
|
|
1442
|
+
},
|
|
1423
1443
|
};
|
|
1424
1444
|
|
|
1425
1445
|
// ---------------------------------------------------------------------------
|
|
@@ -949,7 +949,7 @@ describe('Memory regressions', () => {
|
|
|
949
949
|
id: 'msg-conflicts-bg',
|
|
950
950
|
conversationId: 'conv-conflicts-bg',
|
|
951
951
|
role: 'user',
|
|
952
|
-
content: JSON.stringify([{ type: 'text', text: 'Keep the new
|
|
952
|
+
content: JSON.stringify([{ type: 'text', text: 'Keep the new MySQL default instead.' }]),
|
|
953
953
|
createdAt: now + 1,
|
|
954
954
|
}).run();
|
|
955
955
|
|
|
@@ -1047,7 +1047,7 @@ describe('Memory regressions', () => {
|
|
|
1047
1047
|
id: 'msg-conflicts-age',
|
|
1048
1048
|
conversationId: 'conv-conflicts-age',
|
|
1049
1049
|
role: 'user',
|
|
1050
|
-
content: JSON.stringify([{ type: 'text', text: 'Keep the new
|
|
1050
|
+
content: JSON.stringify([{ type: 'text', text: 'Keep the new Bun runtime instead.' }]),
|
|
1051
1051
|
createdAt: now + 1,
|
|
1052
1052
|
}).run();
|
|
1053
1053
|
|
|
@@ -1118,6 +1118,104 @@ describe('Memory regressions', () => {
|
|
|
1118
1118
|
}
|
|
1119
1119
|
});
|
|
1120
1120
|
|
|
1121
|
+
test('background conflict resolver ignores clarification-like replies with no topical overlap when conflict was never asked', async () => {
|
|
1122
|
+
const db = getDb();
|
|
1123
|
+
const now = 1_700_001_400_000;
|
|
1124
|
+
const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
|
|
1125
|
+
TEST_CONFIG.memory.conflicts.enabled = true;
|
|
1126
|
+
|
|
1127
|
+
try {
|
|
1128
|
+
db.insert(conversations).values({
|
|
1129
|
+
id: 'conv-conflicts-unrelated',
|
|
1130
|
+
title: null,
|
|
1131
|
+
createdAt: now,
|
|
1132
|
+
updatedAt: now,
|
|
1133
|
+
totalInputTokens: 0,
|
|
1134
|
+
totalOutputTokens: 0,
|
|
1135
|
+
totalEstimatedCost: 0,
|
|
1136
|
+
contextSummary: null,
|
|
1137
|
+
contextCompactedMessageCount: 0,
|
|
1138
|
+
contextCompactedAt: null,
|
|
1139
|
+
}).run();
|
|
1140
|
+
|
|
1141
|
+
db.insert(messages).values({
|
|
1142
|
+
id: 'msg-conflicts-unrelated',
|
|
1143
|
+
conversationId: 'conv-conflicts-unrelated',
|
|
1144
|
+
role: 'user',
|
|
1145
|
+
content: JSON.stringify([{ type: 'text', text: 'Keep the new one instead.' }]),
|
|
1146
|
+
createdAt: now + 1,
|
|
1147
|
+
}).run();
|
|
1148
|
+
|
|
1149
|
+
db.insert(memoryItems).values([
|
|
1150
|
+
{
|
|
1151
|
+
id: 'item-conflict-existing-unrelated',
|
|
1152
|
+
kind: 'preference',
|
|
1153
|
+
subject: 'database',
|
|
1154
|
+
statement: 'Use Postgres by default.',
|
|
1155
|
+
status: 'active',
|
|
1156
|
+
confidence: 0.8,
|
|
1157
|
+
fingerprint: 'fp-conflict-existing-unrelated',
|
|
1158
|
+
verificationState: 'assistant_inferred',
|
|
1159
|
+
scopeId: 'scope-conflicts-unrelated',
|
|
1160
|
+
firstSeenAt: now - 10_000,
|
|
1161
|
+
lastSeenAt: now - 5_000,
|
|
1162
|
+
validFrom: now - 10_000,
|
|
1163
|
+
invalidAt: null,
|
|
1164
|
+
},
|
|
1165
|
+
{
|
|
1166
|
+
id: 'item-conflict-candidate-unrelated',
|
|
1167
|
+
kind: 'preference',
|
|
1168
|
+
subject: 'database',
|
|
1169
|
+
statement: 'Use MySQL by default.',
|
|
1170
|
+
status: 'pending_clarification',
|
|
1171
|
+
confidence: 0.8,
|
|
1172
|
+
fingerprint: 'fp-conflict-candidate-unrelated',
|
|
1173
|
+
verificationState: 'assistant_inferred',
|
|
1174
|
+
scopeId: 'scope-conflicts-unrelated',
|
|
1175
|
+
firstSeenAt: now - 9_000,
|
|
1176
|
+
lastSeenAt: now - 4_000,
|
|
1177
|
+
validFrom: now - 9_000,
|
|
1178
|
+
invalidAt: null,
|
|
1179
|
+
},
|
|
1180
|
+
]).run();
|
|
1181
|
+
|
|
1182
|
+
const conflict = createOrUpdatePendingConflict({
|
|
1183
|
+
scopeId: 'scope-conflicts-unrelated',
|
|
1184
|
+
existingItemId: 'item-conflict-existing-unrelated',
|
|
1185
|
+
candidateItemId: 'item-conflict-candidate-unrelated',
|
|
1186
|
+
relationship: 'ambiguous_contradiction',
|
|
1187
|
+
});
|
|
1188
|
+
db.update(memoryItemConflicts)
|
|
1189
|
+
.set({ createdAt: now, updatedAt: now, lastAskedAt: null })
|
|
1190
|
+
.where(eq(memoryItemConflicts.id, conflict.id))
|
|
1191
|
+
.run();
|
|
1192
|
+
|
|
1193
|
+
enqueueResolvePendingConflictsForMessageJob('msg-conflicts-unrelated', 'scope-conflicts-unrelated');
|
|
1194
|
+
const processed = await runMemoryJobsOnce();
|
|
1195
|
+
expect(processed).toBe(1);
|
|
1196
|
+
|
|
1197
|
+
const existing = db
|
|
1198
|
+
.select()
|
|
1199
|
+
.from(memoryItems)
|
|
1200
|
+
.where(eq(memoryItems.id, 'item-conflict-existing-unrelated'))
|
|
1201
|
+
.get();
|
|
1202
|
+
const candidate = db
|
|
1203
|
+
.select()
|
|
1204
|
+
.from(memoryItems)
|
|
1205
|
+
.where(eq(memoryItems.id, 'item-conflict-candidate-unrelated'))
|
|
1206
|
+
.get();
|
|
1207
|
+
const updatedConflict = getConflictById(conflict.id);
|
|
1208
|
+
|
|
1209
|
+
expect(existing?.status).toBe('active');
|
|
1210
|
+
expect(existing?.invalidAt).toBeNull();
|
|
1211
|
+
expect(candidate?.status).toBe('pending_clarification');
|
|
1212
|
+
expect(updatedConflict?.status).toBe('pending_clarification');
|
|
1213
|
+
expect(updatedConflict?.resolutionNote).toBeNull();
|
|
1214
|
+
} finally {
|
|
1215
|
+
TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1121
1219
|
test('cleanup job enqueue is deduped and retention overrides upgrade payload', () => {
|
|
1122
1220
|
const db = getDb();
|
|
1123
1221
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
registerPendingCallback,
|
|
4
|
+
consumeCallback,
|
|
5
|
+
consumeCallbackError,
|
|
6
|
+
clearAllCallbacks,
|
|
7
|
+
} from '../security/oauth-callback-registry.js';
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
clearAllCallbacks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('OAuth callback registry', () => {
|
|
14
|
+
test('registerPendingCallback + consumeCallback resolves with code', async () => {
|
|
15
|
+
const promise = new Promise<string>((resolve, reject) => {
|
|
16
|
+
registerPendingCallback('state-1', resolve, reject);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const consumed = consumeCallback('state-1', 'auth-code-123');
|
|
20
|
+
expect(consumed).toBe(true);
|
|
21
|
+
|
|
22
|
+
const code = await promise;
|
|
23
|
+
expect(code).toBe('auth-code-123');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('consumeCallback with unknown state returns false', () => {
|
|
27
|
+
const consumed = consumeCallback('nonexistent', 'code');
|
|
28
|
+
expect(consumed).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('consumeCallbackError rejects the pending callback', async () => {
|
|
32
|
+
const promise = new Promise<string>((resolve, reject) => {
|
|
33
|
+
registerPendingCallback('state-err', resolve, reject);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const consumed = consumeCallbackError('state-err', 'access_denied');
|
|
37
|
+
expect(consumed).toBe(true);
|
|
38
|
+
|
|
39
|
+
await expect(promise).rejects.toThrow('access_denied');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('consumeCallbackError with unknown state returns false', () => {
|
|
43
|
+
const consumed = consumeCallbackError('nonexistent', 'some error');
|
|
44
|
+
expect(consumed).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('duplicate consumeCallback returns false on second call', async () => {
|
|
48
|
+
const promise = new Promise<string>((resolve, reject) => {
|
|
49
|
+
registerPendingCallback('state-dup', resolve, reject);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const first = consumeCallback('state-dup', 'code-1');
|
|
53
|
+
expect(first).toBe(true);
|
|
54
|
+
|
|
55
|
+
const second = consumeCallback('state-dup', 'code-2');
|
|
56
|
+
expect(second).toBe(false);
|
|
57
|
+
|
|
58
|
+
const code = await promise;
|
|
59
|
+
expect(code).toBe('code-1');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('TTL expiry rejects callback with timeout error', async () => {
|
|
63
|
+
const promise = new Promise<string>((resolve, reject) => {
|
|
64
|
+
registerPendingCallback('state-ttl', resolve, reject, 50);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Wait for the TTL to expire
|
|
68
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
69
|
+
|
|
70
|
+
await expect(promise).rejects.toThrow('OAuth callback timed out');
|
|
71
|
+
|
|
72
|
+
// After expiry, consume should return false
|
|
73
|
+
const consumed = consumeCallback('state-ttl', 'late-code');
|
|
74
|
+
expect(consumed).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('clearAllCallbacks cleans up all pending entries', () => {
|
|
78
|
+
registerPendingCallback('s1', () => {}, () => {});
|
|
79
|
+
registerPendingCallback('s2', () => {}, () => {});
|
|
80
|
+
clearAllCallbacks();
|
|
81
|
+
|
|
82
|
+
expect(consumeCallback('s1', 'code')).toBe(false);
|
|
83
|
+
expect(consumeCallback('s2', 'code')).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|