@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 CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  For error handling conventions (throw vs result objects vs null), see [docs/error-handling.md](docs/error-handling.md).
4
4
 
5
- Subdirectory-scoped rules live in local AGENTS.md files: `src/cli/`, `src/runtime/`, `src/approvals/`, `src/notifications/`.
5
+ Subdirectory-scoped rules live in local AGENTS.md files: `src/cli/`, `src/runtime/`, `src/approvals/`, `src/notifications/`, `src/workspace/migrations/`.
package/ARCHITECTURE.md CHANGED
@@ -321,7 +321,7 @@ The WhatsApp channel enables inbound and outbound messaging via the Meta WhatsAp
321
321
  - `WHATSAPP_APP_SECRET` — App secret for webhook signature verification
322
322
  - `WHATSAPP_WEBHOOK_VERIFY_TOKEN` — Token for the Meta webhook subscription handshake
323
323
 
324
- These can be set via environment variables or stored in the credential vault (keychain / encrypted store) under the `whatsapp` service prefix.
324
+ These can be set via environment variables or stored in the credential vault (CES / encrypted store) under the `whatsapp` service prefix.
325
325
 
326
326
  **Limitations (v1)**: Rich approval UI (inline buttons) is not supported. Contacts and location message types are acknowledged but not forwarded.
327
327
 
@@ -343,7 +343,7 @@ All endpoints are JWT-authenticated via `Authorization: Bearer <jwt>`.
343
343
 
344
344
  **Credential storage pattern:**
345
345
 
346
- Both tokens are stored in the secure key store (macOS Keychain with encrypted file fallback):
346
+ Both tokens are stored in the secure key store (CES credential store with encrypted file fallback):
347
347
 
348
348
  | Secure key | Content |
349
349
  | ------------------------------------ | -------------------------------------------------------------------------- |
@@ -667,9 +667,9 @@ All six enforcement points derive the flag key via `skillFlagKey(skill)` — whi
667
667
 
