From f653d7e864b10bdc3a84e0fb7fa494a18b8223d9 Mon Sep 17 00:00:00 2001 From: bthornto Date: Thu, 13 Aug 2026 09:25:03 -0700 Subject: [PATCH 1/4] fix(daemon): use camelCase secretStore on HTTP secrets API Align secrets routes with the rest of the web JSON (apiKey, secretName). YAML/config and proto keep snake_case secret_store; HTTP accepts the old snake_case/store aliases as deprecated input only. Co-authored-by: Cursor --- docs/CONFIGURATION.md | 9 +++--- docs/decisions.md | 2 +- .../daemon/src/daemon/web/api-schemas.test.ts | 4 +-- packages/daemon/src/daemon/web/api-schemas.ts | 8 +++-- packages/daemon/src/daemon/web/server.ts | 21 ++++++------ .../daemon/tests/integration/web-sse.test.ts | 32 +++++++++---------- 6 files changed, 41 insertions(+), 35 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 951b6df6..61a96650 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -412,17 +412,18 @@ providers: ### Option 1b: In-memory (process-lifetime) via secrets API - Same `secret_name` in the `memory` namespace; set value with `SetSecret` / - `POST /api/secrets` and `secret_store: memory` (SetSecret still cannot + `POST /api/secrets` and HTTP `secretStore: memory` (SetSecret still cannot write `env`) - Lives only while the daemon process is running - Independent from keychain — overlapping names are allowed (DR-047) ### Recommended workflow (keys are N:1 with providers) -1. **Add a secret** (store) — `SetSecret` with `secret_store=memory|keychain`, **or** - export an env var yourself. +1. **Add a secret** (store) — `SetSecret` with store `memory|keychain`, HTTP + `secretStore`, **or** export an env var yourself. 2. **Configure the provider** — `secret_name=NAME` and - `secret_store=memory|keychain|env`. For `env`, the variable is resolved at + `secret_store=memory|keychain|env` (YAML/config; HTTP configure uses + camelCase `secretName` / `secretStore`). For `env`, the variable is resolved at request time (it need not exist at configure time). 3. Many providers may share one `(secret_store, secret_name)` pair. diff --git a/docs/decisions.md b/docs/decisions.md index 36f1bd6b..710bca78 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -839,7 +839,7 @@ Numbered DR-046 because main already shipped VS Code webview UX as DR-045. addressed by `(secret_store, secret_name)` — the same name may exist in more than one backend. Clients select the backend on write via gRPC `SetSecretRequest.store` (`SECRET_STORE_MEMORY` / `SECRET_STORE_KEYCHAIN`) or -HTTP `secret_store: "memory" | "keychain"` (default keychain). Get/Delete +HTTP `secretStore: "memory" | "keychain"` (default keychain). Get/Delete take the same store field (default keychain). Provider config records both `secret_name` and `secret_store`; resolve reads only that backend (no cross-store overlay). Memory secrets survive until consumer delete/set or diff --git a/packages/daemon/src/daemon/web/api-schemas.test.ts b/packages/daemon/src/daemon/web/api-schemas.test.ts index f3d943e7..30af2913 100644 --- a/packages/daemon/src/daemon/web/api-schemas.test.ts +++ b/packages/daemon/src/daemon/web/api-schemas.test.ts @@ -174,9 +174,9 @@ describe('secret body schemas', () => { expect(PostSecretByKeyBodySchema.safeParse({ value: '' }).success).toBe(false); }); - it('accepts optional secret_store memory|keychain|env (and legacy store)', () => { + it('accepts optional secretStore memory|keychain|env (and legacy aliases)', () => { expect( - PostSecretBodySchema.safeParse({ key: 'K', value: 'v', secret_store: 'memory' }).success, + PostSecretBodySchema.safeParse({ key: 'K', value: 'v', secretStore: 'memory' }).success, ).toBe(true); expect( PostSecretByKeyBodySchema.safeParse({ value: 'v', store: 'keychain' }).success, diff --git a/packages/daemon/src/daemon/web/api-schemas.ts b/packages/daemon/src/daemon/web/api-schemas.ts index 4f073dc8..e9840b45 100644 --- a/packages/daemon/src/daemon/web/api-schemas.ts +++ b/packages/daemon/src/daemon/web/api-schemas.ts @@ -48,8 +48,10 @@ export const PostSecretByKeyBodySchema = z .object({ value: z.string().min(1), /** Default: keychain (persistent). memory = process-lifetime only. */ + secretStore: SecretStoreFieldSchema, + /** @deprecated Use secretStore (HTTP camelCase). */ secret_store: SecretStoreFieldSchema, - /** @deprecated Use secret_store. */ + /** @deprecated Use secretStore. */ store: SecretStoreFieldSchema, }) .strict(); @@ -59,8 +61,10 @@ export const PostSecretBodySchema = z key: z.string().min(1), value: z.string().min(1), /** Default: keychain (persistent). memory = process-lifetime only. */ + secretStore: SecretStoreFieldSchema, + /** @deprecated Use secretStore (HTTP camelCase). */ secret_store: SecretStoreFieldSchema, - /** @deprecated Use secret_store. */ + /** @deprecated Use secretStore. */ store: SecretStoreFieldSchema, }) .strict(); diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index 2e978b0c..79edd791 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -1054,7 +1054,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): /** * POST /api/secrets/:key - Set a specific secret (API key) - * Body: { value: string, store?: "memory" | "keychain" } + * Body: { value: string, secretStore?: "memory" | "keychain" } */ app.post('/api/secrets/:key', async (req, res) => { try { @@ -1065,7 +1065,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): return; } const storeChoice = parseSecretStoreChoice( - parsed.data.secret_store ?? parsed.data.store ?? 'keychain', + parsed.data.secretStore ?? parsed.data.secret_store ?? parsed.data.store ?? 'keychain', ); if (!storeChoice.ok) { sendBadRequest(res, storeChoice.error); @@ -1085,7 +1085,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): const auditSource = storeChoice.backend === 'memory' ? 'http-secrets-memory' : 'http-secrets'; auditSecretChange({ key, op: 'set', source: auditSource }); - res.json({ success: true, secret_store: storeChoice.backend }); + res.json({ success: true, secretStore: storeChoice.backend }); state.notifyModelsChanged('secret_updated'); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -1096,7 +1096,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): /** * POST /api/secrets - Set a secret (API key) — legacy route - * Body: { key: string, value: string, secret_store?: "memory" | "keychain" } + * Body: { key: string, value: string, secretStore?: "memory" | "keychain" } */ app.post('/api/secrets', async (req, res) => { try { @@ -1106,7 +1106,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): return; } const storeChoice = parseSecretStoreChoice( - parsed.data.secret_store ?? parsed.data.store ?? 'keychain', + parsed.data.secretStore ?? parsed.data.secret_store ?? parsed.data.store ?? 'keychain', ); if (!storeChoice.ok) { sendBadRequest(res, storeChoice.error); @@ -1126,7 +1126,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): const auditSource = storeChoice.backend === 'memory' ? 'http-secrets-memory' : 'http-secrets'; auditSecretChange({ key: parsed.data.key, op: 'set', source: auditSource }); - res.json({ success: true, secret_store: storeChoice.backend }); + res.json({ success: true, secretStore: storeChoice.backend }); state.notifyModelsChanged('secret_updated'); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -1137,7 +1137,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): /** * DELETE /api/secrets/:key - Delete a secret - * Query: secret_store=memory|keychain (default keychain) + * Query: secretStore=memory|keychain (default keychain) */ app.delete('/api/secrets/:key', async (req, res) => { try { @@ -1147,7 +1147,8 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): return; } const storeChoice = parseSecretStoreChoice( - (req.query.secret_store as string | undefined) ?? + (req.query.secretStore as string | undefined) ?? + (req.query.secret_store as string | undefined) ?? (req.query.store as string | undefined) ?? 'keychain', ); @@ -1172,7 +1173,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): source: storeChoice.backend === 'memory' ? 'http-secrets-memory' : 'http-secrets', }); - res.json({ success: true, secret_store: storeChoice.backend }); + res.json({ success: true, secretStore: storeChoice.backend }); state.notifyModelsChanged('secret_deleted'); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -1535,7 +1536,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): res.status(400).json({ error: `Secret "${secretNameRef}" not found in keychain; ` + - `POST /api/secrets first, pass apiKey, or set secret_store`, + `POST /api/secrets first, pass apiKey, or set secretStore`, }); return; } diff --git a/packages/daemon/tests/integration/web-sse.test.ts b/packages/daemon/tests/integration/web-sse.test.ts index 4c3e6275..c47fdbbc 100644 --- a/packages/daemon/tests/integration/web-sse.test.ts +++ b/packages/daemon/tests/integration/web-sse.test.ts @@ -476,43 +476,43 @@ describe('Web API - Secrets Endpoints', () => { expect(body.success).toBe(true); }); - it('POST /api/secrets/:key should accept secret_store=memory', async () => { + it('POST /api/secrets/:key should accept secretStore=memory', async () => { const { statusCode, body } = await httpRequest('POST', `${baseUrl}/api/secrets/MEM_KEY`, { value: 'ephemeral', - secret_store: 'memory', + secretStore: 'memory', }); expect(statusCode).toBe(200); expect(body.success).toBe(true); - expect(body.secret_store).toBe('memory'); + expect(body.secretStore).toBe('memory'); }); - it('POST /api/secrets should reject secret_store=env', async () => { + it('POST /api/secrets should reject secretStore=env', async () => { const { statusCode, body } = await httpRequest('POST', `${baseUrl}/api/secrets`, { key: 'ENV_KEY', value: 'nope', - secret_store: 'env', + secretStore: 'env', }); expect(statusCode).toBe(400); expect(body.error).toMatch(/ENV/i); }); - it('DELETE /api/secrets/:key?secret_store=memory clears memory only', async () => { + it('DELETE /api/secrets/:key?secretStore=memory clears memory only', async () => { await httpRequest('POST', `${baseUrl}/api/secrets/BOTH_HTTP`, { value: 'persistent', - secret_store: 'keychain', + secretStore: 'keychain', }); await httpRequest('POST', `${baseUrl}/api/secrets/BOTH_HTTP`, { value: 'ephemeral', - secret_store: 'memory', + secretStore: 'memory', }); const { statusCode, body } = await httpRequest( 'DELETE', - `${baseUrl}/api/secrets/BOTH_HTTP?secret_store=memory`, + `${baseUrl}/api/secrets/BOTH_HTTP?secretStore=memory`, ); expect(statusCode).toBe(200); expect(body.success).toBe(true); - expect(body.secret_store).toBe('memory'); + expect(body.secretStore).toBe('memory'); const memStatus = await httpRequest( 'GET', @@ -532,7 +532,7 @@ describe('Web API - Secrets Endpoints', () => { it('GET /api/key-status?source=memory reports memory-only secrets', async () => { await httpRequest('POST', `${baseUrl}/api/secrets/MEM_STATUS`, { value: 'ephemeral', - secret_store: 'memory', + secretStore: 'memory', }); const mem = await httpRequest( 'GET', @@ -564,7 +564,7 @@ describe('Web API - Secrets Endpoints', () => { it('POST /api/provider/:id/configure picks an existing memory secret', async () => { await httpRequest('POST', `${baseUrl}/api/secrets/SHARED_MEM`, { value: 'shared-value', - secret_store: 'memory', + secretStore: 'memory', }); const { statusCode, body } = await httpRequest('POST', `${baseUrl}/api/provider/shared-openai/configure`, { engine: 'openai', @@ -600,7 +600,7 @@ describe('Web API - Secrets Endpoints', () => { it('POST /api/provider/:id/configure defaults omitted secretStore to keychain', async () => { await httpRequest('POST', `${baseUrl}/api/secrets/DEFAULT_KC`, { value: 'kc-value', - secret_store: 'keychain', + secretStore: 'keychain', }); const { statusCode, body } = await httpRequest('POST', `${baseUrl}/api/provider/default-kc/configure`, { engine: 'openai', @@ -631,7 +631,7 @@ describe('Web API - Secrets Endpoints', () => { it('POST /api/secrets/:key rejects invalid store and legacy POST accepts memory', async () => { const bad = await httpRequest('POST', `${baseUrl}/api/secrets/BAD_STORE`, { value: 'x', - secret_store: 'vault', + secretStore: 'vault', }); expect(bad.statusCode).toBe(400); @@ -641,14 +641,14 @@ describe('Web API - Secrets Endpoints', () => { store: 'memory', }); expect(legacy.statusCode).toBe(200); - expect(legacy.body.secret_store).toBe('memory'); + expect(legacy.body.secretStore).toBe('memory'); expect(await mockSecretStore.getFrom('memory', 'LEGACY_MEM')).toBe('v'); }); it('DELETE /api/secrets/:key rejects invalid store', async () => { const { statusCode } = await httpRequest( 'DELETE', - `${baseUrl}/api/secrets/ANY?secret_store=vault`, + `${baseUrl}/api/secrets/ANY?secretStore=vault`, ); expect(statusCode).toBe(400); }); From c2a0cd3de71ada46493452522e939fb679758578 Mon Sep 17 00:00:00 2001 From: bthornto Date: Thu, 13 Aug 2026 09:38:58 -0700 Subject: [PATCH 2/4] fix(daemon): drop HTTP secret_store/store aliases; secretStore only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard-break the miss from #113 — no deprecated snake_case or bare store fields on secrets request bodies or DELETE query. Co-authored-by: Cursor --- docs/decisions.md | 2 +- .../daemon/src/daemon/web/api-schemas.test.ts | 14 ++++++++++---- packages/daemon/src/daemon/web/api-schemas.ts | 8 -------- packages/daemon/src/daemon/web/server.ts | 13 +++---------- packages/daemon/tests/integration/web-sse.test.ts | 15 ++++++++++----- 5 files changed, 24 insertions(+), 28 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 710bca78..70353180 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -840,7 +840,7 @@ addressed by `(secret_store, secret_name)` — the same name may exist in more than one backend. Clients select the backend on write via gRPC `SetSecretRequest.store` (`SECRET_STORE_MEMORY` / `SECRET_STORE_KEYCHAIN`) or HTTP `secretStore: "memory" | "keychain"` (default keychain). Get/Delete -take the same store field (default keychain). Provider config records both +take the same field (default keychain). Provider config records both `secret_name` and `secret_store`; resolve reads only that backend (no cross-store overlay). Memory secrets survive until consumer delete/set or daemon restart — no TTL and no clear-on-disconnect. `SECRET_STORE_ENV` is diff --git a/packages/daemon/src/daemon/web/api-schemas.test.ts b/packages/daemon/src/daemon/web/api-schemas.test.ts index 30af2913..8ed02fe5 100644 --- a/packages/daemon/src/daemon/web/api-schemas.test.ts +++ b/packages/daemon/src/daemon/web/api-schemas.test.ts @@ -174,18 +174,24 @@ describe('secret body schemas', () => { expect(PostSecretByKeyBodySchema.safeParse({ value: '' }).success).toBe(false); }); - it('accepts optional secretStore memory|keychain|env (and legacy aliases)', () => { + it('accepts optional secretStore memory|keychain|env', () => { expect( PostSecretBodySchema.safeParse({ key: 'K', value: 'v', secretStore: 'memory' }).success, ).toBe(true); expect( - PostSecretByKeyBodySchema.safeParse({ value: 'v', store: 'keychain' }).success, + PostSecretByKeyBodySchema.safeParse({ value: 'v', secretStore: 'keychain' }).success, ).toBe(true); expect( - PostSecretBodySchema.safeParse({ key: 'K', value: 'v', secret_store: 'env' }).success, + PostSecretBodySchema.safeParse({ key: 'K', value: 'v', secretStore: 'env' }).success, ).toBe(true); expect( - PostSecretBodySchema.safeParse({ key: 'K', value: 'v', store: 'vault' }).success, + PostSecretBodySchema.safeParse({ key: 'K', value: 'v', secretStore: 'vault' }).success, + ).toBe(false); + expect( + PostSecretBodySchema.safeParse({ key: 'K', value: 'v', secret_store: 'memory' }).success, + ).toBe(false); + expect( + PostSecretBodySchema.safeParse({ key: 'K', value: 'v', store: 'memory' }).success, ).toBe(false); }); }); diff --git a/packages/daemon/src/daemon/web/api-schemas.ts b/packages/daemon/src/daemon/web/api-schemas.ts index e9840b45..ee1a2eee 100644 --- a/packages/daemon/src/daemon/web/api-schemas.ts +++ b/packages/daemon/src/daemon/web/api-schemas.ts @@ -49,10 +49,6 @@ export const PostSecretByKeyBodySchema = z value: z.string().min(1), /** Default: keychain (persistent). memory = process-lifetime only. */ secretStore: SecretStoreFieldSchema, - /** @deprecated Use secretStore (HTTP camelCase). */ - secret_store: SecretStoreFieldSchema, - /** @deprecated Use secretStore. */ - store: SecretStoreFieldSchema, }) .strict(); @@ -62,10 +58,6 @@ export const PostSecretBodySchema = z value: z.string().min(1), /** Default: keychain (persistent). memory = process-lifetime only. */ secretStore: SecretStoreFieldSchema, - /** @deprecated Use secretStore (HTTP camelCase). */ - secret_store: SecretStoreFieldSchema, - /** @deprecated Use secretStore. */ - store: SecretStoreFieldSchema, }) .strict(); diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index 79edd791..21ae4999 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -1064,9 +1064,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): sendBadRequest(res, parsed.error); return; } - const storeChoice = parseSecretStoreChoice( - parsed.data.secretStore ?? parsed.data.secret_store ?? parsed.data.store ?? 'keychain', - ); + const storeChoice = parseSecretStoreChoice(parsed.data.secretStore ?? 'keychain'); if (!storeChoice.ok) { sendBadRequest(res, storeChoice.error); return; @@ -1105,9 +1103,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): sendBadRequest(res, parsed.error); return; } - const storeChoice = parseSecretStoreChoice( - parsed.data.secretStore ?? parsed.data.secret_store ?? parsed.data.store ?? 'keychain', - ); + const storeChoice = parseSecretStoreChoice(parsed.data.secretStore ?? 'keychain'); if (!storeChoice.ok) { sendBadRequest(res, storeChoice.error); return; @@ -1147,10 +1143,7 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): return; } const storeChoice = parseSecretStoreChoice( - (req.query.secretStore as string | undefined) ?? - (req.query.secret_store as string | undefined) ?? - (req.query.store as string | undefined) ?? - 'keychain', + (req.query.secretStore as string | undefined) ?? 'keychain', ); if (!storeChoice.ok) { sendBadRequest(res, storeChoice.error); diff --git a/packages/daemon/tests/integration/web-sse.test.ts b/packages/daemon/tests/integration/web-sse.test.ts index c47fdbbc..f073b0ca 100644 --- a/packages/daemon/tests/integration/web-sse.test.ts +++ b/packages/daemon/tests/integration/web-sse.test.ts @@ -628,21 +628,26 @@ describe('Web API - Secrets Endpoints', () => { expect(body.success).toBe(true); }); - it('POST /api/secrets/:key rejects invalid store and legacy POST accepts memory', async () => { + it('POST /api/secrets/:key rejects invalid store and snake_case aliases', async () => { const bad = await httpRequest('POST', `${baseUrl}/api/secrets/BAD_STORE`, { value: 'x', secretStore: 'vault', }); expect(bad.statusCode).toBe(400); - const legacy = await httpRequest('POST', `${baseUrl}/api/secrets`, { + const snake = await httpRequest('POST', `${baseUrl}/api/secrets`, { + key: 'SNAKE_MEM', + value: 'v', + secret_store: 'memory', + }); + expect(snake.statusCode).toBe(400); + + const legacyStore = await httpRequest('POST', `${baseUrl}/api/secrets`, { key: 'LEGACY_MEM', value: 'v', store: 'memory', }); - expect(legacy.statusCode).toBe(200); - expect(legacy.body.secretStore).toBe('memory'); - expect(await mockSecretStore.getFrom('memory', 'LEGACY_MEM')).toBe('v'); + expect(legacyStore.statusCode).toBe(400); }); it('DELETE /api/secrets/:key rejects invalid store', async () => { From 3b7352be39b3a7ce0e3394a5a7cbc9fb32576d45 Mon Sep 17 00:00:00 2001 From: bthornto Date: Thu, 13 Aug 2026 09:56:01 -0700 Subject: [PATCH 3/4] fix(daemon): align GET secrets list and DELETE legacy query rejection List responses now use secretStore; DELETE rejects secret_store/store query params so clients cannot silently hit the wrong backend. Co-authored-by: Cursor --- packages/daemon/src/daemon/web/server.ts | 11 ++++++-- .../daemon/tests/integration/web-sse.test.ts | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/daemon/web/server.ts b/packages/daemon/src/daemon/web/server.ts index 21ae4999..7680ce63 100644 --- a/packages/daemon/src/daemon/web/server.ts +++ b/packages/daemon/src/daemon/web/server.ts @@ -1033,11 +1033,11 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): if (backends.length === 0) { return [{ key, engine: e.id, hasValue: false }]; } - return backends.map((store) => ({ + return backends.map((backend) => ({ key, engine: e.id, hasValue: true, - store, + secretStore: backend, })); } const hasValue = await state.secretStore.has(key); @@ -1142,6 +1142,13 @@ export function createWebApp(state: DaemonState, options?: WebSecurityOptions): sendBadRequest(res, parsed.error); return; } + if ( + Object.prototype.hasOwnProperty.call(req.query, 'secret_store') || + Object.prototype.hasOwnProperty.call(req.query, 'store') + ) { + sendBadRequest(res, 'Use secretStore query param (secret_store/store are not accepted)'); + return; + } const storeChoice = parseSecretStoreChoice( (req.query.secretStore as string | undefined) ?? 'keychain', ); diff --git a/packages/daemon/tests/integration/web-sse.test.ts b/packages/daemon/tests/integration/web-sse.test.ts index f073b0ca..a87ddb3c 100644 --- a/packages/daemon/tests/integration/web-sse.test.ts +++ b/packages/daemon/tests/integration/web-sse.test.ts @@ -456,6 +456,10 @@ describe('Web API - Config Endpoints', () => { describe('Web API - Secrets Endpoints', () => { it('GET /api/secrets should return { secrets: [...] } with hasValue (camelCase)', async () => { + await httpRequest('POST', `${baseUrl}/api/secrets/OPENAI_API_KEY`, { + value: 'ephemeral', + secretStore: 'memory', + }); const { statusCode, body } = await httpRequest('GET', `${baseUrl}/api/secrets`); expect(statusCode).toBe(200); expect(body).toHaveProperty('secrets'); @@ -465,7 +469,13 @@ describe('Web API - Secrets Endpoints', () => { expect(s).toHaveProperty('key'); expect(s).toHaveProperty('hasValue'); expect(s).not.toHaveProperty('has_value'); + expect(s).not.toHaveProperty('store'); } + const listed = body.secrets.filter((s: { key: string }) => s.key === 'OPENAI_API_KEY'); + expect(listed.length).toBeGreaterThanOrEqual(1); + expect(listed.some((s: { secretStore?: string; hasValue?: boolean }) => ( + s.hasValue === true && s.secretStore === 'memory' + ))).toBe(true); }); it('POST /api/secrets/:key should set a secret', async () => { @@ -658,6 +668,22 @@ describe('Web API - Secrets Endpoints', () => { expect(statusCode).toBe(400); }); + it('DELETE /api/secrets/:key rejects legacy secret_store/store query params', async () => { + const snake = await httpRequest( + 'DELETE', + `${baseUrl}/api/secrets/ANY?secret_store=memory`, + ); + expect(snake.statusCode).toBe(400); + expect(snake.body.error).toMatch(/secretStore/i); + + const bare = await httpRequest( + 'DELETE', + `${baseUrl}/api/secrets/ANY?store=memory`, + ); + expect(bare.statusCode).toBe(400); + expect(bare.body.error).toMatch(/secretStore/i); + }); + it('POST /api/provider/:id/configure rejects apiKey with secretStore=env', async () => { const { statusCode, body } = await httpRequest('POST', `${baseUrl}/api/provider/bad-openai/configure`, { engine: 'openai', From f01d841cda755447de4eb8aabf6e6ca2eee9f590 Mon Sep 17 00:00:00 2001 From: bthornto Date: Thu, 13 Aug 2026 09:56:03 -0700 Subject: [PATCH 4/4] test(daemon): assert keytar import failures via loadError Parallel suites can clobber console.warn spies; sticky loadError is the stable contract under test. Co-authored-by: Cursor --- .../daemon/src/daemon/secrets/keychain.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/daemon/src/daemon/secrets/keychain.test.ts b/packages/daemon/src/daemon/secrets/keychain.test.ts index c9b203d1..f5d6217e 100644 --- a/packages/daemon/src/daemon/secrets/keychain.test.ts +++ b/packages/daemon/src/daemon/secrets/keychain.test.ts @@ -63,7 +63,6 @@ function patchNodeSeaModule( } describe('KeychainSecretStore', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); beforeEach(() => { @@ -73,7 +72,6 @@ describe('KeychainSecretStore', () => { mocks.getPassword.mockReset(); mocks.setPassword.mockReset(); mocks.deletePassword.mockReset(); - warnSpy.mockClear(); errorSpy.mockClear(); }); @@ -134,13 +132,14 @@ describe('KeychainSecretStore', () => { await expect(store.get('MISSING')).resolves.toBeNull(); }); - it('warns when keytar import fails', async () => { + it('records Error keytar import failures', async () => { const store = await loadStoreWithFailingKeytarImport(); + const internals = store as unknown as KeychainInternals; await expect(store.get('MISSING')).resolves.toBeNull(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Secrets] keytar not available: keytar missing'), - ); + // Prefer sticky loadError over console.warn — parallel suites can clobber spies. + expect(internals.keytar).toBeNull(); + expect(internals.loadError).toMatch(/keytar missing/); }); it('get returns null and logs when keytar throws', async () => { @@ -411,10 +410,11 @@ describe('KeychainSecretStore', () => { it('records non-Error keytar import failures', async () => { const store = await loadStoreWithFailingKeytarImport('native addon missing'); + const internals = store as unknown as KeychainInternals; await expect(store.get('KEY')).resolves.toBeNull(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Secrets] keytar not available: native addon missing'), - ); + // Prefer sticky loadError over console.warn — parallel suites can clobber spies. + expect(internals.keytar).toBeNull(); + expect(internals.loadError).toBe('native addon missing'); }); });