* fix(#2997): mask SECRET_CONFIG_KEYS in SDK config-set/get and init responses The CJS→TS port at sdk/src/query/config-mutation.ts:240,243 and config-query.ts:122,128,132 dropped the masking layer that secrets.cjs spec defines for brave_search/firecrawl/exa_search. Result: the SDK echoed plaintext API keys into machine-readable JSON output (stdout, transcripts, CI logs). Adjacent leak in init.ts:673-675 / init.cjs:728-730: the init bundle passed config.brave_search through raw, leaking the API key whenever the user had stored one. Fix: - New sdk/src/query/secrets.ts ports SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret. Exact CJS parity (verified by 17 tests in secrets.test.ts that import secrets.cjs and compare). - config-set masks value + previousValue in response; on-disk plaintext intact (key stays usable). - config-get masks read response. --default flows through unmasked (user's own input, not stored secret). - init.ts/init.cjs mask string values only; booleans (availability flags) pass through unchanged so the typed contract is preserved. Tests: 17 in secrets.test.ts (including CJS parity), 5 in config-mutation.test.ts (#2997 block — covers on-disk-preserved, previousValue masking, short-value, unset, non-secret pass-through), 4 in config-query.test.ts. Closes #2997 * chore(#2997): add changeset fragment for PR #2999 * chore(#2997): add changeset fragment for PR #2999 * chore(#2999): drop direct CHANGELOG.md edit; release entry now lives in .changeset/ The changeset-fragment workflow (#2975) renders fragments into CHANGELOG.md at release time. Direct edits to [Unreleased] on each PR caused merge conflicts on every concurrent PR. This commit restores CHANGELOG.md to match origin/main; the release entry for this fix is preserved in the .changeset/*.md fragment(s) on this branch, which the release workflow consolidates.
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for config-get and resolve-model query handlers.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { GSDError, ErrorClassification, exitCodeFor } from '../errors.js';
|
|
|
|
// ─── Test setup ─────────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-cfg-'));
|
|
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─── configGet ──────────────────────────────────────────────────────────────
|
|
|
|
describe('configGet', () => {
|
|
it('returns raw config value for top-level key', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'quality' }),
|
|
);
|
|
const result = await configGet(['model_profile'], tmpDir);
|
|
expect(result.data).toBe('quality');
|
|
});
|
|
|
|
it('traverses dot-notation for nested keys', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ workflow: { auto_advance: true } }),
|
|
);
|
|
const result = await configGet(['workflow.auto_advance'], tmpDir);
|
|
expect(result.data).toBe(true);
|
|
});
|
|
|
|
it('throws GSDError when no key provided', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
await expect(configGet([], tmpDir)).rejects.toThrow(GSDError);
|
|
});
|
|
|
|
it('throws GSDError for nonexistent key', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'quality' }),
|
|
);
|
|
await expect(configGet(['nonexistent.key'], tmpDir)).rejects.toThrow(GSDError);
|
|
});
|
|
|
|
it('throws GSDError that maps to exit code 1 for missing key (bug #2544)', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'quality' }),
|
|
);
|
|
try {
|
|
await configGet(['nonexistent.key'], tmpDir);
|
|
throw new Error('expected configGet to throw for missing key');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(GSDError);
|
|
const gsdErr = err as GSDError;
|
|
// UNIX convention: missing config key should exit 1 (like `git config --get`).
|
|
// Validation (exit 10) is the previous buggy classification — see issue #2544.
|
|
expect(gsdErr.classification).toBe(ErrorClassification.Execution);
|
|
expect(exitCodeFor(gsdErr.classification)).toBe(1);
|
|
}
|
|
});
|
|
|
|
it('throws GSDError that maps to exit code 1 when traversing into non-object (bug #2544)', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'quality' }),
|
|
);
|
|
try {
|
|
await configGet(['model_profile.subkey'], tmpDir);
|
|
throw new Error('expected configGet to throw');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(GSDError);
|
|
const gsdErr = err as GSDError;
|
|
expect(exitCodeFor(gsdErr.classification)).toBe(1);
|
|
}
|
|
});
|
|
|
|
it('reads raw config without merging defaults', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
// Write config with only model_profile -- no workflow section
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'balanced' }),
|
|
);
|
|
// Accessing workflow should fail (not merged with defaults)
|
|
await expect(configGet(['workflow.auto_advance'], tmpDir)).rejects.toThrow(GSDError);
|
|
});
|
|
});
|
|
|
|
// ─── resolveModel ───────────────────────────────────────────────────────────
|
|
|
|
describe('resolveModel', () => {
|
|
it('returns model and profile for known agent', async () => {
|
|
const { resolveModel } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'balanced' }),
|
|
);
|
|
const result = await resolveModel(['gsd-planner'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data).toHaveProperty('model');
|
|
expect(data).toHaveProperty('profile', 'balanced');
|
|
expect(data).not.toHaveProperty('unknown_agent');
|
|
});
|
|
|
|
it('returns unknown_agent flag for unknown agent', async () => {
|
|
const { resolveModel } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'balanced' }),
|
|
);
|
|
const result = await resolveModel(['unknown-agent'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data).toHaveProperty('model', 'sonnet');
|
|
expect(data).toHaveProperty('unknown_agent', true);
|
|
});
|
|
|
|
it('throws GSDError when no agent type provided', async () => {
|
|
const { resolveModel } = await import('./config-query.js');
|
|
await expect(resolveModel([], tmpDir)).rejects.toThrow(GSDError);
|
|
});
|
|
|
|
it('respects model_overrides from config', async () => {
|
|
const { resolveModel } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({
|
|
model_profile: 'balanced',
|
|
model_overrides: { 'gsd-planner': 'openai/gpt-5.4' },
|
|
}),
|
|
);
|
|
const result = await resolveModel(['gsd-planner'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data).toHaveProperty('model', 'openai/gpt-5.4');
|
|
});
|
|
|
|
it('returns empty model when resolve_model_ids is omit', async () => {
|
|
const { resolveModel } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({
|
|
model_profile: 'balanced',
|
|
resolve_model_ids: 'omit',
|
|
}),
|
|
);
|
|
const result = await resolveModel(['gsd-planner'], tmpDir);
|
|
const data = result.data as Record<string, unknown>;
|
|
expect(data).toHaveProperty('model', '');
|
|
});
|
|
|
|
it('resolveModel uses workstream config when --ws is specified', async () => {
|
|
const { resolveModel } = await import('./config-query.js');
|
|
// Root config: balanced profile → gsd-executor resolves to 'sonnet'
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'balanced' }),
|
|
);
|
|
// Workstream config: quality profile → gsd-executor resolves to 'opus'
|
|
await mkdir(join(tmpDir, '.planning', 'workstreams', 'frontend'), { recursive: true });
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'workstreams', 'frontend', 'config.json'),
|
|
JSON.stringify({ model_profile: 'quality' }),
|
|
);
|
|
|
|
const rootResult = await resolveModel(['gsd-executor'], tmpDir);
|
|
const rootData = rootResult.data as Record<string, unknown>;
|
|
expect(rootData.profile).toBe('balanced');
|
|
expect(rootData.model).toBe('sonnet');
|
|
|
|
const wsResult = await resolveModel(['gsd-executor'], tmpDir, 'frontend');
|
|
const wsData = wsResult.data as Record<string, unknown>;
|
|
expect(wsData.profile).toBe('quality');
|
|
expect(wsData.model).toBe('opus');
|
|
});
|
|
});
|
|
|
|
// ─── MODEL_PROFILES ─────────────────────────────────────────────────────────
|
|
|
|
describe('MODEL_PROFILES', () => {
|
|
it('contains all 18 agent entries (sync with model-profiles.cjs)', async () => {
|
|
const { MODEL_PROFILES } = await import('./config-query.js');
|
|
expect(Object.keys(MODEL_PROFILES)).toHaveLength(18);
|
|
});
|
|
|
|
it('has quality/balanced/budget/adaptive for each agent', async () => {
|
|
const { MODEL_PROFILES } = await import('./config-query.js');
|
|
for (const agent of Object.keys(MODEL_PROFILES)) {
|
|
expect(MODEL_PROFILES[agent]).toHaveProperty('quality');
|
|
expect(MODEL_PROFILES[agent]).toHaveProperty('balanced');
|
|
expect(MODEL_PROFILES[agent]).toHaveProperty('budget');
|
|
expect(MODEL_PROFILES[agent]).toHaveProperty('adaptive');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── VALID_PROFILES ─────────────────────────────────────────────────────────
|
|
|
|
describe('VALID_PROFILES', () => {
|
|
it('contains the four profile names', async () => {
|
|
const { VALID_PROFILES } = await import('./config-query.js');
|
|
expect(VALID_PROFILES).toEqual(['quality', 'balanced', 'budget', 'adaptive']);
|
|
});
|
|
});
|
|
|
|
// ─── #2997: Secret masking in configGet response ────────────────────────────
|
|
|
|
describe('configGet secret masking (#2997)', () => {
|
|
it('masks the response data for SECRET_CONFIG_KEYS', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
const apiKey = 'BSA-1234567890abcdef';
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ brave_search: apiKey }),
|
|
);
|
|
const result = await configGet(['brave_search'], tmpDir);
|
|
expect(result.data).toBe('****cdef');
|
|
expect(result.data).not.toBe(apiKey);
|
|
});
|
|
|
|
it('does NOT mask non-secret keys', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'quality' }),
|
|
);
|
|
const result = await configGet(['model_profile'], tmpDir);
|
|
expect(result.data).toBe('quality');
|
|
});
|
|
|
|
it('renders short secret values as **** (no tail leak)', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ firecrawl: 'abc' }),
|
|
);
|
|
const result = await configGet(['firecrawl'], tmpDir);
|
|
expect(result.data).toBe('****');
|
|
});
|
|
|
|
it('does not mask the user-supplied --default value (it is the user\'s own input, not a stored secret)', async () => {
|
|
const { configGet } = await import('./config-query.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'balanced' }),
|
|
);
|
|
const result = await configGet(['brave_search', '--default', 'placeholder'], tmpDir);
|
|
// Default flows through unchanged: the user typed it, the SDK echoed it.
|
|
expect(result.data).toBe('placeholder');
|
|
});
|
|
});
|