diff --git a/.changeset/2046-config-set-null-unset.md b/.changeset/2046-config-set-null-unset.md new file mode 100644 index 000000000..1272e73da --- /dev/null +++ b/.changeset/2046-config-set-null-unset.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2058 +--- +**`gsd-tools config-set null` now clears (removes) the key instead of persisting the literal string `"null"`.** The documented "Clear" action previously fell through the value parser and stored `"null"` — a truthy value — so "cleared" keys stayed set and `config-get` returned `"null"`; for secret keys (`brave_search`/`firecrawl`/`exa_search`) a masked success line hid a truthy value on disk that integrations could pass along as a real credential. `config-set null` now deletes the key (short-circuiting the typed per-key validators so clearing an enum/boolean/number key removes it rather than being rejected), making the "Clear" flows in `settings-integrations.md` / `settings-advanced.md` actually clear. diff --git a/src/config.cts b/src/config.cts index ed42da9dd..0e54cabaa 100644 --- a/src/config.cts +++ b/src/config.cts @@ -38,6 +38,14 @@ interface SetConfigValueResult { previousValue: unknown; } +interface UnsetConfigValueResult { + updated: boolean; + unset: true; + key: string; + value: null; + previousValue: unknown; +} + interface WorkstreamContext { configPath?: string; [key: string]: unknown; @@ -452,6 +460,87 @@ function _setNestedValue( return previousValue; } +/** + * Deletes a value from the config object, allowing nested values via dot + * notation (e.g., "review.models.gemini"). Mirrors `_setNestedValue`'s + * prototype-pollution guard on every path segment (including intermediates). + * + * Unlike `_setNestedValue`, this NEVER creates missing intermediate objects — + * if any segment along the path is missing (or not a plain, non-array + * object), the key doesn't exist and we return early without mutating + * `config` at all. + * + * Does not prune now-empty parent objects after deletion (matches the + * conservative, structure-preserving behaviour callers expect from a bare + * unset). + * + * Returns { previousValue, existed } — existed is false when the leaf key + * (or an intermediate segment) was never present. + * Calls error() (process.exit(1)) on prototype-pollution attempts. + */ +function _unsetNestedValue( + config: Record, + keyPath: string, +): { previousValue: unknown; existed: boolean } { + const keys = keyPath.split('.'); + let current: Record = config; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (key === '__proto__' || key === 'prototype' || key === 'constructor') { + error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED); + } + const existingChild = current[key]; + if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) { + // Path doesn't exist — nothing to unset, and we must not create it. + return { previousValue: undefined, existed: false }; + } + current = existingChild as Record; + } + const lastKey = keys[keys.length - 1]; + if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') { + error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED); + } + const existed = Object.prototype.hasOwnProperty.call(current, lastKey); + const previousValue = current[lastKey]; + if (existed) { + delete current[lastKey]; + } + return { previousValue, existed }; +} + +/** + * Deletes a key from the config file, allowing nested values via dot + * notation. Mirrors `setConfigValue`'s load/lock/write cycle. + * + * Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in + * the happy path. But note that `error()` will still `exit(1)` out of the process. + */ +function unsetConfigValue(cwd: string, keyPath: string): UnsetConfigValueResult { + const configPath = path.join(planningDir(cwd), 'config.json'); + + return withPlanningLock(cwd, () => { + // Load existing config or start with empty object + let config: Record = {}; + try { + if (fs.existsSync(configPath)) { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record; + } + } catch (err) { + error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED); + } + + const { previousValue, existed } = _unsetNestedValue(config, keyPath); + + // Write back + try { + platformWriteSync(configPath, JSON.stringify(config, null, 2)); + return { updated: existed, unset: true, key: keyPath, value: null, previousValue }; + } catch (err) { + error('Failed to write config.json: ' + (err as Error).message); + } + }) as UnsetConfigValueResult; +} + /** * Sets a value in the config file, allowing nested values via dot notation (e.g., * "workflow.research"). @@ -586,6 +675,7 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | let parsedValue: unknown = val; if (val === 'true') parsedValue = true; else if (val === 'false') parsedValue = false; + else if (val === 'null') parsedValue = null; // #1581: Number.isFinite (not !isNaN) so 'Infinity'/'-Infinity' are NOT // coerced to non-finite numbers that JSON.stringify later renders as `null` // (disk=null while the CLI echoed 'Infinity'). They fall through to the @@ -596,6 +686,25 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | try { parsedValue = JSON.parse(val); } catch { /* keep as string */ } } + // #2046: a bare `null` unsets (deletes) the key — the documented "Clear" action. + // Short-circuits before every typed per-key validator so clearing a typed key + // (enum/boolean/number) removes it rather than being rejected. Deleting (not + // persisting JSON null) is the correct "clear": a persisted null is still a + // present, truthy-adjacent value that consumers must special-case — worst for + // secret keys where a leftover value can be passed as a real credential. + if (parsedValue === null) { + const unsetResult = unsetConfigValue(cwd, kp); + if (isSecretKey(kp)) { + const maskedPrev = unsetResult.previousValue === undefined + ? undefined + : maskSecret(unsetResult.previousValue as Parameters[0]); + output({ ...unsetResult, value: null, previousValue: maskedPrev, masked: true }, raw, `${kp} unset`); + return; + } + output(unsetResult, raw, `${kp} unset`); + return; + } + // #1581: project_code is an identifier string — never number-coerce it. A // leading-zero code like '007' must persist verbatim (not collapse to 7). if (kp === 'project_code') { diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 13a25ecf0..96d67e10e 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -747,6 +747,131 @@ describe('config-set — no silent coercion (#1581)', () => { }); }); +// ─── config-set null — unset/clear (#2046) ───────────────────────────── + +describe('config-set null — unset/clear (#2046)', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('non-secret routing key: config-set null removes the key (not the string "null")', () => { + const setResult = runGsdTools('config-set review.models.gemini foo', tmpDir); + assert.ok(setResult.success, `Command failed: ${setResult.error}`); + assert.strictEqual(readConfig(tmpDir).review.models.gemini, 'foo'); + + const unsetResult = runGsdTools('config-set review.models.gemini null', tmpDir); + assert.ok(unsetResult.success, `Command failed: ${unsetResult.error}`); + + const config = readConfig(tmpDir); + assert.ok( + !config.review || !config.review.models || !Object.prototype.hasOwnProperty.call(config.review.models, 'gemini'), + 'review.models.gemini must be absent on disk after unset, not the string "null"' + ); + + const getResult = runGsdTools('config-get review.models.gemini', tmpDir); + assert.ok( + !getResult.output || !getResult.output.trim() || getResult.output.trim() === 'undefined', + `config-get should return empty/undefined after unset, got: ${getResult.output}` + ); + assert.notStrictEqual(getResult.output && getResult.output.trim(), 'null'); + }); + + test('secret key: config-set brave_search null removes the key (never persists "null")', () => { + const setResult = runGsdTools('config-set brave_search sk-test-1234', tmpDir); + assert.ok(setResult.success, `Command failed: ${setResult.error}`); + assert.strictEqual(readConfig(tmpDir).brave_search, 'sk-test-1234'); + + const unsetResult = runGsdTools('config-set brave_search null', tmpDir); + assert.ok(unsetResult.success, `Command failed: ${unsetResult.error}`); + + const configPath = path.join(tmpDir, '.planning', 'config.json'); + const rawText = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(rawText); + assert.ok( + !Object.prototype.hasOwnProperty.call(config, 'brave_search'), + 'brave_search must be absent on disk after unset' + ); + assert.doesNotMatch(rawText, /"brave_search":\s*"null"/, + 'raw config.json must never contain brave_search mapped to the literal string "null"'); + }); + + test('typed-key bypass: config-set context null clears an enum-typed key without validator rejection', () => { + const setResult = runGsdTools('config-set context dev', tmpDir); + assert.ok(setResult.success, `Command failed: ${setResult.error}`); + assert.strictEqual(readConfig(tmpDir).context, 'dev'); + + const unsetResult = runGsdTools('config-set context null', tmpDir); + assert.ok(unsetResult.success, `config-set context null must succeed (bypass enum validator), got error: ${unsetResult.error}`); + + const config = readConfig(tmpDir); + assert.ok(!Object.prototype.hasOwnProperty.call(config, 'context'), + 'context key must be removed after unset'); + }); + + test('idempotent: config-set null succeeds with no crash and no key written', () => { + const result = runGsdTools('config-set git.base_branch null', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.ok( + !config.git || !Object.prototype.hasOwnProperty.call(config.git, 'base_branch'), + 'git.base_branch must not be present after unsetting a key that was never set' + ); + }); + + test('literal-"null" guard: no config-set null ever persists the literal string "null" on disk', () => { + runGsdTools('config-set review.models.gemini foo', tmpDir); + runGsdTools('config-set review.models.gemini null', tmpDir); + runGsdTools('config-set brave_search sk-test-1234', tmpDir); + runGsdTools('config-set brave_search null', tmpDir); + runGsdTools('config-set context dev', tmpDir); + runGsdTools('config-set context null', tmpDir); + + const configPath = path.join(tmpDir, '.planning', 'config.json'); + const rawText = fs.readFileSync(configPath, 'utf-8'); + assert.doesNotMatch(rawText, /:\s*"null"/, + `config.json must never contain a key mapped to the literal string "null": ${rawText}`); + }); + + test('prototype-pollution guard: unsetting a __proto__ leaf is rejected, no pollution (alert #26 parity)', () => { + // Create the intermediate so the unset walk reaches the leaf guard (a bare + // dynamic-prefix key passes the schema gate, so this exercises the + // _unsetNestedValue guard, not the schema gate — mirrors the set-path test). + const seed = runGsdTools('config-set agent_skills.sonnet-coder true', tmpDir); + assert.ok(seed.success, `seed failed: ${seed.error}`); + + const result = runGsdTools('config-set agent_skills.__proto__ null', tmpDir); + assert.strictEqual(result.success, false, `Expected rejection, got: ${result.output}`); + assert.ok( + result.error.includes('prototype pollution guard'), + `Expected pollution-guard error, got: ${result.error}`, + ); + // No prototype pollution occurred via the unset path. + assert.strictEqual(({}).polluted, undefined); + assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'polluted'), false); + }); + + test('deep (4-segment) nested key: config-set null removes only the leaf', () => { + const set = runGsdTools('config-set review.reviewer_instances.myinst.model some-model', tmpDir); + assert.ok(set.success, `deep set failed: ${set.error}`); + assert.strictEqual(readConfig(tmpDir).review.reviewer_instances.myinst.model, 'some-model'); + + const unset = runGsdTools('config-set review.reviewer_instances.myinst.model null', tmpDir); + assert.ok(unset.success, `deep unset failed: ${unset.error}`); + + const config = readConfig(tmpDir); + assert.ok( + !config.review.reviewer_instances.myinst || + !Object.prototype.hasOwnProperty.call(config.review.reviewer_instances.myinst, 'model'), + 'the leaf model key must be removed', + ); + // Parent objects along the path are preserved (unset removes only the leaf). + assert.ok(config.review && config.review.reviewer_instances, + 'parent objects on the path must remain after leaf unset'); + }); +}); + // ─── config-set (research_before_questions and discuss_mode) ────────────────── describe('config-set research_before_questions and discuss_mode', () => { diff --git a/tests/review-model-config.test.cjs b/tests/review-model-config.test.cjs index 3777108e7..4ae6c0d3b 100644 --- a/tests/review-model-config.test.cjs +++ b/tests/review-model-config.test.cjs @@ -4,11 +4,14 @@ * Verifies the review.models. dynamic config key pattern: * - isValidConfigKey accepts review.models. * - validateKnownConfigKeyPath suggests review.models. for review.model - * - End-to-end round-trip via config-set / config-get for both model IDs and null + * - End-to-end round-trip via config-set / config-get for model IDs and the + * null "Clear" action (#2046 — config-set 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. config key', () => { @@ -110,28 +113,49 @@ describe('review.models. config key', () => { ); }); - test('round-trip: config-set null then config-get returns "null"', () => { - // The issue spec documents null as the "fall back to CLI default" sentinel. - // cmdConfigSet does not parse 'null' as JSON null — it stores the literal - // string 'null'. config-get --raw returns the string 'null', and the - // workflow's `[ "$VAR" != "null" ]` guard handles this. + test('round-trip: config-set null UNSETS the model key (#2046 — the "Clear" action)', () => { + // #2046: `config-set 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(setResult.success, `config-set null failed: ${setResult.error}`); + 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 failed: ${getResult.error}`); - assert.strictEqual( - getResult.output, - 'null', - 'config-get should return the literal string "null" so the workflow guard can match it' - ); + 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'); }); });