668
668
  ```mermaid
669
669
  graph LR
670
- subgraph "macOS Keychain"
671
- K1["API Key<br/>service: vellum-assistant<br/>account: anthropic<br/>stored via /usr/bin/security CLI"]
672
- K2["Credential Secrets<br/>key: credential/{service}/{field}<br/>stored via secure-keys.ts<br/>(encrypted file fallback if Keychain unavailable)"]
670
+ subgraph "Credential Store"
671
+ K1["API Key<br/>service: vellum-assistant<br/>account: anthropic<br/>stored via CES"]
672
+ K2["Credential Secrets<br/>key: credential/{service}/{field}<br/>stored via secure-keys.ts<br/>(encrypted file fallback)"]
673
673
  end
674
674
 
675
675
  subgraph "UserDefaults (plist)"
@@ -1937,10 +1937,10 @@ Connected channels are resolved at signal emission time: vellum is always includ
1937
1937
 
1938
1938
  | What | Where | Format | ORM/Driver | Retention |
1939
1939
  | ---------------------------------------- | ----------------------------------------------------------------- | ----------------------------------- | ---------------------------------- | ------------------------------------------------------- |
1940
- | API key | macOS Keychain | Encrypted binary | `/usr/bin/security` CLI | Permanent |
1941
- | Credential secrets | macOS Keychain (or encrypted file fallback) | Encrypted binary | `secure-keys.ts` wrapper | Permanent (until deleted via tool) |
1940
+ | API key | CES / encrypted file store | Encrypted binary | CES API / `secure-keys.ts` | Permanent |
1941
+ | Credential secrets | CES / encrypted file store | Encrypted binary | `secure-keys.ts` wrapper | Permanent (until deleted via tool) |
1942
1942
  | Credential metadata | `~/.vellum/workspace/data/credentials/metadata.json` | JSON | Atomic file write | Permanent (until deleted via tool) |
1943
- | Integration OAuth tokens | macOS Keychain (or encrypted file fallback, via `secure-keys.ts`) | Encrypted binary | `TokenManager` auto-refresh | Until disconnected or revoked |
1943
+ | Integration OAuth tokens | CES / encrypted file store (via `secure-keys.ts`) | Encrypted binary | `TokenManager` auto-refresh | Until disconnected or revoked |
1944
1944
  | User preferences | UserDefaults | plist | Foundation | Permanent |
1945
1945
  | Session logs | `~/Library/.../logs/session-*.json` | JSON per session | Swift Codable | Unbounded |
1946
1946
  | Conversations & messages | `~/.vellum/workspace/data/db/assistant.db` | SQLite + WAL | Drizzle ORM (Bun) | Permanent |
package/README.md CHANGED
@@ -204,7 +204,7 @@ The runtime exposes a RESTful HTTP API for Twilio configuration, credential mana
204
204
  | Method | Path | Description |
205
205
  | ------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
206
206
  | GET | `/v1/integrations/twilio/config` | Returns current state: `hasCredentials` (boolean) and `phoneNumber` (if assigned) |
207
- | POST | `/v1/integrations/twilio/credentials` | Validates and stores Account SID and Auth Token in secure storage (Keychain / encrypted file) |
207
+ | POST | `/v1/integrations/twilio/credentials` | Validates and stores Account SID and Auth Token in secure storage (CES / encrypted file store) |
208
208
  | DELETE | `/v1/integrations/twilio/credentials` | Removes stored credentials. Preserves the phone number in config so re-entering credentials resumes working without reassigning the number. |
209
209
  | GET | `/v1/integrations/twilio/numbers` | Lists all incoming phone numbers on the Twilio account with their capabilities |
210
210
  | POST | `/v1/integrations/twilio/numbers/provision` | Purchases a new phone number. Accepts optional `areaCode` and `country`. Auto-assigns and configures webhooks when ingress is available. |
@@ -122,7 +122,7 @@ sequenceDiagram
122
122
  participant Browser as System Browser
123
123
  participant Google as Google OAuth Server
124
124
  participant Store as SQLite OAuth Store
125
- participant Vault as Secure Keychain
125
+ participant Vault as Credential Store
126
126
  participant TokenMgr as TokenManager
127
127
  participant Tool as Gmail Tool Executor
128
128
  participant API as Gmail REST API
@@ -173,7 +173,7 @@ sequenceDiagram
173
173
 
174
174
  | Decision | Rationale |
175
175
  | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
176
- | PKCE by default, optional client_secret | Desktop apps prefer PKCE; some providers (Slack) require a secret, which is stored in the secure keychain (`oauth_app/{id}/client_secret`) for autonomous refresh |
176
+ | PKCE by default, optional client_secret | Desktop apps prefer PKCE; some providers (Slack) require a secret, which is stored in the credential store (`oauth_app/{id}/client_secret`) for autonomous refresh |
177
177
  | Shared connect orchestrator | All OAuth providers route through `orchestrateOAuthConnect()`, which resolves profiles, enforces scope policy, runs the flow, stores tokens, and verifies identity. Adding a provider is a declarative profile entry, not new orchestration code |
178
178
  | Canonical credential naming | All reads and writes use `client_id`/`client_secret` as canonical field names |
179
179
  | Gateway callback transport | OAuth callbacks are now routed through the gateway at `${ingress.publicBaseUrl}/webhooks/oauth/callback` instead of a loopback redirect URI. This enables OAuth flows to work in remote and tunneled deployments. |
@@ -261,7 +261,7 @@ Result is a discriminated union: `{ success, deferred, grantedScopes, accountInf
261
261
 
262
262
  `assistant/src/daemon/handlers/oauth-connect.ts` handles `oauth_connect_start` messages. The handler:
263
263
 
264
- 1. Resolves client credentials from the keychain using canonical names (`client_id`, `client_secret`).
264
+ 1. Resolves client credentials from the credential store using canonical names (`client_id`, `client_secret`).
265
265
  2. Validates that required credentials exist (including `client_secret` when the provider requires it).
266
266
  3. Delegates to `orchestrateOAuthConnect()`.
267
267
  4. Sends `oauth_connect_result` back to the client.
@@ -286,7 +286,7 @@ This replaces provider-specific handlers — any provider in the registry can be
286
286
  | `assistant/src/oauth/scope-policy.ts` | Scope resolution and policy enforcement (pure, no I/O) |
287
287
  | `assistant/src/oauth/connect-orchestrator.ts` | Shared connect orchestrator (profile → scopes → flow → tokens) |
288
288
  | `assistant/src/oauth/connect-types.ts` | Shared types (`OAuthProviderBehavior`, `OAuthScopePolicy`, `OAuthConnectResult`) |
289
- | `assistant/src/oauth/token-persistence.ts` | Token storage: keychain writes, metadata upsert, post-connect hooks |
289
+ | `assistant/src/oauth/token-persistence.ts` | Token storage: credential store writes, metadata upsert, post-connect hooks |
290
290
  | `assistant/src/daemon/handlers/oauth-connect.ts` | Generic `oauth_connect_start` / `oauth_connect_result` handler |
291
291
 
292
292
  ---
@@ -1,7 +1,7 @@
1
1
  # macOS Keychain Broker Architecture (Legacy)
2
2
 
3
3
  **Status:** Superseded by CES credential routing
4
- **Last Updated:** 2026-03-22
4
+ **Last Updated:** 2026-03-24
5
5
  **Owners:** macOS client + assistant runtime
6
6
 
7
7
  ## Current State
@@ -16,24 +16,23 @@ See [`assistant/docs/credential-execution-service.md`](../credential-execution-s
16
16
 
17
17
  ### What remains
18
18
 
19
- The keychain broker is not fully deleted. These components still exist:
19
+ The keychain broker is fully deleted. Only these historical migrations remain:
20
20
 
21
- | Component | Location | Why it exists |
22
- |---|---|---|
23
- | Keychain broker client | `assistant/src/security/keychain-broker-client.ts` | Used only by workspace migrations 015 and 016 |
24
- | Migration 015 | `assistant/src/workspace/migrations/015-migrate-credentials-to-keychain.ts` | Historical migration that copied encrypted store credentials into keychain |
21
+ | Component | Location | Why it exists |
22
+ | ------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
23
+ | Migration 015 | `assistant/src/workspace/migrations/015-migrate-credentials-to-keychain.ts` | Historical migration that copied encrypted store credentials into keychain |
25
24
  | Migration 016 | `assistant/src/workspace/migrations/016-migrate-credentials-from-keychain.ts` | Reverse migration that copies keychain credentials back to the encrypted store for CES unification |
26
- | Swift broker server | `clients/macos/vellum-assistant/Security/KeychainBrokerServer.swift` | UDS server in the macOS app; still compiled for release builds (`#if !DEBUG`) |
27
- | Swift broker service | `clients/macos/vellum-assistant/Security/KeychainBrokerService.swift` | `SecItem*` wrapper used by the broker server |
28
- | Gateway credential reader | `gateway/src/credential-reader.ts` | Still tries the keychain broker as a secondary fallback after CES, before the encrypted store |
29
25
 
30
- The broker client and Swift server remain because migrations 015/016 must be able to read/write the keychain for users who previously stored credentials there. These migrations are append-only and cannot be removed. The gateway's broker fallback provides a read path for credentials that may still be in the keychain during the migration window.
26
+ Migrations 015/016 use inline `security` CLI helpers that shell out to `/usr/bin/security` directly, replacing the former broker client and UDS protocol. These migrations are append-only and cannot be removed.
31
27
 
32
28
  ### What was removed
33
29
 
30
+ - **Keychain broker client** (`keychain-broker-client.ts`) -- the TypeScript client that communicated with the Swift UDS server. Migrations 015/016 now use inline `security` CLI helpers instead.
34
31
  - **`KeychainBackend`** class and `createKeychainBackend()` factory -- the daemon's `CredentialBackend` implementation that wrapped the broker client. Removed from `credential-backend.ts`.
35
32
  - **`resolveBackendAsync()` keychain resolution path** -- the daemon no longer considers `VELLUM_DESKTOP_APP` or `VELLUM_DEV` for backend selection. Backend resolution in `secure-keys.ts` now follows the CES RPC > CES HTTP > encrypted store priority.
36
33
  - **Dual-writing and broker-unavailable commit behavior** -- the daemon previously committed to the keychain backend even when the broker socket was unreachable, causing operations to fail visibly. This behavior is gone; CES RPC is the primary backend with encrypted store as a graceful fallback.
34
+ - **Swift broker server** (`KeychainBrokerServer.swift`) -- the UDS server in the macOS app that accepted credential requests from the daemon and gateway. Deleted along with its `SecItem*` wrapper (`KeychainBrokerService.swift`).
35
+ - **Gateway broker fallback** -- the gateway's `credential-reader.ts` no longer tries the keychain broker as a secondary fallback. Credential resolution is now CES HTTP > encrypted file store.
37
36
 
38
37
  ## Original Design (Historical)
39
38
 
@@ -51,15 +50,15 @@ The macOS app embedded a `KeychainBrokerServer` (NWListener on a Unix domain soc
51
50
 
52
51
  Transport: Unix domain socket at `~/.vellum/keychain-broker.sock`, newline-delimited JSON.
53
52
 
54
- | Method | Params | Result |
55
- |---|---|---|
56
- | `broker.ping` | none | `{ pong: true }` |
57
- | `key.get` | `{ account }` | `{ found, value? }` |
58
- | `key.set` | `{ account, value }` | `{ stored: true }` |
59
- | `key.delete` | `{ account }` | `{ deleted: true }` |
60
- | `key.list` | none | `{ accounts: string[] }` |
53
+ | Method | Params | Result |
54
+ | ------------- | -------------------- | ------------------------ |
55
+ | `broker.ping` | none | `{ pong: true }` |
56
+ | `key.get` | `{ account }` | `{ found, value? }` |
57
+ | `key.set` | `{ account, value }` | `{ stored: true }` |
58
+ | `key.delete` | `{ account }` | `{ deleted: true }` |
59
+ | `key.list` | none | `{ accounts: string[] }` |
61
60
 
62
- This protocol is still used by migrations 015/016 via the broker client.
61
+ This protocol is no longer used. Migrations 015/016 now use inline `security` CLI helpers that shell out to `/usr/bin/security` directly instead of communicating over UDS.
63
62
 
64
63
  ### Security model
65
64
 
@@ -222,7 +222,7 @@ sequenceDiagram
222
222
  participant Prompter as SecretPrompter
223
223
  participant HTTP as HTTP Transport
224
224
  participant UI as SecretPromptManager (Swift)
225
- participant Keychain as macOS Keychain
225
+ participant Store as Credential Store (CES / encrypted file)
226
226
 
227
227
  Model->>Vault: action: "prompt", service, field, label
228
228
  Vault->>Prompter: requestSecret(service, field, label, ...)
@@ -233,7 +233,7 @@ sequenceDiagram
233
233
  UI->>HTTP: secret_response {requestId, value, delivery: "store"}
234
234
  HTTP->>Prompter: resolve(value, "store")
235
235
  Prompter->>Vault: {value, delivery: "store"}
236
- Vault->>Keychain: setSecureKeyAsync("credential/svc/field", value)
236
+ Vault->>Store: setSecureKeyAsync("credential/svc/field", value)
237
237
  Vault->>Model: "Credential stored securely" (no value in output)
238
238
  else One-Time Send (if enabled)
239
239
  UI->>HTTP: secret_response {requestId, value, delivery: "transient_send"}
@@ -265,7 +265,7 @@ graph TB
265
265
  The `allowOneTimeSend` config gate (default: `false`) enables a secondary "Send Once" button in the secret prompt UI. When used:
266
266
 
267
267
  - The secret value is handed to the `CredentialBroker`, which holds it in memory for the next `consume` or `browserFill` call
268
- - The value is **not** persisted to the keychain
268
+ - The value is **not** persisted to the credential store
269
269
  - The broker discards the value after a single use
270
270
  - The vault tool output confirms delivery without including the secret value — the value is never returned to the model
271
271
  - The config gate must be explicitly enabled by the operator
@@ -274,7 +274,7 @@ The `allowOneTimeSend` config gate (default: `false`) enables a secondary "Send
274
274
 
275
275
  | Component | Location | What it stores |
276
276
  | ------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
277
- | Secret values | macOS Keychain (primary) or encrypted file fallback | Encrypted credential values keyed as `credential/{service}/{field}`. Falls back to encrypted file backend on Linux/headless or when Keychain is unavailable. |
277
+ | Secret values | CES credential store or encrypted file store | Encrypted credential values keyed as `credential/{service}/{field}`. Stored via CES RPC (primary), CES HTTP (containerized), or encrypted file store (fallback). |
278
278
  | Credential metadata | `~/.vellum/workspace/data/credentials/metadata.json` | Service, field, label, policy (allowedTools, allowedDomains), timestamps |
279
279
  | Config | `~/.vellum/workspace/config.*` | `secretDetection` settings: enabled, action, entropyThreshold, allowOneTimeSend |
280
280
 
@@ -283,7 +283,7 @@ The `allowOneTimeSend` config gate (default: `false`) enables a secondary "Send
283
283
  | File | Role |
284
284
  | ---------------------------------------------------- | --------------------------------------------------------------------- |
285
285
  | `assistant/src/tools/credentials/vault.ts` | `credential_store` tool — store, list, delete, prompt actions |
286
- | `assistant/src/security/secure-keys.ts` | Async secure key CRUD via keychain broker and encrypted file store |
286
+ | `assistant/src/security/secure-keys.ts` | Async secure key CRUD via CES and encrypted file store |
287
287
  | `assistant/src/tools/credentials/metadata-store.ts` | JSON file metadata CRUD for credential records |
288
288
  | `assistant/src/tools/credentials/broker.ts` | Brokered credential access with policy enforcement and transient send |
289
289
  | `assistant/src/tools/credentials/policy-validate.ts` | Policy input validation (allowedTools, allowedDomains) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -895,7 +895,7 @@ describe("assistant credentials CLI", () => {
895
895
  expect(result.exitCode).toBe(0);
896
896
  const parsed = JSON.parse(result.stdout);
897
897
  expect(parsed.ok).toBe(true);
898
- expect(parsed.scrubbedValue).toBe("(broker unreachable)");
898
+ expect(parsed.scrubbedValue).toBe("(credential store unreachable)");
899
899
  expect(parsed.brokerUnreachable).toBe(true);
900
900
  });
901
901
 
@@ -913,7 +913,7 @@ describe("assistant credentials CLI", () => {
913
913
  expect(result.exitCode).toBe(1);
914
914
  const parsed = JSON.parse(result.stdout);
915
915
  expect(parsed.ok).toBe(false);
916
- expect(parsed.error).toContain("Keychain broker is unreachable");
916
+ expect(parsed.error).toContain("Credential store is unreachable");
917
917
  });
918
918
  });
919
919
 
@@ -1029,7 +1029,7 @@ describe("assistant credentials CLI", () => {
1029
1029
  expect(result.exitCode).toBe(1);
1030
1030
  const parsed = JSON.parse(result.stdout);
1031
1031
  expect(parsed.ok).toBe(false);
1032
- expect(parsed.error).toContain("Keychain broker is unreachable");
1032
+ expect(parsed.error).toContain("Credential store is unreachable");
1033
1033
  });
1034
1034
 
1035
1035
  test("returns credential-not-found when broker is up", async () => {
@@ -1,252 +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 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("skips when VELLUM_DESKTOP_APP is not set", async () => {
92
- delete process.env.VELLUM_DESKTOP_APP;
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
- "throws when broker is not available after max retry attempts",
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
  });