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
7 changes: 4 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ 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/registry.ts` | `SecretStoreRegistry` (memory + keychain + file namespaces) |
| `daemon/secrets/keychain.ts` | `KeychainSecretStore` (keytar native addon) |
| `daemon/secrets/file-store.ts` | `FileSecretStore` (`<configDir>/secrets.json`, mode `0600`) |

## Components

Expand Down Expand Up @@ -206,8 +207,8 @@ interface SecretStore {
```

`CoreState` accepts any `SecretStore` via constructor injection. `DaemonState`
uses `SecretStoreRegistry` (discrete process-lifetime `MemorySecretStore` +
keytar-backed `KeychainSecretStore` namespaces; resolve via
uses `SecretStoreRegistry` (discrete `MemorySecretStore`, keytar-backed
`KeychainSecretStore`, and config-dir `FileSecretStore` 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` / …
Expand Down
24 changes: 21 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ Each provider can specify exactly ONE of these (mutually exclusive):
- `secret_store` selects the namespace:
- `keychain` (default when omitted) → OS keychain
- `memory` → process-lifetime daemon memory
- `file` → `<configDir>/secrets.json` (durable with the config volume; mode `0600`)
- `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)
Expand All @@ -409,20 +410,37 @@ providers:
secret_store: env # already exported in the process environment
```

```yaml
providers:
container-openai:
engine: openai
secret_name: "OPENAI_API_KEY"
secret_store: file # persists next to config.yaml on the RW volume
```

### Option 1b: In-memory (process-lifetime) via secrets API

- Same `secret_name` in the `memory` namespace; set value with `SetSecret` /
`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)
- Independent from keychain/file — overlapping names are allowed (DR-047)

### Option 1c: File-backed (config-dir) via secrets API

- Same API with `secretStore: file` / `secret_store: file`
- Written to `<configDir>/secrets.json` (override path with `ABBENAY_SECRETS_FILE`)
- Survives daemon restarts when the config directory is on a persistent volume
(e.g. APME Helm `persistence.abbenay.enabled=true`)
- If `secrets.json` exists but is not valid JSON object map, reads treat it as
empty and **writes are refused** so a later `set` cannot wipe the on-disk file

### Recommended workflow (keys are N:1 with providers)

