@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.
- 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/package.json +1 -1
- package/src/__tests__/credentials-cli.test.ts +3 -3
- 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/lifecycle.ts +2 -3
- package/src/memory/migrations/validate-migration-state.ts +14 -1
- package/src/runtime/routes/settings-routes.ts +1 -1
- 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
|
@@ -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 () => {
|
|
@@ -635,7 +635,7 @@ Examples:
|
|
|
635
635
|
if (unreachable) {
|
|
636
636
|
writeError(
|
|
637
637
|
cmd,
|
|
638
|
-
"
|
|
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 = "(
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -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
|
|
289
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
};
|