@vellumai/assistant 0.5.8 → 0.5.10
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/AGENTS.md +1 -1
- package/ARCHITECTURE.md +8 -8
- package/README.md +1 -1
- package/docs/architecture/integrations.md +4 -4
- package/docs/architecture/keychain-broker.md +17 -18
- package/docs/architecture/security.md +5 -5
- package/eslint.config.mjs +0 -31
- package/package.json +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
- package/src/__tests__/credentials-cli.test.ts +3 -3
- package/src/__tests__/stt-hints.test.ts +22 -22
- package/src/__tests__/voice-quality.test.ts +2 -2
- package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +5 -238
- package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -206
- package/src/__tests__/workspace-migrations-runner.test.ts +15 -7
- package/src/cli/commands/credentials.ts +4 -4
- package/src/cli/commands/oauth/apps.ts +3 -3
- package/src/daemon/conversation-agent-loop.ts +6 -0
- package/src/daemon/conversation-runtime-assembly.ts +61 -1
- package/src/daemon/lifecycle.ts +2 -3
- package/src/memory/migrations/validate-migration-state.ts +14 -1
- package/src/prompts/system-prompt.ts +22 -0
- package/src/prompts/templates/NOW.md +26 -0
- package/src/prompts/templates/SOUL.md +10 -0
- package/src/prompts/update-bulletin-format.ts +0 -2
- package/src/runtime/routes/settings-routes.ts +1 -1
- package/src/skills/inline-command-expansions.ts +7 -7
- package/src/tools/sensitive-output-placeholders.ts +2 -2
- package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +13 -148
- package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +7 -145
- package/src/workspace/migrations/AGENTS.md +11 -0
- package/src/workspace/migrations/runner.ts +16 -6
- package/src/workspace/migrations/types.ts +7 -0
- package/src/__tests__/keychain-broker-client.test.ts +0 -800
- package/src/security/keychain-broker-client.ts +0 -446
|
@@ -6,8 +6,8 @@ import type { ContactWithChannels } from "../contacts/types.js";
|
|
|
6
6
|
// Mock state for resolveCallHints tests
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
let mockAssistantName: string | null = "
|
|
10
|
-
let mockGuardianName: string = "
|
|
9
|
+
let mockAssistantName: string | null = "Nova";
|
|
10
|
+
let mockGuardianName: string = "Alex";
|
|
11
11
|
let mockTargetContact: ContactWithChannels | null = null;
|
|
12
12
|
let mockRecentContacts: ContactWithChannels[] = [];
|
|
13
13
|
let mockFindContactByAddressThrows = false;
|
|
@@ -93,14 +93,14 @@ describe("buildSttHints", () => {
|
|
|
93
93
|
|
|
94
94
|
test("assistant name included", () => {
|
|
95
95
|
const input = emptyInput();
|
|
96
|
-
input.assistantName = "
|
|
97
|
-
expect(buildSttHints(input)).toBe("
|
|
96
|
+
input.assistantName = "Nova";
|
|
97
|
+
expect(buildSttHints(input)).toBe("Nova");
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
test("guardian name included", () => {
|
|
101
101
|
const input = emptyInput();
|
|
102
|
-
input.guardianName = "
|
|
103
|
-
expect(buildSttHints(input)).toBe("
|
|
102
|
+
input.guardianName = "Alex";
|
|
103
|
+
expect(buildSttHints(input)).toBe("Alex");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
test('default guardian name "my human" excluded', () => {
|
|
@@ -214,8 +214,8 @@ describe("buildSttHints", () => {
|
|
|
214
214
|
test("all sources combined in correct order", () => {
|
|
215
215
|
const input: SttHintsInput = {
|
|
216
216
|
staticHints: ["StaticOne"],
|
|
217
|
-
assistantName: "
|
|
218
|
-
guardianName: "
|
|
217
|
+
assistantName: "Nova",
|
|
218
|
+
guardianName: "Alex",
|
|
219
219
|
taskDescription: "Call John at Acme",
|
|
220
220
|
targetContactName: "Target",
|
|
221
221
|
callerContactName: "Caller",
|
|
@@ -227,8 +227,8 @@ describe("buildSttHints", () => {
|
|
|
227
227
|
const parts = result.split(",");
|
|
228
228
|
// Verify all expected hints are present
|
|
229
229
|
expect(parts).toContain("StaticOne");
|
|
230
|
-
expect(parts).toContain("
|
|
231
|
-
expect(parts).toContain("
|
|
230
|
+
expect(parts).toContain("Nova");
|
|
231
|
+
expect(parts).toContain("Alex");
|
|
232
232
|
expect(parts).toContain("John");
|
|
233
233
|
expect(parts).toContain("Acme");
|
|
234
234
|
expect(parts).toContain("Target");
|
|
@@ -308,8 +308,8 @@ function makeContact(displayName: string): ContactWithChannels {
|
|
|
308
308
|
|
|
309
309
|
describe("resolveCallHints", () => {
|
|
310
310
|
beforeEach(() => {
|
|
311
|
-
mockAssistantName = "
|
|
312
|
-
mockGuardianName = "
|
|
311
|
+
mockAssistantName = "Nova";
|
|
312
|
+
mockGuardianName = "Alex";
|
|
313
313
|
mockTargetContact = null;
|
|
314
314
|
mockRecentContacts = [];
|
|
315
315
|
mockFindContactByAddressThrows = false;
|
|
@@ -334,8 +334,8 @@ describe("resolveCallHints", () => {
|
|
|
334
334
|
const parts = result.split(",");
|
|
335
335
|
|
|
336
336
|
expect(parts).toContain("StaticHint");
|
|
337
|
-
expect(parts).toContain("
|
|
338
|
-
expect(parts).toContain("
|
|
337
|
+
expect(parts).toContain("Nova");
|
|
338
|
+
expect(parts).toContain("Alex");
|
|
339
339
|
expect(parts).toContain("Alice");
|
|
340
340
|
expect(parts).toContain("Dave");
|
|
341
341
|
expect(parts).toContain("Eve");
|
|
@@ -365,8 +365,8 @@ describe("resolveCallHints", () => {
|
|
|
365
365
|
|
|
366
366
|
// Target contact should be absent (lookup failed)
|
|
367
367
|
// But other sources should still work
|
|
368
|
-
expect(parts).toContain("
|
|
369
|
-
expect(parts).toContain("
|
|
368
|
+
expect(parts).toContain("Nova");
|
|
369
|
+
expect(parts).toContain("Alex");
|
|
370
370
|
expect(parts).toContain("Bob");
|
|
371
371
|
expect(logWarnFn).toHaveBeenCalled();
|
|
372
372
|
});
|
|
@@ -390,8 +390,8 @@ describe("resolveCallHints", () => {
|
|
|
390
390
|
|
|
391
391
|
// Recent contacts should be absent (listing failed)
|
|
392
392
|
// But other sources should still work
|
|
393
|
-
expect(parts).toContain("
|
|
394
|
-
expect(parts).toContain("
|
|
393
|
+
expect(parts).toContain("Nova");
|
|
394
|
+
expect(parts).toContain("Alex");
|
|
395
395
|
expect(parts).toContain("Alice");
|
|
396
396
|
expect(logWarnFn).toHaveBeenCalled();
|
|
397
397
|
});
|
|
@@ -414,8 +414,8 @@ describe("resolveCallHints", () => {
|
|
|
414
414
|
|
|
415
415
|
// For inbound, the contact found via fromNumber should appear as caller, not target
|
|
416
416
|
expect(parts).toContain("Alice");
|
|
417
|
-
expect(parts).toContain("
|
|
418
|
-
expect(parts).toContain("
|
|
417
|
+
expect(parts).toContain("Nova");
|
|
418
|
+
expect(parts).toContain("Alex");
|
|
419
419
|
expect(parts).toContain("Bob");
|
|
420
420
|
expect(logWarnFn).not.toHaveBeenCalled();
|
|
421
421
|
});
|
|
@@ -427,8 +427,8 @@ describe("resolveCallHints", () => {
|
|
|
427
427
|
const parts = result.split(",");
|
|
428
428
|
|
|
429
429
|
expect(parts).toContain("Static");
|
|
430
|
-
expect(parts).toContain("
|
|
431
|
-
expect(parts).toContain("
|
|
430
|
+
expect(parts).toContain("Nova");
|
|
431
|
+
expect(parts).toContain("Alex");
|
|
432
432
|
expect(parts).toContain("RecentOne");
|
|
433
433
|
expect(parts).toContain("RecentTwo");
|
|
434
434
|
// No target contact lookup should have been attempted (no session)
|
|
@@ -175,11 +175,11 @@ describe("resolveVoiceQualityProfile", () => {
|
|
|
175
175
|
voice: {
|
|
176
176
|
language: "en-US",
|
|
177
177
|
transcriptionProvider: "Deepgram",
|
|
178
|
-
hints: ["Vellum", "
|
|
178
|
+
hints: ["Vellum", "Nova", "AI assistant"],
|
|
179
179
|
},
|
|
180
180
|
},
|
|
181
181
|
};
|
|
182
182
|
const profile = resolveVoiceQualityProfile();
|
|
183
|
-
expect(profile.hints).toEqual(["Vellum", "
|
|
183
|
+
expect(profile.hints).toEqual(["Vellum", "Nova", "AI assistant"]);
|
|
184
184
|
});
|
|
185
185
|
});
|
|
@@ -1,252 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// Mock state
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
|
|
7
|
-
const isAvailableFn = mock((): boolean => true);
|
|
8
|
-
const brokerSetFn = mock(
|
|
9
|
-
async (
|
|
10
|
-
_account: string,
|
|
11
|
-
_value: string,
|
|
12
|
-
): Promise<{ status: string; code?: string; message?: string }> => ({
|
|
13
|
-
status: "ok",
|
|
14
|
-
}),
|
|
15
|
-
);
|
|
16
|
-
const createBrokerClientFn = mock(() => ({
|
|
17
|
-
isAvailable: isAvailableFn,
|
|
18
|
-
set: brokerSetFn,
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
const listKeysFn = mock((): string[] => []);
|
|
22
|
-
const getKeyFn = mock((_account: string): string | undefined => undefined);
|
|
23
|
-
const deleteKeyFn = mock(
|
|
24
|
-
(_account: string): "deleted" | "not-found" | "error" => "deleted",
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Mock modules — before importing module under test
|
|
29
|
-
//
|
|
30
|
-
// The logger is mocked with a silent Proxy to suppress pino output in tests.
|
|
31
|
-
// The broker client and encrypted store are mocked to control migration
|
|
32
|
-
// behavior without touching real keychain or filesystem state.
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
mock.module("../util/logger.js", () => ({
|
|
36
|
-
getLogger: () =>
|
|
37
|
-
new Proxy({} as Record<string, unknown>, {
|
|
38
|
-
get: () => () => {},
|
|
39
|
-
}),
|
|
40
|
-
}));
|
|
41
|
-
|
|
42
|
-
mock.module("../security/keychain-broker-client.js", () => ({
|
|
43
|
-
createBrokerClient: createBrokerClientFn,
|
|
44
|
-
}));
|
|
45
|
-
|
|
46
|
-
mock.module("../security/encrypted-store.js", () => ({
|
|
47
|
-
listKeys: listKeysFn,
|
|
48
|
-
getKey: getKeyFn,
|
|
49
|
-
deleteKey: deleteKeyFn,
|
|
50
|
-
}));
|
|
51
|
-
|
|
52
|
-
// Import after mocking
|
|
53
3
|
import { migrateCredentialsToKeychainMigration } from "../workspace/migrations/015-migrate-credentials-to-keychain.js";
|
|
54
4
|
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
// Helpers
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
|
|
59
|
-
const WORKSPACE_DIR = "/mock-home/.vellum/workspace";
|
|
60
|
-
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
// Tests
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
|
|
65
5
|
describe("015-migrate-credentials-to-keychain migration", () => {
|
|
66
|
-
beforeEach(() => {
|
|
67
|
-
isAvailableFn.mockClear();
|
|
68
|
-
brokerSetFn.mockClear();
|
|
69
|
-
createBrokerClientFn.mockClear();
|
|
70
|
-
listKeysFn.mockClear();
|
|
71
|
-
getKeyFn.mockClear();
|
|
72
|
-
deleteKeyFn.mockClear();
|
|
73
|
-
|
|
74
|
-
// Defaults: mac production build
|
|
75
|
-
process.env.VELLUM_DESKTOP_APP = "1";
|
|
76
|
-
delete process.env.VELLUM_DEV;
|
|
77
|
-
|
|
78
|
-
isAvailableFn.mockReturnValue(true);
|
|
79
|
-
brokerSetFn.mockResolvedValue({ status: "ok" });
|
|
80
|
-
listKeysFn.mockReturnValue([]);
|
|
81
|
-
getKeyFn.mockReturnValue(undefined);
|
|
82
|
-
deleteKeyFn.mockReturnValue("deleted");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
6
|
test("has correct migration id", () => {
|
|
86
7
|
expect(migrateCredentialsToKeychainMigration.id).toBe(
|
|
87
8
|
"015-migrate-credentials-to-keychain",
|
|
88
9
|
);
|
|
89
10
|
});
|
|
90
11
|
|
|
91
|
-
test("
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
|
|
95
|
-
|
|
96
|
-
expect(createBrokerClientFn).not.toHaveBeenCalled();
|
|
97
|
-
expect(listKeysFn).not.toHaveBeenCalled();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("skips when VELLUM_DESKTOP_APP is not '1'", async () => {
|
|
101
|
-
process.env.VELLUM_DESKTOP_APP = "0";
|
|
102
|
-
|
|
103
|
-
await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
|
|
104
|
-
|
|
105
|
-
expect(createBrokerClientFn).not.toHaveBeenCalled();
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("skips when VELLUM_DEV=1", async () => {
|
|
109
|
-
process.env.VELLUM_DEV = "1";
|
|
110
|
-
|
|
111
|
-
await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
|
|
112
|
-
|
|
113
|
-
expect(createBrokerClientFn).not.toHaveBeenCalled();
|
|
114
|
-
expect(listKeysFn).not.toHaveBeenCalled();
|
|
12
|
+
test("run is a no-op", async () => {
|
|
13
|
+
await migrateCredentialsToKeychainMigration.run("/fake");
|
|
115
14
|
});
|
|
116
15
|
|
|
117
|
-
test(
|
|
118
|
-
|
|
119
|
-
async () => {
|
|
120
|
-
isAvailableFn.mockReturnValue(false);
|
|
121
|
-
|
|
122
|
-
await expect(
|
|
123
|
-
migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR),
|
|
124
|
-
).rejects.toThrow(
|
|
125
|
-
"Keychain broker not available after waiting — credential migration will be retried on next startup",
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
// Should have retried isAvailable multiple times
|
|
129
|
-
expect(isAvailableFn.mock.calls.length).toBeGreaterThan(1);
|
|
130
|
-
|
|
131
|
-
// Should not proceed to list or migrate keys
|
|
132
|
-
expect(listKeysFn).not.toHaveBeenCalled();
|
|
133
|
-
expect(brokerSetFn).not.toHaveBeenCalled();
|
|
134
|
-
},
|
|
135
|
-
{ timeout: 10_000 },
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
test("succeeds when broker becomes available after retry", async () => {
|
|
139
|
-
// Broker unavailable for first 3 calls, then available
|
|
140
|
-
let callCount = 0;
|
|
141
|
-
isAvailableFn.mockImplementation(() => {
|
|
142
|
-
callCount++;
|
|
143
|
-
return callCount > 3;
|
|
144
|
-
});
|
|
145
|
-
listKeysFn.mockReturnValue(["retry-key"]);
|
|
146
|
-
getKeyFn.mockReturnValue("retry-secret");
|
|
147
|
-
brokerSetFn.mockResolvedValue({ status: "ok" });
|
|
148
|
-
|
|
149
|
-
await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
|
|
150
|
-
|
|
151
|
-
// Should have called isAvailable 4 times (3 false + 1 true)
|
|
152
|
-
expect(isAvailableFn).toHaveBeenCalledTimes(4);
|
|
153
|
-
|
|
154
|
-
// Should have proceeded with migration
|
|
155
|
-
expect(brokerSetFn).toHaveBeenCalledWith("retry-key", "retry-secret");
|
|
156
|
-
expect(deleteKeyFn).toHaveBeenCalledWith("retry-key");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("no-ops when encrypted store has no keys", async () => {
|
|
160
|
-
listKeysFn.mockReturnValue([]);
|
|
161
|
-
|
|
162
|
-
await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
|
|
163
|
-
|
|
164
|
-
expect(brokerSetFn).not.toHaveBeenCalled();
|
|
165
|
-
expect(deleteKeyFn).not.toHaveBeenCalled();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test("successfully migrates keys from encrypted store to keychain", async () => {
|
|
169
|
-
listKeysFn.mockReturnValue(["account-a", "account-b"]);
|
|
170
|
-
getKeyFn.mockImplementation((account: string) => {
|
|
171
|
-
if (account === "account-a") return "secret-a";
|
|
172
|
-
if (account === "account-b") return "secret-b";
|
|
173
|
-
return undefined;
|
|
174
|
-
});
|
|
175
|
-
brokerSetFn.mockResolvedValue({ status: "ok" });
|
|
176
|
-
|
|
177
|
-
await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
|
|
178
|
-
|
|
179
|
-
// Should have called broker.set for each key
|
|
180
|
-
expect(brokerSetFn).toHaveBeenCalledTimes(2);
|
|
181
|
-
expect(brokerSetFn).toHaveBeenCalledWith("account-a", "secret-a");
|
|
182
|
-
expect(brokerSetFn).toHaveBeenCalledWith("account-b", "secret-b");
|
|
183
|
-
|
|
184
|
-
// Should have deleted each key from encrypted store after successful migration
|
|
185
|
-
expect(deleteKeyFn).toHaveBeenCalledTimes(2);
|
|
186
|
-
expect(deleteKeyFn).toHaveBeenCalledWith("account-a");
|
|
187
|
-
expect(deleteKeyFn).toHaveBeenCalledWith("account-b");
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("continues on individual key failure and migrates others", async () => {
|
|
191
|
-
listKeysFn.mockReturnValue(["fail-key", "ok-key"]);
|
|
192
|
-
getKeyFn.mockImplementation((account: string) => {
|
|
193
|
-
if (account === "fail-key") return "fail-secret";
|
|
194
|
-
if (account === "ok-key") return "ok-secret";
|
|
195
|
-
return undefined;
|
|
196
|
-
});
|
|
197
|
-
brokerSetFn.mockImplementation(async (account: string) => {
|
|
198
|
-
if (account === "fail-key") {
|
|
199
|
-
return {
|
|
200
|
-
status: "rejected" as const,
|
|
201
|
-
code: "UNKNOWN",
|
|
202
|
-
message: "broker rejected",
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
return { status: "ok" as const };
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
|
|
209
|
-
|
|
210
|
-
// fail-key should NOT have been deleted (broker rejected it)
|
|
211
|
-
expect(deleteKeyFn).not.toHaveBeenCalledWith("fail-key");
|
|
212
|
-
|
|
213
|
-
// ok-key should have been migrated and deleted
|
|
214
|
-
expect(brokerSetFn).toHaveBeenCalledWith("ok-key", "ok-secret");
|
|
215
|
-
expect(deleteKeyFn).toHaveBeenCalledWith("ok-key");
|
|
216
|
-
expect(deleteKeyFn).toHaveBeenCalledTimes(1);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test("handles getKey returning undefined for a listed key", async () => {
|
|
220
|
-
listKeysFn.mockReturnValue(["ghost-key", "real-key"]);
|
|
221
|
-
getKeyFn.mockImplementation((account: string) => {
|
|
222
|
-
if (account === "ghost-key") return undefined;
|
|
223
|
-
if (account === "real-key") return "real-secret";
|
|
224
|
-
return undefined;
|
|
225
|
-
});
|
|
226
|
-
brokerSetFn.mockResolvedValue({ status: "ok" });
|
|
227
|
-
|
|
228
|
-
await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
|
|
229
|
-
|
|
230
|
-
// ghost-key should not be sent to broker or deleted
|
|
231
|
-
expect(brokerSetFn).not.toHaveBeenCalledWith(
|
|
232
|
-
"ghost-key",
|
|
233
|
-
expect.anything(),
|
|
234
|
-
);
|
|
235
|
-
expect(deleteKeyFn).not.toHaveBeenCalledWith("ghost-key");
|
|
236
|
-
|
|
237
|
-
// real-key should be migrated
|
|
238
|
-
expect(brokerSetFn).toHaveBeenCalledWith("real-key", "real-secret");
|
|
239
|
-
expect(deleteKeyFn).toHaveBeenCalledWith("real-key");
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test("handles broker unreachable status for individual keys", async () => {
|
|
243
|
-
listKeysFn.mockReturnValue(["key-1"]);
|
|
244
|
-
getKeyFn.mockReturnValue("secret-1");
|
|
245
|
-
brokerSetFn.mockResolvedValue({ status: "unreachable" });
|
|
246
|
-
|
|
247
|
-
await migrateCredentialsToKeychainMigration.run(WORKSPACE_DIR);
|
|
248
|
-
|
|
249
|
-
// Should not delete when broker is unreachable
|
|
250
|
-
expect(deleteKeyFn).not.toHaveBeenCalled();
|
|
16
|
+
test("down is a no-op", async () => {
|
|
17
|
+
await migrateCredentialsToKeychainMigration.down("/fake");
|
|
251
18
|
});
|
|
252
19
|
});
|
|
@@ -1,220 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// Mock state
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
|
|
7
|
-
const isAvailableFn = mock((): boolean => true);
|
|
8
|
-
const brokerGetFn = mock(
|
|
9
|
-
async (
|
|
10
|
-
_account: string,
|
|
11
|
-
): Promise<{ found: boolean; value?: string } | null> => ({
|
|
12
|
-
found: true,
|
|
13
|
-
value: "secret",
|
|
14
|
-
}),
|
|
15
|
-
);
|
|
16
|
-
const brokerDelFn = mock(async (_account: string): Promise<boolean> => true);
|
|
17
|
-
const brokerListFn = mock(async (): Promise<string[]> => []);
|
|
18
|
-
const createBrokerClientFn = mock(() => ({
|
|
19
|
-
isAvailable: isAvailableFn,
|
|
20
|
-
get: brokerGetFn,
|
|
21
|
-
del: brokerDelFn,
|
|
22
|
-
list: brokerListFn,
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
|
-
const setKeyFn = mock(
|
|
26
|
-
(_account: string, _value: string): boolean => true,
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Mock modules — before importing module under test
|
|
31
|
-
//
|
|
32
|
-
// The logger is mocked with a silent Proxy to suppress pino output in tests.
|
|
33
|
-
// The broker client and encrypted store are mocked to control migration
|
|
34
|
-
// behavior without touching real keychain or filesystem state.
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
mock.module("../util/logger.js", () => ({
|
|
38
|
-
getLogger: () =>
|
|
39
|
-
new Proxy({} as Record<string, unknown>, {
|
|
40
|
-
get: () => () => {},
|
|
41
|
-
}),
|
|
42
|
-
}));
|
|
43
|
-
|
|
44
|
-
mock.module("../security/keychain-broker-client.js", () => ({
|
|
45
|
-
createBrokerClient: createBrokerClientFn,
|
|
46
|
-
}));
|
|
47
|
-
|
|
48
|
-
mock.module("../security/encrypted-store.js", () => ({
|
|
49
|
-
setKey: setKeyFn,
|
|
50
|
-
}));
|
|
51
|
-
|
|
52
|
-
// Import after mocking
|
|
53
3
|
import { migrateCredentialsFromKeychainMigration } from "../workspace/migrations/016-migrate-credentials-from-keychain.js";
|
|
54
4
|
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
// Helpers
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
|
|
59
|
-
const WORKSPACE_DIR = "/mock-home/.vellum/workspace";
|
|
60
|
-
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
// Tests
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
|
|
65
5
|
describe("016-migrate-credentials-from-keychain migration", () => {
|
|
66
|
-
beforeEach(() => {
|
|
67
|
-
isAvailableFn.mockClear();
|
|
68
|
-
brokerGetFn.mockClear();
|
|
69
|
-
brokerDelFn.mockClear();
|
|
70
|
-
brokerListFn.mockClear();
|
|
71
|
-
createBrokerClientFn.mockClear();
|
|
72
|
-
setKeyFn.mockClear();
|
|
73
|
-
|
|
74
|
-
// Defaults: mac production build
|
|
75
|
-
process.env.VELLUM_DESKTOP_APP = "1";
|
|
76
|
-
delete process.env.VELLUM_DEV;
|
|
77
|
-
|
|
78
|
-
isAvailableFn.mockReturnValue(true);
|
|
79
|
-
brokerGetFn.mockResolvedValue({ found: true, value: "secret" });
|
|
80
|
-
brokerDelFn.mockResolvedValue(true);
|
|
81
|
-
brokerListFn.mockResolvedValue([]);
|
|
82
|
-
setKeyFn.mockReturnValue(true);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
6
|
test("has correct migration id", () => {
|
|
86
7
|
expect(migrateCredentialsFromKeychainMigration.id).toBe(
|
|
87
8
|
"016-migrate-credentials-from-keychain",
|
|
88
9
|
);
|
|
89
10
|
});
|
|
90
11
|
|
|
91
|
-
test("
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
|
|
95
|
-
|
|
96
|
-
expect(createBrokerClientFn).not.toHaveBeenCalled();
|
|
97
|
-
expect(brokerListFn).not.toHaveBeenCalled();
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("skips when VELLUM_DESKTOP_APP is not '1'", async () => {
|
|
101
|
-
process.env.VELLUM_DESKTOP_APP = "0";
|
|
102
|
-
|
|
103
|
-
await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
|
|
104
|
-
|
|
105
|
-
expect(createBrokerClientFn).not.toHaveBeenCalled();
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("skips when VELLUM_DEV=1", async () => {
|
|
109
|
-
process.env.VELLUM_DEV = "1";
|
|
110
|
-
|
|
111
|
-
await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
|
|
112
|
-
|
|
113
|
-
expect(createBrokerClientFn).not.toHaveBeenCalled();
|
|
114
|
-
expect(brokerListFn).not.toHaveBeenCalled();
|
|
12
|
+
test("run is a no-op", async () => {
|
|
13
|
+
await migrateCredentialsFromKeychainMigration.run("/fake");
|
|
115
14
|
});
|
|
116
15
|
|
|
117
|
-
test(
|
|
118
|
-
|
|
119
|
-
async () => {
|
|
120
|
-
isAvailableFn.mockReturnValue(false);
|
|
121
|
-
|
|
122
|
-
// Throwing skips the checkpoint so the migration retries on next startup
|
|
123
|
-
await expect(
|
|
124
|
-
migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR),
|
|
125
|
-
).rejects.toThrow("Keychain broker not available after waiting");
|
|
126
|
-
|
|
127
|
-
// Should not proceed to list or migrate keys
|
|
128
|
-
expect(brokerListFn).not.toHaveBeenCalled();
|
|
129
|
-
expect(setKeyFn).not.toHaveBeenCalled();
|
|
130
|
-
},
|
|
131
|
-
{ timeout: 10_000 },
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
test("no-ops when keychain has no accounts", async () => {
|
|
135
|
-
brokerListFn.mockResolvedValue([]);
|
|
136
|
-
|
|
137
|
-
await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
|
|
138
|
-
|
|
139
|
-
expect(setKeyFn).not.toHaveBeenCalled();
|
|
140
|
-
expect(brokerDelFn).not.toHaveBeenCalled();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test("copies credentials from keychain to encrypted store and deletes from keychain", async () => {
|
|
144
|
-
brokerListFn.mockResolvedValue(["account-a", "account-b"]);
|
|
145
|
-
brokerGetFn.mockImplementation(async (account: string) => {
|
|
146
|
-
if (account === "account-a") return { found: true, value: "secret-a" };
|
|
147
|
-
if (account === "account-b") return { found: true, value: "secret-b" };
|
|
148
|
-
return null;
|
|
149
|
-
});
|
|
150
|
-
setKeyFn.mockReturnValue(true);
|
|
151
|
-
|
|
152
|
-
await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
|
|
153
|
-
|
|
154
|
-
// Should have written each key to encrypted store
|
|
155
|
-
expect(setKeyFn).toHaveBeenCalledTimes(2);
|
|
156
|
-
expect(setKeyFn).toHaveBeenCalledWith("account-a", "secret-a");
|
|
157
|
-
expect(setKeyFn).toHaveBeenCalledWith("account-b", "secret-b");
|
|
158
|
-
|
|
159
|
-
// Should have deleted each key from keychain after successful migration
|
|
160
|
-
expect(brokerDelFn).toHaveBeenCalledTimes(2);
|
|
161
|
-
expect(brokerDelFn).toHaveBeenCalledWith("account-a");
|
|
162
|
-
expect(brokerDelFn).toHaveBeenCalledWith("account-b");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("skips key when broker.get returns null", async () => {
|
|
166
|
-
brokerListFn.mockResolvedValue(["ghost-key", "real-key"]);
|
|
167
|
-
brokerGetFn.mockImplementation(async (account: string) => {
|
|
168
|
-
if (account === "ghost-key") return null;
|
|
169
|
-
if (account === "real-key") return { found: true, value: "real-secret" };
|
|
170
|
-
return null;
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
|
|
174
|
-
|
|
175
|
-
// ghost-key should not be written or deleted
|
|
176
|
-
expect(setKeyFn).not.toHaveBeenCalledWith(
|
|
177
|
-
"ghost-key",
|
|
178
|
-
expect.anything(),
|
|
179
|
-
);
|
|
180
|
-
expect(brokerDelFn).not.toHaveBeenCalledWith("ghost-key");
|
|
181
|
-
|
|
182
|
-
// real-key should be migrated
|
|
183
|
-
expect(setKeyFn).toHaveBeenCalledWith("real-key", "real-secret");
|
|
184
|
-
expect(brokerDelFn).toHaveBeenCalledWith("real-key");
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("skips key when broker.get returns not found", async () => {
|
|
188
|
-
brokerListFn.mockResolvedValue(["missing-key"]);
|
|
189
|
-
brokerGetFn.mockResolvedValue({ found: false });
|
|
190
|
-
|
|
191
|
-
await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
|
|
192
|
-
|
|
193
|
-
expect(setKeyFn).not.toHaveBeenCalled();
|
|
194
|
-
expect(brokerDelFn).not.toHaveBeenCalled();
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
test("skips key when setKey fails and does not delete from keychain", async () => {
|
|
198
|
-
brokerListFn.mockResolvedValue(["fail-key", "ok-key"]);
|
|
199
|
-
brokerGetFn.mockImplementation(async (account: string) => {
|
|
200
|
-
if (account === "fail-key")
|
|
201
|
-
return { found: true, value: "fail-secret" };
|
|
202
|
-
if (account === "ok-key") return { found: true, value: "ok-secret" };
|
|
203
|
-
return null;
|
|
204
|
-
});
|
|
205
|
-
setKeyFn.mockImplementation((account: string) => {
|
|
206
|
-
if (account === "fail-key") return false;
|
|
207
|
-
return true;
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
await migrateCredentialsFromKeychainMigration.run(WORKSPACE_DIR);
|
|
211
|
-
|
|
212
|
-
// fail-key should NOT have been deleted from keychain (setKey failed)
|
|
213
|
-
expect(brokerDelFn).not.toHaveBeenCalledWith("fail-key");
|
|
214
|
-
|
|
215
|
-
// ok-key should have been migrated and deleted
|
|
216
|
-
expect(setKeyFn).toHaveBeenCalledWith("ok-key", "ok-secret");
|
|
217
|
-
expect(brokerDelFn).toHaveBeenCalledWith("ok-key");
|
|
218
|
-
expect(brokerDelFn).toHaveBeenCalledTimes(1);
|
|
16
|
+
test("down is a no-op", async () => {
|
|
17
|
+
await migrateCredentialsFromKeychainMigration.down("/fake");
|
|
219
18
|
});
|
|
220
19
|
});
|
|
@@ -130,16 +130,16 @@ describe("runWorkspaceMigrations", () => {
|
|
|
130
130
|
throw new Error("migration 002 failed");
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
).rejects.toThrow("migration 002 failed");
|
|
133
|
+
// Runner no longer throws — it marks failed migrations and continues
|
|
134
|
+
await runWorkspaceMigrations(WORKSPACE_DIR, [m1, m2]);
|
|
136
135
|
|
|
137
|
-
// m1 ran successfully
|
|
136
|
+
// m1 ran successfully, m2 was attempted
|
|
138
137
|
expect(m1.run).toHaveBeenCalledTimes(1);
|
|
138
|
+
expect(m2.run).toHaveBeenCalledTimes(1);
|
|
139
139
|
|
|
140
|
-
// Checkpoints saved: started m1, completed m1, started m2 =
|
|
141
|
-
expect(writeFileSyncFn).toHaveBeenCalledTimes(
|
|
142
|
-
expect(renameSyncFn).toHaveBeenCalledTimes(
|
|
140
|
+
// Checkpoints saved: started m1, completed m1, started m2, failed m2 = 4 writes
|
|
141
|
+
expect(writeFileSyncFn).toHaveBeenCalledTimes(4);
|
|
142
|
+
expect(renameSyncFn).toHaveBeenCalledTimes(4);
|
|
143
143
|
|
|
144
144
|
// Verify the completed checkpoint contains m1
|
|
145
145
|
// The second write is the "completed" marker for m1
|
|
@@ -149,6 +149,14 @@ describe("runWorkspaceMigrations", () => {
|
|
|
149
149
|
const parsed = JSON.parse(completedWrite);
|
|
150
150
|
expect(parsed.applied["001"]).toBeDefined();
|
|
151
151
|
expect(parsed.applied["001"].status).toBe("completed");
|
|
152
|
+
|
|
153
|
+
// Verify m2 is marked as failed
|
|
154
|
+
const failedWrite = (
|
|
155
|
+
writeFileSyncFn.mock.calls[3] as unknown[]
|
|
156
|
+
)[1] as string;
|
|
157
|
+
const failedParsed = JSON.parse(failedWrite);
|
|
158
|
+
expect(failedParsed.applied["002"]).toBeDefined();
|
|
159
|
+
expect(failedParsed.applied["002"].status).toBe("failed");
|
|
152
160
|
});
|
|
153
161
|
|
|
154
162
|
test("idempotent on re-run", async () => {
|