From b62589b73f77512c15b8192111251accee975907 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 1 Aug 2026 16:33:43 -0400 Subject: [PATCH] fix(#2840): exclude runtime from defaults.json spread into project config (#2985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#2840): add regression for runtime poisoning from defaults.json * fix(#2840): exclude runtime from defaults.json spread into project config runtime is host-specific (written by whichever installer ran last). On a machine with 2+ runtimes, it poisons every new project config — e.g. a Codex install's runtime:'codex' leaks into Claude Code projects. Now excluded from the userDefaults spread, mirroring the resolve_model_ids guard (#2297). * chore(#2840): add changeset fragment * fix(#2840): add new test file to lint-test-file-count allowlist * chore(#2840): backfill changeset PR number 2985 --------- Co-authored-by: sim --- .changeset/quick-sloths-tumble.md | 5 ++ scripts/lint-test-file-count.allowlist.json | 1 + src/config.cts | 12 ++- ...config-defaults-runtime-exclusion.test.cjs | 75 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 .changeset/quick-sloths-tumble.md create mode 100644 tests/config-defaults-runtime-exclusion.test.cjs diff --git a/.changeset/quick-sloths-tumble.md b/.changeset/quick-sloths-tumble.md new file mode 100644 index 000000000..a58aef3c2 --- /dev/null +++ b/.changeset/quick-sloths-tumble.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2985 +--- +**Project configs no longer inherit `runtime` from the machine-wide `~/.gsd/defaults.json`** — on machines with 2+ runtimes installed (e.g. Codex + Claude Code), the last installer's `runtime` value poisoned every new project, resolving agents to wrong model IDs. The key is now excluded from the defaults spread. (#2840) diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 81bbba155..da964e598 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -3,6 +3,7 @@ "modules": { "config": { "files": [ + "config-defaults-runtime-exclusion.test.cjs", "config-field-docs.test.cjs", "config-get-default.test.cjs", "config-schema.property.test.cjs", diff --git a/src/config.cts b/src/config.cts index 826873ac7..bcd259d3a 100644 --- a/src/config.cts +++ b/src/config.cts @@ -329,10 +329,20 @@ function buildNewProjectConfig(userChoices: Record): Record>; const hd = hardcoded as Record>; + // #2840: `runtime` is host-specific (written by the installer for whichever + // runtime's install ran last). On a machine with 2+ runtimes, it poisons every + // new project config — e.g. a Codex install's `runtime:"codex"` leaks into + // Claude Code projects, resolving agents to wrong model IDs. `resolve_model_ids` + // already has a per-install guard (#2297); `runtime` gets the same treatment + // by excluding it from the defaults spread. Projects detect the runtime from + // the install path / .gsd-runtime marker, not from a copied config key. + const safeDefaults = { ...userDefaults }; + delete safeDefaults['runtime']; + // Three-level deep merge: hardcoded <- userDefaults <- choices const config: Record = { ...hardcoded, - ...userDefaults, + ...safeDefaults, ...choices, git: { ...hd['git'], diff --git a/tests/config-defaults-runtime-exclusion.test.cjs b/tests/config-defaults-runtime-exclusion.test.cjs new file mode 100644 index 000000000..22f815546 --- /dev/null +++ b/tests/config-defaults-runtime-exclusion.test.cjs @@ -0,0 +1,75 @@ +// allow-test-rule: source-text-is-the-product (see #2840) +// ~/.gsd/defaults.json is machine-wide. The `runtime` key is host-specific +// (written by whichever installer ran last). It must NOT be copied into project +// configs — on a machine with 2+ runtimes, it poisons every new project. + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs'); + +describe('#2840 — runtime from defaults.json must not poison project config', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('cfg-runtime-'); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('runtime key in defaults.json is NOT copied into the new project config', () => { + // Create ~/.gsd/defaults.json with a runtime key + a legitimate key + fs.mkdirSync(path.join(tmpDir, '.gsd'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.gsd', 'defaults.json'), + JSON.stringify({ runtime: 'codex', model_profile: 'opus' }), + ); + + const result = runGsdTools('config-ensure-section', tmpDir, { + HOME: tmpDir, + USERPROFILE: tmpDir, + }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf8'), + ); + // runtime must NOT be in the project config — it's host-specific. + assert.ok( + !('runtime' in config), + `runtime must not be copied from defaults.json into the project config; got: ${JSON.stringify({ runtime: config.runtime })}`, + ); + // Other defaults must still be copied. + assert.strictEqual( + config.model_profile, + 'opus', + 'legitimate defaults (model_profile) must still be copied from defaults.json', + ); + }); + + test('defaults.json without a runtime key works normally', () => { + fs.mkdirSync(path.join(tmpDir, '.gsd'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.gsd', 'defaults.json'), + JSON.stringify({ model_profile: 'haiku' }), + ); + + const result = runGsdTools('config-ensure-section', tmpDir, { + HOME: tmpDir, + USERPROFILE: tmpDir, + }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf8'), + ); + assert.strictEqual(config.model_profile, 'haiku'); + assert.ok(!('runtime' in config), 'runtime should not appear when defaults.json lacks it'); + }); +});