Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -839,8 +839,8 @@ 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
take the same store field (default keychain). Provider config records both
HTTP `secretStore: "memory" | "keychain"` (default keychain). Get/Delete
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
Expand Down
18 changes: 9 additions & 9 deletions packages/daemon/src/daemon/secrets/keychain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ function patchNodeSeaModule(
}

describe('KeychainSecretStore', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

beforeEach(() => {
Expand All @@ -73,7 +72,6 @@ describe('KeychainSecretStore', () => {
mocks.getPassword.mockReset();
mocks.setPassword.mockReset();
mocks.deletePassword.mockReset();
warnSpy.mockClear();
errorSpy.mockClear();
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
16 changes: 11 additions & 5 deletions packages/daemon/src/daemon/web/api-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,18 +174,24 @@ 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', () => {
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,
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);
});
});
Expand Down
8 changes: 2 additions & 6 deletions packages/daemon/src/daemon/web/api-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,7 @@ export const PostSecretByKeyBodySchema = z
.object({
value: z.string().min(1),
/** Default: keychain (persistent). memory = process-lifetime only. */
secret_store: SecretStoreFieldSchema,
/** @deprecated Use secret_store. */
store: SecretStoreFieldSchema,
secretStore: SecretStoreFieldSchema,
})
.strict();

Expand All @@ -59,9 +57,7 @@ export const PostSecretBodySchema = z
key: z.string().min(1),
value: z.string().min(1),
/** Default: keychain (persistent). memory = process-lifetime only. */
secret_store: SecretStoreFieldSchema,
/** @deprecated Use secret_store. */
store: SecretStoreFieldSchema,
secretStore: SecretStoreFieldSchema,
})
.strict();

Expand Down
37 changes: 19 additions & 18 deletions packages/daemon/src/daemon/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1033,11 +1033,11 @@
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);
Expand All @@ -1054,7 +1054,7 @@

/**
* 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 {
Expand All @@ -1064,9 +1064,7 @@
sendBadRequest(res, parsed.error);
return;
}
const storeChoice = parseSecretStoreChoice(
parsed.data.secret_store ?? parsed.data.store ?? 'keychain',
);
const storeChoice = parseSecretStoreChoice(parsed.data.secretStore ?? 'keychain');
if (!storeChoice.ok) {
sendBadRequest(res, storeChoice.error);
return;
Expand All @@ -1085,7 +1083,7 @@
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 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
state.notifyModelsChanged('secret_updated');
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
Expand All @@ -1096,7 +1094,7 @@

/**
* 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 {
Expand All @@ -1105,9 +1103,7 @@
sendBadRequest(res, parsed.error);
return;
}
const storeChoice = parseSecretStoreChoice(
parsed.data.secret_store ?? parsed.data.store ?? 'keychain',
);
const storeChoice = parseSecretStoreChoice(parsed.data.secretStore ?? 'keychain');
if (!storeChoice.ok) {
sendBadRequest(res, storeChoice.error);
return;
Expand All @@ -1126,7 +1122,7 @@
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);
Expand All @@ -1137,7 +1133,7 @@

/**
* 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 {
Expand All @@ -1146,10 +1142,15 @@
sendBadRequest(res, parsed.error);
return;
}
if (
Object.prototype.hasOwnProperty.call(req.query, 'secret_store') ||

Check warning on line 1146 in packages/daemon/src/daemon/web/server.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Object.hasOwn()' instead of 'Object.prototype.hasOwnProperty.call()'.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_abbenay&issues=AZ_8Eh02-A7VkHH8wjyC&open=AZ_8Eh02-A7VkHH8wjyC&pullRequest=119
Object.prototype.hasOwnProperty.call(req.query, 'store')

Check warning on line 1147 in packages/daemon/src/daemon/web/server.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Object.hasOwn()' instead of 'Object.prototype.hasOwnProperty.call()'.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_abbenay&issues=AZ_8Eh02-A7VkHH8wjyD&open=AZ_8Eh02-A7VkHH8wjyD&pullRequest=119
) {
sendBadRequest(res, 'Use secretStore query param (secret_store/store are not accepted)');
return;
}
const storeChoice = parseSecretStoreChoice(
(req.query.secret_store as string | undefined) ??
(req.query.store as string | undefined) ??
'keychain',
(req.query.secretStore as string | undefined) ?? 'keychain',
);
Comment thread
cidrblock marked this conversation as resolved.
if (!storeChoice.ok) {
sendBadRequest(res, storeChoice.error);
Expand All @@ -1172,7 +1173,7 @@
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);
Expand Down Expand Up @@ -1535,7 +1536,7 @@
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;
}
Expand Down
Loading
Loading