1. **Add a secret** (store) — `SetSecret` with store `memory|keychain`, HTTP
1. **Add a secret** (store) — `SetSecret` with store `memory|keychain|file`, HTTP
`secretStore`, **or** export an env var yourself.
2. **Configure the provider** — `secret_name=NAME` and
`secret_store=memory|keychain|env` (YAML/config; HTTP configure uses
`secret_store=memory|keychain|file|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: 4 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ defaults (or stronger) in place.
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.
- File-backed secrets (DR-048) persist as plaintext JSON at
`<configDir>/secrets.json` (mode `0600`; override with `ABBENAY_SECRETS_FILE`).
Providers must set `secret_store: file`. Protect the config volume the same
way as `config.yaml`; there is no encryption-at-rest in this revision.

---

Expand Down
22 changes: 22 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -861,3 +861,25 @@ 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.

---

## DR-048: Filesystem secret store for durable non-keychain hosts

**Date:** 2026-08-14
**Decision:** Add a third writable secret backend, `file`
(`SECRET_STORE_FILE` / `secretStore: "file"`), implemented as
`FileSecretStore` persisting a JSON map at `<configDir>/secrets.json`
(mode `0600`; override with `ABBENAY_SECRETS_FILE`). It is always registered
in `SecretStoreRegistry` alongside memory and keychain; clients opt in via
`(secret_store, secret_name)`. No encryption-at-rest in this revision —
protection is filesystem permissions and the same RW volume that already
holds `config.yaml` (e.g. container emptyDir or PVC). Default write backend
remains keychain.
**Rationale:** OS keychain is unavailable or awkward in many container /
sidecar deployments (APME Helm Abbenay), and process-lifetime memory is not
durable across restarts. Portal/Gateway databases are not a secrets vault;
Abbenay remains source of truth for provider credentials. Placing secrets
next to config reuses the existing config volume contract without introducing
a separate Gateway SoT or a cloud KMS dependency. Encryption can layer later
without changing the `(secret_store, secret_name)` address model.
4 changes: 2 additions & 2 deletions packages/daemon/src/core/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ export const ProviderConfigSchema = z
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(),
/** memory | keychain | env | file — where secret_name is resolved. */
secret_store: z.enum(['memory', 'keychain', 'env', 'file']).optional(),
/** @deprecated Use secret_name. */
api_key_keychain_name: z.string().min(1).optional(),
/** @deprecated Use secret_name + secret_store: env. */
Expand Down
11 changes: 7 additions & 4 deletions packages/daemon/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export interface ProviderConfig {
* - `memory` / `keychain` / omitted (with a store name): daemon secret store
* - `env`: process environment variable
*/
secret_store?: 'memory' | 'keychain' | 'env';
secret_store?: 'memory' | 'keychain' | 'env' | 'file';
/**
* @deprecated Use {@link secret_name}. Kept for existing YAML configs.
*/
Expand Down Expand Up @@ -116,13 +116,13 @@ export function isProviderOwnedSecretName(providerId: string, secretName: string

/**
* How to resolve credentials for a provider.
* `store` → named backend (memory|keychain) via (secret_store, secret_name);
* `store` → named backend (memory|keychain|file) via (secret_store, secret_name);
* `env` → process.env[secret_name]. Omitted secret_store defaults to keychain.
*/
export function providerCredentialSource(
cfg: ProviderConfig,
):
| { kind: 'store'; name: string; backend: 'memory' | 'keychain' }
| { kind: 'store'; name: string; backend: 'memory' | 'keychain' | 'file' }
| { kind: 'env'; name: string }
| null {
if (cfg.secret_store === 'env') {
Expand All @@ -131,7 +131,10 @@ export function providerCredentialSource(
}
const storeName = providerSecretName(cfg);
if (storeName) {
const backend = cfg.secret_store === 'memory' ? 'memory' : 'keychain';
const backend =
cfg.secret_store === 'memory' || cfg.secret_store === 'file'
? cfg.secret_store
: 'keychain';
return { kind: 'store', name: storeName, backend };
}
if (cfg.api_key_env_var_name) {
Expand Down
13 changes: 13 additions & 0 deletions packages/daemon/src/core/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* Linux: $XDG_CONFIG_HOME/abbenay → ~/.config/abbenay
* macOS: ~/Library/Application Support/abbenay
* Windows: %APPDATA%/abbenay
* Holds config.yaml and secrets.json (file secret store)
*
* Workspace config dir
* All: <workspace>/.config/abbenay
Expand Down Expand Up @@ -159,6 +160,18 @@ export function getUserConfigPath(): string {
return path.join(getConfigDir(), 'config.yaml');
}

/**
* File-backed secrets path: <configDir>/secrets.json
*
* Override with ``ABBENAY_SECRETS_FILE`` (tests / custom mounts). Lives next to
* ``config.yaml`` so the same RW volume persists providers and secrets.
*/
export function getSecretsPath(): string {
const override = process.env.ABBENAY_SECRETS_FILE?.trim();
if (override) return override;
return path.join(getConfigDir(), 'secrets.json');
}

/** Workspace config file: <workspaceConfigDir>/config.yaml */
export function getWorkspaceConfigPath(workspacePath: string): string {
return path.join(getWorkspaceConfigDir(workspacePath), 'config.yaml');
Expand Down
12 changes: 7 additions & 5 deletions packages/daemon/src/core/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* Secret store interface and in-memory implementation.
*
* Core consumers inject their own SecretStore implementation.
* The daemon uses SecretStoreRegistry (MemorySecretStore + KeychainSecretStore);
* tests/library use MemorySecretStore.
* The daemon uses SecretStoreRegistry (MemorySecretStore + KeychainSecretStore
* + FileSecretStore); tests/library use MemorySecretStore.
*
* Credential aggregation (finding A1): one daemon may hold many provider keys.
* Mutating APIs must stay auth-gated; use {@link auditSecretChange} so operators
Expand Down Expand Up @@ -56,16 +56,18 @@ export interface SecretAuditEvent {
/** Secret key name only — never the value */
key: string;
op: 'set' | 'delete';
/** http-secrets | http-secrets-memory | grpc-secrets | grpc-secrets-memory | http-configure | grpc-configure | core-add */
/** http-secrets | http-secrets-memory | http-secrets-file | grpc-secrets | grpc-secrets-memory | grpc-secrets-file | http-configure | grpc-configure | core-add */
source: string;
actor?: string;
}

/**
* Emit an audit log line for a secret mutation (A1 accountability).
* Never logs the secret value.
* Sources include: http-secrets | http-secrets-memory | grpc-secrets |
* grpc-secrets-memory | http-configure | grpc-configure | core-add
* Sources include: http-secrets | http-secrets-memory | http-secrets-file |
* grpc-secrets | grpc-secrets-memory | grpc-secrets-file |
* http-configure | http-configure-memory | http-configure-file |
* grpc-configure | grpc-configure-memory | grpc-configure-file | core-add
*/
export function auditSecretChange(event: SecretAuditEvent): void {
const safeKey = sanitizeForLog(event.key);
Expand Down
110 changes: 110 additions & 0 deletions packages/daemon/src/daemon/secrets/file-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Unit tests for FileSecretStore (config-dir secrets.json).
*/

import * as fsp from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const fsMocks = vi.hoisted(() => ({
rename: vi.fn(),
actualRename: undefined as typeof import('node:fs/promises').rename | undefined,
}));

vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
fsMocks.actualRename = actual.rename.bind(actual);
fsMocks.rename.mockImplementation(fsMocks.actualRename);
return { ...actual, rename: fsMocks.rename };
});

import { FileSecretStore } from './file-store.js';

describe('FileSecretStore', () => {
let dir: string;
let filePath: string;
let store: FileSecretStore;

beforeEach(async () => {
fsMocks.rename.mockReset();
fsMocks.rename.mockImplementation(fsMocks.actualRename!);
dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'abbenay-secrets-'));
filePath = path.join(dir, 'secrets.json');
store = new FileSecretStore(filePath);
});

afterEach(async () => {
await fsp.rm(dir, { recursive: true, force: true });
});

it('set/get/has/delete round-trip', async () => {
expect(await store.has('K')).toBe(false);
await store.set('K', 'secret-value');
expect(await store.has('K')).toBe(true);
expect(await store.get('K')).toBe('secret-value');
expect(await store.delete('K')).toBe(true);
expect(await store.get('K')).toBeNull();
expect(await store.delete('K')).toBe(false);
});

it('persists across store instances (reload from disk)', async () => {
await store.set('OPENROUTER_API_KEY', 'sk-or-test');
const reloaded = new FileSecretStore(filePath);
expect(await reloaded.get('OPENROUTER_API_KEY')).toBe('sk-or-test');
});

it.skipIf(process.platform === 'win32')('writes mode 0600 on unix', async () => {
await store.set('K', 'v');
const st = await fsp.stat(filePath);
expect(st.mode & 0o777).toBe(0o600);
});

it('starts empty when file is missing', async () => {
expect(await store.get('missing')).toBeNull();
});

it('refuses writes when file is corrupt JSON', async () => {
await fsp.writeFile(filePath, 'not-json{', 'utf8');
const reloaded = new FileSecretStore(filePath);
expect(await reloaded.get('K')).toBeNull();
await expect(reloaded.set('K', 'v')).rejects.toThrow(/unparseable/);
expect(await fsp.readFile(filePath, 'utf8')).toBe('not-json{');
});

it('refuses writes when file is a JSON array', async () => {
await fsp.writeFile(filePath, '[]', 'utf8');
const reloaded = new FileSecretStore(filePath);
expect(await reloaded.get('K')).toBeNull();
await expect(reloaded.set('K', 'v')).rejects.toThrow(/unparseable/);
});

it('exposes absolute path', () => {
expect(store.path).toBe(filePath);
});

it('serializes concurrent writes so all keys survive', async () => {
await Promise.all([
store.set('A', '1'),
store.set('B', '2'),
store.set('C', '3'),
store.delete('missing'),
]);
expect(await store.get('A')).toBe('1');
expect(await store.get('B')).toBe('2');
expect(await store.get('C')).toBe('3');
const reloaded = new FileSecretStore(filePath);
expect(await reloaded.get('A')).toBe('1');
expect(await reloaded.get('B')).toBe('2');
expect(await reloaded.get('C')).toBe('3');
});

it('leaves cache consistent with disk when persist fails', async () => {
await store.set('K', 'ok');
fsMocks.rename.mockRejectedValueOnce(new Error('disk full'));
await expect(store.set('K', 'bad')).rejects.toThrow(/Failed to persist/);
expect(await store.get('K')).toBe('ok');
const reloaded = new FileSecretStore(filePath);
expect(await reloaded.get('K')).toBe('ok');
});
});
Loading
Loading