`gsd-tools config-set <key> null` — the "Clear" action documented in
settings-integrations.md / settings-advanced.md — previously fell through the
value parser as the literal STRING "null" and persisted it. Consequences:
"cleared" keys stayed set (config-get returned truthy "null"), and for secret
keys (brave_search/firecrawl/exa_search) a masked success line hid a truthy
4-char value on disk that integrations could pass along as a real credential.
There was also no unset/delete verb at all.
Fix: parse a bare `null` to JS null and short-circuit to a real UNSET that
DELETES the key from config.json — the semantic the docs already describe
("Remove the stored key" / "remove the key by setting it to null"). Deleting
(not persisting JSON null) is the correct clear: a persisted null is still a
present value consumers must special-case.
- src/config.cts:
- parse block: `else if (val === 'null') parsedValue = null;`
- cmdConfigSet: when parsedValue === null, short-circuit BEFORE the typed
per-key validator gauntlet (so clearing an enum/boolean/number key removes
it rather than being rejected) and before the project_code special-case;
mask the previous value for secret keys in the output.
- new `unsetConfigValue()` + `_unsetNestedValue()` mirroring setConfigValue/
_setNestedValue: same prototype-pollution guard, but never creates missing
intermediates and never prunes empty parents; returns { previousValue,
existed }. Unsetting a never-set key is an idempotent no-op success.
- tests/config.test.cjs: new suite covering non-secret routing key, secret key,
typed-enum-key bypass (context), idempotent unset, literal-"null"-on-disk
guard, the unset-path prototype-pollution guard (alert #26 parity), and a
4-segment deep-nested unset.
- tests/review-model-config.test.cjs: update the stale round-trip test that
codified the bug (asserted config-set null → config-get returns "null") to the
fixed contract — the model key is removed; the review workflow's
`[ -n "$VAR" ] && [ "$VAR" != "null" ]` guard handles the empty read as
"no override → reviewer default", same as the old "null" sentinel.
The 4 documented "Clear" flows (settings-integrations.md, settings-advanced.md)
were verified — their prose already describes removal, so the fix makes them
accurate rather than aspirational; no doc wording change required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
6.3 KiB
JavaScript
162 lines
6.3 KiB
JavaScript
/**
|
|
* Review Model Config Tests (#1849)
|
|
*
|
|
* Verifies the review.models.<cli> dynamic config key pattern:
|
|
* - isValidConfigKey accepts review.models.<cli-name>
|
|
* - validateKnownConfigKeyPath suggests review.models.<cli-name> for review.model
|
|
* - End-to-end round-trip via config-set / config-get for model IDs and the
|
|
* null "Clear" action (#2046 — config-set <key> null unsets the key)
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
describe('review.models.<cli> config key', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
// Ensure config exists for set/get
|
|
runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('isValidConfigKey accepts review.models.gemini', () => {
|
|
// Exercised via config-set, which calls isValidConfigKey internally and
|
|
// errors out if the key is not valid.
|
|
const result = runGsdTools(
|
|
['config-set', 'review.models.gemini', 'gemini-3.1-pro-preview'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(result.success, `config-set should succeed for review.models.gemini: ${result.error}`);
|
|
});
|
|
|
|
test('isValidConfigKey accepts review.models.codex', () => {
|
|
const result = runGsdTools(
|
|
['config-set', 'review.models.codex', 'gpt-5-codex'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(result.success, `config-set should succeed for review.models.codex: ${result.error}`);
|
|
});
|
|
|
|
test('isValidConfigKey accepts review.models.claude (#2688)', () => {
|
|
const result = runGsdTools(
|
|
['config-set', 'review.models.claude', 'claude-opus-4-6'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(result.success, `config-set should succeed for review.models.claude: ${result.error}`);
|
|
});
|
|
|
|
test('round-trip: review.models.claude config-set then config-get (#2688)', () => {
|
|
const setResult = runGsdTools(
|
|
['config-set', 'review.models.claude', 'claude-opus-4-6'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
|
|
|
|
const getResult = runGsdTools(
|
|
['config-get', 'review.models.claude', '--raw'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(getResult.success, `config-get failed: ${getResult.error}`);
|
|
assert.strictEqual(
|
|
getResult.output,
|
|
'claude-opus-4-6',
|
|
'config-get should return the model ID set via config-set'
|
|
);
|
|
});
|
|
|
|
test('review.model is rejected and suggests review.models.<cli-name>', () => {
|
|
// The suggestion path goes through validateKnownConfigKeyPath, which is
|
|
// called before isValidConfigKey in cmdConfigSet.
|
|
const result = runGsdTools(
|
|
['config-set', 'review.model', 'gemini-3.1-pro-preview'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(!result.success, 'config-set should fail for review.model');
|
|
assert.ok(
|
|
result.error.includes('review.models.<cli-name>'),
|
|
`error should suggest review.models.<cli-name>, got: ${result.error}`
|
|
);
|
|
});
|
|
|
|
test('round-trip: config-set then config-get for a model ID', () => {
|
|
const setResult = runGsdTools(
|
|
['config-set', 'review.models.gemini', 'gemini-3.1-pro-preview'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
|
|
|
|
const getResult = runGsdTools(
|
|
['config-get', 'review.models.gemini', '--raw'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(getResult.success, `config-get failed: ${getResult.error}`);
|
|
assert.strictEqual(
|
|
getResult.output,
|
|
'gemini-3.1-pro-preview',
|
|
'config-get should return the value set via config-set'
|
|
);
|
|
});
|
|
|
|
test('round-trip: config-set null UNSETS the model key (#2046 — the "Clear" action)', () => {
|
|
// #2046: `config-set <key> null` now DELETES the key (the documented "Clear"
|
|
// action) instead of persisting the literal string "null". A previously-set
|
|
// model override is removed cleanly; config-get then reports key-not-found.
|
|
// The review workflow's guard (`[ -n "$VAR" ] && [ "$VAR" != "null" ]`,
|
|
// review.md:259) treats the resulting empty read as "no override → use the
|
|
// reviewer's default", exactly as it treated the old "null" sentinel.
|
|
const setResult = runGsdTools(
|
|
['config-set', 'review.models.gemini', 'gemini-3.1-pro-preview'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
|
|
|
|
const clearResult = runGsdTools(
|
|
['config-set', 'review.models.gemini', 'null'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(clearResult.success, `config-set null failed: ${clearResult.error}`);
|
|
|
|
// The key is gone from disk — not persisted as the string "null".
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
const rawText = fs.readFileSync(configPath, 'utf-8');
|
|
const config = JSON.parse(rawText);
|
|
assert.ok(
|
|
!config.review || !config.review.models ||
|
|
!Object.prototype.hasOwnProperty.call(config.review.models, 'gemini'),
|
|
`review.models.gemini must be absent after clear, got: ${rawText}`
|
|
);
|
|
assert.doesNotMatch(rawText, /"gemini":\s*"null"/,
|
|
'must never persist review.models.gemini as the literal string "null"');
|
|
|
|
// config-get on the removed key reports not-found (the review workflow reads
|
|
// it as `... 2>/dev/null || echo ""` → empty → the `[ -n "$VAR" ]` guard falls
|
|
// back to the reviewer default).
|
|
const getResult = runGsdTools(
|
|
['config-get', 'review.models.gemini', '--raw'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(!getResult.success, 'config-get on a cleared key should report not-found');
|
|
assert.notStrictEqual(getResult.output && getResult.output.trim(), 'null',
|
|
'config-get must not emit the literal string "null" for a cleared key');
|
|
});
|
|
});
|