Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7abfae0
feat(daemon): add process-lifetime in-memory secret store
cidrblock Aug 12, 2026
3849ce7
feat(daemon): let ConfigureProvider pick a named secret
cidrblock Aug 12, 2026
6e9eac8
refactor(daemon): prefer secret_name and secret_store vocabulary
cidrblock Aug 12, 2026
111c3ab
feat(daemon): allow secret_store=env for provider references
cidrblock Aug 12, 2026
03bd64f
fix(daemon): narrow secret_store writes to memory|keychain for tsc
cidrblock Aug 12, 2026
3d060c4
refactor(daemon): namespace secrets by (store, name) via SecretStoreR…
cidrblock Aug 12, 2026
4077c90
fix(daemon): align secret remove/status with namespaced stores
cidrblock Aug 12, 2026
6c1e6b8
fix(daemon): normalize keytar missing values to null
cidrblock Aug 12, 2026
9a5b7be
test(daemon): cover namespaced secret store resolve and remove paths
cidrblock Aug 13, 2026
7bcc559
test(daemon): raise patch coverage for namespaced secret HTTP/gRPC paths
cidrblock Aug 13, 2026
8ca5edb
ci(sonar): classify test-env.ts as a test helper, not production source
cidrblock Aug 13, 2026
00841c9
test(daemon): cover withEnv restore path for previously set keys
cidrblock Aug 13, 2026
06f6a31
fix(proto): make secret request store fields optional
cidrblock Aug 13, 2026
5f4c61e
fix(daemon): move withEnv helper out of production sources
cidrblock Aug 13, 2026
2b3b896
ci(sonar): sync finalize fork PR resolution with #118
cidrblock Aug 13, 2026
52c5066
Merge branch 'main' into feat/in-memory-secret-store
cidrblock Aug 13, 2026
05fbef8
ci(sonar): exclude generated vscode proto stubs from analysis
cidrblock Aug 13, 2026
77d5875
test(daemon): assert keytar load-failure cache without console.warn spy
cidrblock Aug 13, 2026
7edbbe3
fix(daemon): reject memory secret_store without SecretStoreRegistry
cidrblock Aug 13, 2026
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
13 changes: 10 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Abbenay is a unified AI daemon and library written in TypeScript/Node.js that pr
│ │
│ ┌─ daemon layer ────────────────────────────────────────────────────┐ │
│ │ DaemonState gRPC Server VS Code Backchannel │ │
│ │ CLI (Commander) Web Dashboard (Express) KeychainSecretStore │ │
│ │ CLI (Commander) Web Dashboard (Express) SecretStoreRegistry │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────┬──────────────────────────────┘
Expand Down Expand Up @@ -87,6 +87,7 @@ Full application layer. Extends core with transport, UI, and CLI.
| `daemon/web/server.ts` | Express web server + REST API |
| `daemon/web/openai-compat.ts` | OpenAI-compatible `/v1/*` routes (models, chat completions) |
| `daemon/web/grpc-web-control.ts` | gRPC client for web server control |
| `daemon/secrets/registry.ts` | `SecretStoreRegistry` (memory + keychain namespaces) |
| `daemon/secrets/keychain.ts` | `KeychainSecretStore` (keytar native addon) |

## Components
Expand Down Expand Up @@ -184,7 +185,8 @@ Secrets are managed explicitly per-provider with two options:
- macOS: Keychain
- Linux: libsecret (GNOME Keyring / KDE Wallet)
- Windows: Credential Vault
- Config references key by name: `api_key_keychain_name: "OPENAI_API_KEY"`
- Config references secret by name: `secret_name: "OPENAI_API_KEY"`
(legacy: `api_key_keychain_name`)

