@vellumai/assistant 0.5.9 → 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.
@@ -1,220 +1,19 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
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("skips when VELLUM_DESKTOP_APP is not set", async () => {
92
- delete process.env.VELLUM_DESKTOP_APP;
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
- "throws when broker is not available (skips checkpoint for retry)",
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
- await expect(
134
- runWorkspaceMigrations(WORKSPACE_DIR, [m1, m2]),
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 before the error
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 = 3 writes
141
- expect(writeFileSyncFn).toHaveBeenCalledTimes(3);
142
- expect(renameSyncFn).toHaveBeenCalledTimes(3);
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 () => {
@@ -635,7 +635,7 @@ Examples:
635
635
  if (unreachable) {
636
636
  writeError(
637
637
  cmd,
638
- "Keychain broker is unreachable — restart the Vellum app and accept the macOS Keychain prompt",
638
+ "Credential store is unreachable — ensure the assistant is running",
639
639
  );
640
640
  } else {
641
641
  writeError(cmd, "Credential not found");
@@ -676,7 +676,7 @@ Examples:
676
676
  const output = buildCredentialOutput(metadata, secret, connection);
677
677
 
678
678
  if (unreachable && (secret == null || secret.length === 0)) {
679
- output.scrubbedValue = "(broker unreachable)";
679
+ output.scrubbedValue = "(credential store unreachable)";
680
680
  output.brokerUnreachable = true;
681
681
  }
682
682
 
@@ -686,7 +686,7 @@ Examples:
686
686
  printCredentialHuman(output);
687
687
  if (unreachable && (secret == null || secret.length === 0)) {
688
688
  log.info(
689
- " \u26A0 Keychain broker unreachable — restart the Vellum app and accept the macOS Keychain prompt to access credentials",
689
+ " \u26A0 Credential store is unreachable — ensure the assistant is running",
690
690
  );
691
691
  }
692
692
  }
@@ -770,7 +770,7 @@ Examples:
770
770
  if (unreachable) {
771
771
  writeError(
772
772
  cmd,
773
- "Keychain broker is unreachable — restart the Vellum app and accept the macOS Keychain prompt",
773
+ "Credential store is unreachable — ensure the assistant is running",
774
774
  );
775
775
  } else {
776
776
  writeError(cmd, "Credential not found");
@@ -168,7 +168,7 @@ At least --id or --provider must be specified.`,
168
168
  .requiredOption("--client-id <id>", "OAuth client ID")
169
169
  .option(
170
170
  "--client-secret <secret>",
171
- "OAuth client secret (stored in secure keychain)",
171
+ "OAuth client secret (stored in credential store)",
172
172
  )
173
173
  .option(
174
174
  "--client-secret-credential-path <path>",
@@ -179,7 +179,7 @@ At least --id or --provider must be specified.`,
179
179
  `
180
180
  Creates a new app registration or returns the existing one if an app with the
181
181
  same provider and client ID already exists. The client secret, if provided, is
182
- stored in the secure system keychain — not in the database.
182
+ stored in the secure credential store — not in the database.
183
183
 
184
184
  When an existing app is matched and a --client-secret is provided, the stored
185
185
  secret is updated. The app row itself is returned as-is.
@@ -277,7 +277,7 @@ Arguments:
277
277
  id The app UUID to delete (as returned by "apps list" or "apps get")
278
278
 
279
279
  Permanently removes the app registration and its stored client secret from
280
- the keychain. Any OAuth connections that reference this app will no longer be
280
+ the credential store. Any OAuth connections that reference this app will no longer be
281
281
  able to refresh tokens.
282
282
 
283
283
  Exits with code 1 if the app ID is not found.
@@ -285,9 +285,8 @@ export async function runDaemon(): Promise<void> {
285
285
  // Slack channel) that already have keychain credentials from before the
286
286
  // oauth_connection migration. Safe to call on every startup.
287
287
  //
288
- // Must run AFTER workspace migrations so that migration 015 (which copies
289
- // encrypted-store credentials to the keychain) has already executed.
290
- // Otherwise syncManualTokenConnection sees no keychain credentials and
288
+ // Must run AFTER workspace migrations.
289
+ // Otherwise syncManualTokenConnection sees no stored credentials and
291
290
  // incorrectly removes existing connection rows.
292
291
  try {
293
292
  await backfillManualTokenConnections();
@@ -98,7 +98,20 @@ export function withCrashRecovery(
98
98
  )
99
99
  .run(checkpointKey, Date.now());
100
100
 
101
- migrationFn();
101
+ try {
102
+ migrationFn();
103
+ } catch (error) {
104
+ log.error(
105
+ { checkpointKey, error },
106
+ `Memory migration failed: ${checkpointKey} — marking as failed and continuing`,
107
+ );
108
+ raw
109
+ .query(
110
+ `UPDATE memory_checkpoints SET value = 'failed', updated_at = ? WHERE key = ?`,
111
+ )
112
+ .run(Date.now(), checkpointKey);
113
+ return;
114
+ }
102
115
 
103
116
  raw
104
117
  .query(
@@ -213,7 +213,7 @@ async function handleOAuthConnectStart(body: {
213
213
  if (requiresSecret && !clientSecret) {
214
214
  return httpError(
215
215
  "BAD_REQUEST",
216
- `client_secret is required for "${body.service}" but not found in the keychain. Store it first via the credential vault.`,
216
+ `client_secret is required for "${body.service}" but not found in the credential store. Store it first via the credential vault.`,
217
217
  400,
218
218
  );
219
219
  }
@@ -1,153 +1,18 @@
1
- import { getLogger } from "../../util/logger.js";
2
1
  import type { WorkspaceMigration } from "./types.js";
3
2
 
4
- const log = getLogger("workspace-migrations");
5
-
6
- const BROKER_WAIT_INTERVAL_MS = 500;
7
- const BROKER_WAIT_MAX_ATTEMPTS = 10; // 5 seconds total
8
-
3
+ /**
4
+ * Originally migrated credentials from encrypted store to macOS Keychain.
5
+ * No-op'd because: (1) the keychain broker was deleted, (2) inline security
6
+ * CLI calls trigger macOS permission prompts on every daemon startup even for
7
+ * users who never had keychain credentials, (3) migration 016 reverses this
8
+ * migration anyway, so the net effect is a round-trip.
9
+ *
10
+ * Users who had credentials stranded in the macOS Keychain from a brief
11
+ * intermediate release will need to re-enter their API keys.
12
+ */
9
13
  export const migrateCredentialsToKeychainMigration: WorkspaceMigration = {
10
14
  id: "015-migrate-credentials-to-keychain",
11
- description:
12
- "Copy encrypted store credentials to keychain for single-backend migration",
13
-
14
- async down(_workspaceDir: string): Promise<void> {
15
- // Reverse: copy credentials from keychain back to encrypted store.
16
- // Mirrors the forward logic of 016-migrate-credentials-from-keychain.
17
- if (
18
- process.env.VELLUM_DESKTOP_APP !== "1" ||
19
- process.env.VELLUM_DEV === "1"
20
- ) {
21
- return;
22
- }
23
-
24
- const { createBrokerClient } =
25
- await import("../../security/keychain-broker-client.js");
26
- const client = createBrokerClient();
27
-
28
- let brokerAvailable = false;
29
- for (let i = 0; i < BROKER_WAIT_MAX_ATTEMPTS; i++) {
30
- if (client.isAvailable()) {
31
- brokerAvailable = true;
32
- break;
33
- }
34
- await new Promise((r) => setTimeout(r, BROKER_WAIT_INTERVAL_MS));
35
- }
36
-
37
- if (!brokerAvailable) {
38
- throw new Error(
39
- "Keychain broker not available after waiting — credential rollback " +
40
- "will be retried on next startup",
41
- );
42
- }
43
-
44
- const { setKey } = await import("../../security/encrypted-store.js");
45
-
46
- const accounts = await client.list();
47
- if (accounts.length === 0) return;
48
-
49
- let rolledBackCount = 0;
50
- let failedCount = 0;
51
-
52
- for (const account of accounts) {
53
- const result = await client.get(account);
54
- if (!result || !result.found || result.value === undefined) {
55
- log.warn(
56
- { account },
57
- "Failed to read key from keychain during rollback — skipping",
58
- );
59
- failedCount++;
60
- continue;
61
- }
62
-
63
- const written = setKey(account, result.value);
64
- if (written) {
65
- await client.del(account);
66
- rolledBackCount++;
67
- } else {
68
- log.warn(
69
- { account },
70
- "Failed to write key to encrypted store during rollback — skipping",
71
- );
72
- failedCount++;
73
- }
74
- }
75
-
76
- log.info(
77
- { rolledBackCount, failedCount },
78
- "Credential rollback from keychain to encrypted store complete",
79
- );
80
- },
81
-
82
- async run(_workspaceDir: string): Promise<void> {
83
- // Only run on mac production builds (desktop app, non-dev).
84
- if (
85
- process.env.VELLUM_DESKTOP_APP !== "1" ||
86
- process.env.VELLUM_DEV === "1"
87
- ) {
88
- return;
89
- }
90
-
91
- const { createBrokerClient } =
92
- await import("../../security/keychain-broker-client.js");
93
- const client = createBrokerClient();
94
-
95
- // Wait for the broker to become available (up to 5 seconds), matching
96
- // the retry strategy in secure-keys.ts waitForBrokerAvailability().
97
- let brokerAvailable = false;
98
- for (let i = 0; i < BROKER_WAIT_MAX_ATTEMPTS; i++) {
99
- if (client.isAvailable()) {
100
- brokerAvailable = true;
101
- break;
102
- }
103
- await new Promise((r) => setTimeout(r, BROKER_WAIT_INTERVAL_MS));
104
- }
105
-
106
- if (!brokerAvailable) {
107
- throw new Error(
108
- "Keychain broker not available after waiting — credential migration " +
109
- "will be retried on next startup",
110
- );
111
- }
112
-
113
- const { listKeys, getKey, deleteKey } =
114
- await import("../../security/encrypted-store.js");
115
-
116
- const accounts = listKeys();
117
- if (accounts.length === 0) {
118
- return;
119
- }
120
-
121
- let migratedCount = 0;
122
- let failedCount = 0;
123
-
124
- for (const account of accounts) {
125
- const value = getKey(account);
126
- if (value === undefined) {
127
- log.warn(
128
- { account },
129
- "Failed to read key from encrypted store — skipping",
130
- );
131
- failedCount++;
132
- continue;
133
- }
134
-
135
- const result = await client.set(account, value);
136
- if (result.status === "ok") {
137
- deleteKey(account);
138
- migratedCount++;
139
- } else {
140
- log.warn(
141
- { account, status: result.status },
142
- "Failed to write key to keychain — skipping",
143
- );
144
- failedCount++;
145
- }
146
- }
147
-
148
- log.info(
149
- { migratedCount, failedCount },
150
- "Credential migration to keychain complete",
151
- );
152
- },
15
+ description: "No-op (keychain migration removed)",
16
+ async run(): Promise<void> {},
17
+ async down(): Promise<void> {},
153
18
  };