Skip to content

Commit 6f2cfeb

Browse files
authored
fix(config): reject non-string apiKeyCommand with ConfigError (#991)
* fix(config): reject non-string apiKeyCommand with ConfigError An array-form apiKeyCommand (the more ergonomic shape users often reach for) silently fell through resolveSecrets' typeof check and left apiKey null with no explanation. Throw a ConfigError with the expected format and example, let it propagate past loadConfig's JSON-parse try/catch, and document the single-string contract on the type. Impact: 3 functions changed, 106 affected * docs(config): clarify apiKeyCommand runs without a shell (#991) Impact: 1 functions changed, 0 affected
1 parent f34bcc4 commit 6f2cfeb

3 files changed

Lines changed: 35 additions & 6 deletions

File tree

src/infrastructure/config.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execFileSync } from 'node:child_process';
22
import fs from 'node:fs';
33
import path from 'node:path';
4-
import { toErrorMessage } from '../shared/errors.js';
4+
import { ConfigError, toErrorMessage } from '../shared/errors.js';
55
import type { CodegraphConfig } from '../types.js';
66
import { debug, warn } from './logger.js';
77

@@ -179,6 +179,7 @@ export function loadConfig(cwd?: string): CodegraphConfig {
179179
_configCache.set(cwd, structuredClone(result));
180180
return result;
181181
} catch (err: unknown) {
182+
if (err instanceof ConfigError) throw err;
182183
debug(`Failed to parse config ${filePath}: ${toErrorMessage(err)}`);
183184
}
184185
}
@@ -215,7 +216,16 @@ export function applyEnvOverrides(config: CodegraphConfig): CodegraphConfig {
215216

216217
export function resolveSecrets(config: CodegraphConfig): CodegraphConfig {
217218
const cmd = config.llm.apiKeyCommand;
218-
if (typeof cmd !== 'string' || cmd.trim() === '') return config;
219+
if (cmd == null) return config;
220+
if (typeof cmd !== 'string') {
221+
const actual = Array.isArray(cmd) ? 'array' : typeof cmd;
222+
throw new ConfigError(
223+
`llm.apiKeyCommand must be a string (received ${actual}). ` +
224+
'The command is split on whitespace and executed without a shell. ' +
225+
'Example: "apiKeyCommand": "op read op://vault/openai/api-key"',
226+
);
227+
}
228+
if (cmd.trim() === '') return config;
219229

220230
const parts = cmd.trim().split(/\s+/);
221231
const [executable, ...args] = parts;

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,13 @@ export interface CodegraphConfig {
11041104
model: string | null;
11051105
baseUrl: string | null;
11061106
apiKey: string | null;
1107+
/**
1108+
* Command that prints the API key to stdout. Must be a single string —
1109+
* it is split on whitespace and executed via `execFileSync` with no shell,
1110+
* so shell features like `$(...)`, pipes, globs, or variable expansion are
1111+
* not supported. Example: `"op read op://vault/openai/api-key"`. Non-string
1112+
* values are rejected with a `ConfigError` at load time.
1113+
*/
11071114
apiKeyCommand: string | null;
11081115
};
11091116

tests/unit/config.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
mergeConfig,
1616
resolveSecrets,
1717
} from '../../src/infrastructure/config.js';
18+
import { ConfigError } from '../../src/shared/errors.js';
1819

1920
vi.mock('node:child_process', async (importOriginal) => {
2021
const actual = await importOriginal();
@@ -430,13 +431,24 @@ describe('resolveSecrets', () => {
430431
expect(config.llm.apiKey).toBe('existing');
431432
});
432433

433-
it('skips when apiKeyCommand is not a string', () => {
434-
const config = {
434+
it('throws ConfigError when apiKeyCommand is not a string', () => {
435+
const numberCfg = {
435436
llm: { provider: null, model: null, baseUrl: null, apiKey: 'existing', apiKeyCommand: 42 },
436437
};
437-
resolveSecrets(config);
438+
expect(() => resolveSecrets(numberCfg as never)).toThrow(ConfigError);
439+
expect(() => resolveSecrets(numberCfg as never)).toThrow(/must be a string/);
440+
441+
const arrayCfg = {
442+
llm: {
443+
provider: null,
444+
model: null,
445+
baseUrl: null,
446+
apiKey: 'existing',
447+
apiKeyCommand: ['op', 'read', 'op://vault/key'],
448+
},
449+
};
450+
expect(() => resolveSecrets(arrayCfg as never)).toThrow(/received array/);
438451
expect(mockExecFile).not.toHaveBeenCalled();
439-
expect(config.llm.apiKey).toBe('existing');
440452
});
441453

442454
it('warns and preserves existing apiKey on command failure', () => {

0 commit comments

Comments
 (0)