### Option 2: Environment Variable Reference
- Config specifies env var name: `api_key_env_var_name: "OPENAI_API_KEY"`
Expand All @@ -203,7 +205,12 @@ interface SecretStore {
}
```

`CoreState` accepts any `SecretStore` via constructor injection. `DaemonState` uses `KeychainSecretStore` (keytar-backed) by default. Tests and library consumers can use `MemorySecretStore`.
`CoreState` accepts any `SecretStore` via constructor injection. `DaemonState`
uses `SecretStoreRegistry` (discrete process-lifetime `MemorySecretStore` +
keytar-backed `KeychainSecretStore` namespaces; resolve via
`(secret_store, secret_name)`) by default. Tests and library consumers can
use `MemorySecretStore` alone. `NamespacedSecretStore` extends the interface
for multi-backend `getFrom` / `setIn` / …

## Configuration

Expand Down
57 changes: 41 additions & 16 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,35 +382,59 @@ Legacy sessions without an `owner` field are treated as `local`.

Each provider can specify exactly ONE of these (mutually exclusive):

### Option 1: Keychain Storage (`api_key_keychain_name`)
### Option 1: Secret store (`secret_name` + `secret_store`)

- Key stored in system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
- Specify the key name used in keychain
- Set via web dashboard "API Key" toggle
- `secret_name` is the logical name within a backend
- `secret_store` selects the namespace:
- `keychain` (default when omitted) → OS keychain
- `memory` → process-lifetime daemon memory
- `env` → process environment variable named `secret_name`
- The same `secret_name` may exist in more than one store; resolve uses only
the provider's `secret_store` (no overlay)
- Legacy aliases: `api_key_keychain_name`, `api_key_env_var_name`

```yaml
providers:
my-openai:
engine: openai
api_key_keychain_name: "OPENAI_API_KEY"
secret_name: "OPENAI_API_KEY"
secret_store: keychain # optional; defaults to keychain
```

### Option 2: Environment Variable (`api_key_env_var_name`)

- Key read from environment variable at runtime
- Specify the env var name to check
- Set via web dashboard "Env" toggle

```yaml
providers:
anthropic-work:
engine: anthropic
api_key_env_var_name: "ANTHROPIC_API_KEY"
ci-openai:
engine: openai
secret_name: "OPENAI_API_KEY"
secret_store: env # already exported in the process environment
```

### 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
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.
2. **Configure the provider** — `secret_name=NAME` and
`secret_store=memory|keychain|env`. 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.

### Option 2: Environment Variable (legacy `api_key_env_var_name`)

Prefer `secret_name` + `secret_store: env`. The legacy field still works and is
mirrored on load.

### Fallback

If neither option is set, the engine's default environment variable is checked (e.g., `OPENAI_API_KEY` for the `openai` engine).
If neither option is set, the engine's default environment variable is checked
(e.g., `OPENAI_API_KEY` for the `openai` engine).

## Supported Engines

Expand Down Expand Up @@ -713,7 +737,8 @@ steal prompts/responses/keys — constrained by the
| Config files mode `0600` | User-only read/write on disk |
| HTTP Bearer auth (DR-030) | Unauthenticated callers cannot read/write secrets or configure providers |
| gRPC consumer capabilities (DR-037) | On non-localhost binds, sensitive RPCs require token + capability (`secrets`, `providers`, `config`, `mcp_register`, …) |
| Secret API shape | HTTP `GET /api/secrets` returns key names + `hasValue` only — never secret values. gRPC `GetSecret` (value-returning) requires the `secrets` capability |
| Secret API shape | HTTP `GET /api/secrets` returns key names + `hasValue` (+ `store` when present) only — never secret values. gRPC `GetSecret` (value-returning) requires the `secrets` capability |
| In-memory secrets (DR-047) | Optional process-lifetime backend; never written to disk/keychain; same auth gates as keychain secrets; vanishes on daemon restart |
| Secret / endpoint audit logs | `[Audit] secret changed` and `[Audit] provider endpoint changed` (never log secret values) |
| Provider endpoint policy (DR-040) | Malformed / disallowed `base_url` values are rejected; changes are audited |
| Localhost bind defaults | HTTP/gRPC default to loopback; non-loopback requires intentional opt-in |
Expand Down
5 changes: 5 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ defaults (or stronger) in place.
- `--host 0.0.0.0`, `ABBENAY_HTTP_AUTH=0`, and `--insecure` deliberately weaken posture.
- Cloud providers still receive prompts if you configure them.
- HTTP on loopback remains plaintext; use a TLS-terminating proxy when exposing beyond the machine.
- In-memory secrets (DR-047) live in a discrete `memory` namespace (not written
to disk or the OS keychain). Providers must set `secret_store: memory` to
resolve them. Authenticated clients with the `secrets` capability can still
read them for the life of the daemon process. Restart clears them; treat
RAM and process dumps accordingly.

---

Expand Down
33 changes: 33 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -828,3 +828,36 @@ matching request tool with `400`. When tools mode is `off`, ignore
`tool_choice` to force or suppress tool use; dropping it silently made
passthrough incomplete versus a real OpenAI-compatible endpoint (issue #77).
Numbered DR-046 because main already shipped VS Code webview UX as DR-045.

---

## DR-047: Process-lifetime in-memory secret store

**Date:** 2026-08-12
**Decision:** The daemon exposes discrete secret backends via
`SecretStoreRegistry` (OS keychain + process-lifetime memory today). Values are
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
`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
rejected on write; env credentials are configure/reference only
(`secret_store: env` → `process.env[secret_name]`). `ConfigureProvider`
accepts `secret_name` + optional `secret_store` to reference an existing
secret (N providers → 1 key). Raw `api_key` alone remains a legacy shortcut
that invents `${PROVIDER_ID}_API_KEY`. Legacy `api_key_env_var_name` maps to
`secret_name` + `secret_store: env` on load; omitted store for store-backed
secrets defaults to `keychain`. `SecretStoreRegistry` + `NamespacedSecretStore`
(`getFrom` / `setIn` / …) is the extension surface for additional backends later.
**Rationale:** Ephemeral keys for clients that must not persist credentials
were already sketched (`MemorySecretStore`, proto `SECRET_STORE_MEMORY`) but
unused by the daemon. Discrete namespaces (not move-on-write exclusivity)
match provider config, which already carries `secret_store`, and position
the daemon for N pluggable backends without renaming. Defaulting unspecified
store to keychain preserves existing callers. Keys are not 1:1 with
providers — configure should pick a secret by name after `SetSecret`.
`secret_name` / `secret_store` name the real abstraction;
`api_key_keychain_name` was a historical misnomer.
6 changes: 6 additions & 0 deletions packages/daemon/src/core/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ export const ModelConfigSchema = z
export const ProviderConfigSchema = z
.object({
engine: z.string().min(1),
/** Logical secret name (store key or env var). Preferred. */
secret_name: z.string().min(1).optional(),
/** memory | keychain | env — where secret_name is resolved. */
secret_store: z.enum(['memory', 'keychain', 'env']).optional(),
/** @deprecated Use secret_name. */
api_key_keychain_name: z.string().min(1).optional(),
/** @deprecated Use secret_name + secret_store: env. */
api_key_env_var_name: z.string().min(1).optional(),
base_url: ProviderBaseUrlSchema.optional(),
models: z.record(z.string(), ModelConfigSchema).optional(),
Expand Down
103 changes: 103 additions & 0 deletions packages/daemon/src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import {
getEnabledModelNames,
isValidVirtualName,
resolveEngineModelId,
providerSecretName,
isProviderOwnedSecretName,
providerCredentialSource,
type ConfigFile,
} from './config.js';

Expand Down Expand Up @@ -65,6 +68,73 @@ function createWorkspace(name: string, data: Record<string, unknown>): string {

// ── isValidVirtualName ────────────────────────────────────────────────────────

describe('providerSecretName / isProviderOwnedSecretName / providerCredentialSource', () => {
it('prefers secret_name over legacy api_key_keychain_name', () => {
expect(providerSecretName({ engine: 'openai', secret_name: 'SHARED' })).toBe('SHARED');
expect(providerSecretName({
engine: 'openai',
secret_name: 'SHARED',
api_key_keychain_name: 'LEGACY',
})).toBe('SHARED');
expect(providerSecretName({
engine: 'openai',
api_key_keychain_name: 'LEGACY',
})).toBe('LEGACY');
expect(providerSecretName({ engine: 'openai' })).toBeUndefined();
});

it('recognizes only invented provider-owned secret names', () => {
expect(isProviderOwnedSecretName('work-openai', 'WORK-OPENAI_API_KEY')).toBe(true);
expect(isProviderOwnedSecretName('work-openai', 'abbenay.work-openai')).toBe(true);
expect(isProviderOwnedSecretName('work-openai', 'SHARED_OPENAI')).toBe(false);
expect(isProviderOwnedSecretName('work-openai', 'OPENAI_API_KEY')).toBe(false);
});

it('resolves store backends with keychain default and memory override', () => {
expect(providerCredentialSource({
engine: 'openai',
secret_name: 'K',
})).toEqual({ kind: 'store', name: 'K', backend: 'keychain' });

expect(providerCredentialSource({
engine: 'openai',
secret_name: 'K',
secret_store: 'memory',
})).toEqual({ kind: 'store', name: 'K', backend: 'memory' });

expect(providerCredentialSource({
engine: 'openai',
secret_name: 'K',
secret_store: 'keychain',
})).toEqual({ kind: 'store', name: 'K', backend: 'keychain' });
});

it('resolves env via secret_store=env or legacy api_key_env_var_name', () => {
expect(providerCredentialSource({
engine: 'openai',
secret_name: 'MY_ENV',
secret_store: 'env',
})).toEqual({ kind: 'env', name: 'MY_ENV' });

expect(providerCredentialSource({
engine: 'openai',
secret_store: 'env',
api_key_env_var_name: 'LEGACY_ENV',
})).toEqual({ kind: 'env', name: 'LEGACY_ENV' });

expect(providerCredentialSource({
engine: 'openai',
api_key_env_var_name: 'ONLY_ENV',
})).toEqual({ kind: 'env', name: 'ONLY_ENV' });

expect(providerCredentialSource({ engine: 'openai' })).toBeNull();
expect(providerCredentialSource({
engine: 'openai',
secret_store: 'env',
})).toBeNull();
});
});

describe('isValidVirtualName', () => {
it('should accept simple lowercase names', () => {
expect(isValidVirtualName('openrouter')).toBe(true);
Expand Down Expand Up @@ -159,6 +229,39 @@ describe('loadConfigFromPath (new schema)', () => {
});
expect(loadConfigFromPath(filePath)).toEqual({ providers: {} });
});

it('mirrors secret_name / env aliases and defaults secret_store', () => {
const filePath = writeYaml(path.join(tmpDir, 'secrets.yaml'), {
providers: {
named: {
engine: 'openai',
secret_name: 'SHARED_KEY',
models: {},
},
envlegacy: {
engine: 'openai',
api_key_env_var_name: 'ONLY_ENV',
models: {},
},
envin: {
engine: 'openai',
secret_name: 'MY_ENV',
secret_store: 'env',
models: {},
},
},
});
const config = loadConfigFromPath(filePath)!;
expect(config.providers!.named.secret_name).toBe('SHARED_KEY');
expect(config.providers!.named.api_key_keychain_name).toBe('SHARED_KEY');
expect(config.providers!.named.secret_store).toBe('keychain');

expect(config.providers!.envlegacy.secret_name).toBe('ONLY_ENV');
expect(config.providers!.envlegacy.secret_store).toBe('env');

expect(config.providers!.envin.api_key_env_var_name).toBe('MY_ENV');
expect(config.providers!.envin.secret_store).toBe('env');
});
});

// ── loadConfigFromPath (old schema migration) ────────────────────────────────
Expand Down
Loading
Loading