[GH-ISSUE #4262] [Bug] Empty CLAUDE_CONFIG_DIR makes setup register Claude MCP in the working directory #1686

Closed
opened 2026-10-16 21:09:03 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 15, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/4262

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Setup, packaging, or upgrade

Observed behavior

With CLAUDE_CONFIG_DIR explicitly set to the empty string, approved setup --client claude_code --skill=false --yes --json writes mcpServers.rea into the working directory's .claude.json and reports ready. Scoped doctor then reports that registration aligned, although HOME/.claude.json still lacks REA. Static inspection of the published Claude global-file resolver selects the home file for this ordinary fixture. The REA write and diagnosis were executed; Claude itself was not launched.

Reproduction

From the root of the source checkout identified below, with locked dependencies installed and npm run build:cached completed, set NODE_FOR_REPRO to the absolute pinned Node executable (for example, export NODE_FOR_REPRO="$(command -v node)") and run this recipe. It uses disposable homes and working directories, a sparse environment with no ambient client overrides or credentials, and checks planned write containment before apply. Skill installation is disabled to isolate global registration routing. The commands saved in registrations are configuration text and are not launched.

python3 <<'PYTHON'
import json, os, pathlib, subprocess, tempfile
checkout = pathlib.Path.cwd()
node = os.environ['NODE_FOR_REPRO']
cli = checkout / 'scripts' / 'rea.mjs'
with tempfile.TemporaryDirectory(prefix='rea-profile-') as trial:
    root = pathlib.Path(trial).resolve()
    for name in ['unset', 'empty', 'explicit', 'explicit-space']:
        case = root / name
        home, cwd = case / 'home', case / 'cwd'
        profile = case / ('profile one' if name == 'explicit-space' else 'profile')
        for path in [home, cwd, profile, case / 'temp']:
            path.mkdir(parents=True)
        env = {'PATH': str(pathlib.Path(node).parent), 'HOME': str(home),
               'USERPROFILE': str(home), 'TMPDIR': str(case / 'temp')}
        for key, directory in [('XDG_CONFIG_HOME', 'config'), ('XDG_CACHE_HOME', 'cache'),
                               ('XDG_DATA_HOME', 'data'), ('XDG_STATE_HOME', 'state'),
                               ('XDG_RUNTIME_DIR', 'runtime'), ('npm_config_cache', 'npm-cache'),
                               ('npm_config_prefix', 'npm-prefix')]:
            env[key] = str(case / directory)
        env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = str(case / 'system/settings.json')
        env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH'] = str(case / 'system/defaults.json')
        if name == 'empty': env['CLAUDE_CONFIG_DIR'] = ''
        elif name.startswith('explicit'): env['CLAUDE_CONFIG_DIR'] = str(profile)
        default = home / '.claude.json'
        default.write_text('{"fixture":"keep","mcpServers":{}}\n')
        def run(args, selected=env, exit_code=0):
            p = subprocess.run([node, str(cli), *args, '--json'], env=selected,
                               cwd=cwd, capture_output=True, text=True, timeout=40)
            assert p.returncode == exit_code, (p.returncode, p.stderr, p.stdout)
            return json.loads(p.stdout)
        setup = ['setup', '--client', 'claude_code', '--skill=false']
        def checked_plan(selected):
            plan = run(setup + ['--dry-run'], selected)
            assert len(plan['plannedActions']) == 1
            action = plan['plannedActions'][0]
            assert action['kind'] == 'configure_client'
            target = pathlib.Path(action['target'])
            target = (target if target.is_absolute() else cwd / target).resolve()
            assert target.is_relative_to(root)
            for state in plan['clientStates']:
                for value in state['client'].get('configPaths', [state['client']['configPath']]):
                    path = pathlib.Path(value)
                    assert (path if path.is_absolute() else cwd / path).resolve().is_relative_to(root)
            return target
        target = checked_plan(env)
        applied = run(setup + ['--yes'])
        doctor = run(['doctor', '--client', 'claude_code'])
        registration = doctor['identity']['registrations'][0]
        assert registration['state'] == 'aligned'
        assert 'rea' in json.loads(target.read_text())['mcpServers']
        print(name, 'config=' + str(target.relative_to(case)),
              'default_has_rea=' + str('rea' in json.loads(default.read_text())['mcpServers']),
              'setup=' + applied['status'], 'doctor=' + registration['state'])
        if name == 'empty':
            unset = dict(env)
            del unset['CLAUDE_CONFIG_DIR']
            missing = run(['doctor', '--client', 'claude_code'], unset, 1)
            assert missing['identity']['registrations'][0]['state'] == 'missing'
            assert checked_plan(unset) == default
            run(setup + ['--yes'], unset)
            run(['doctor', '--client', 'claude_code'], unset)
            assert 'rea' in json.loads(default.read_text())['mcpServers']
            run(['uninstall'], unset)
            assert 'rea' not in json.loads(default.read_text())['mcpServers']
            assert 'rea' in json.loads(target.read_text())['mcpServers']
            print('empty workaround: unset uses default; existing cwd registration remains')
        removed = run(['uninstall'])
        assert removed['status'] == 'complete'
        assert 'rea' not in json.loads(target.read_text()).get('mcpServers', {})
        assert json.loads(default.read_text()) == {'fixture': 'keep', 'mcpServers': {}}
PYTHON

The recipe ran successfully after rebuilding current main. The empty-value failure reproduced in that run and the prior independent baseline verification; each run used fresh isolated fixtures. The home fixture contains only an unrelated marker and an empty mcpServers object, with no legacy .config.json, project MCP file or custom OAuth selection. Fixture directories are removed on exit.

Expected behavior

REA's user-level registration should target the global file selected by the supported Claude client for the same environment, and doctor should assess that registration. The installation contract documents user registration and separate profiles. Claude's user-scope MCP documentation identifies the global .claude.json as the user registration store.

The empty-value rule is source-derived from static inspection of the official Claude 2.1.301 artifact, whose archive SHA-512 matched its registry integrity. Its embedded global-file selector at byte offset 195276846 is function b(){let t=`.claude${R9()}.json`;return I(process.env.CLAUDE_CONFIG_DIR||W_(),t)}. The imports bind I to path.join and W_ to os.homedir. The ordinary getGlobalClaudeFile() delegates to v_(), which uses this selected file when no legacy config-directory .config.json exists; R9() returns an empty production suffix without custom OAuth. For this fixture, unset and empty select HOME/.claude.json, while a nonempty absolute profile selects PROFILE/.claude.json.

Claude's separate configuration-directory selector uses nullish fallback and retains an empty string. This report establishes no equivalent defect for settings, markers or skill roots and does not prescribe whitespace trimming or a shared-root refactor. A specific invalid-root refusal before writes or successful readiness would also avoid the observed false success.

Impact and scope

P2, supported: an explicitly empty override causes a bounded global registration path mismatch and misleading doctor result. Unset and explicit absolute controls, including a path with an ordinary space, select their expected registration files and preserve unrelated fixture values. No exposure, data loss, accidental commit or native-session failure was observed.

Workaround: remove CLAUDE_CONFIG_DIR from the environment before setup and subsequent default-profile checks. The executed recipe verifies that unset doctor first reports the home registration missing, public setup then configures it, and doctor becomes aligned. Unsetting does not remove an existing working-directory registration; the recipe removes that earlier registration separately under its original selection. Setting the override to HOME/.claude is not the unset workaround: it selects HOME/.claude/.claude.json.

REA version

Source build at current-main revision 2202e3f0ba556d40cc6e598df1ecbdf1eda56c13, rebuilt with locked dependencies; launcher reports 6.5.0. This is executed source-build evidence, not a claim that the published 6.5.0 artifact reproduces it.

Runtime and platform

Node.js v24.18.0 and npm 11.16.0; macOS arm64. Native selection evidence is static inspection of @anthropic-ai/claude-code-darwin-arm64 2.1.301, not execution of its native binary. No analysis engine or client login is required.

Target and artifact context

Synthetic Claude Code user-level MCP registration, isolated default JSON fixture and separate working directory. No analysis target is involved.

Interface

CLI

Earliest failure stage

Setup or provider admission

Relevant output or evidence

The exact recipe printed:

unset config=home/.claude.json default_has_rea=True setup=ready doctor=aligned
empty config=cwd/.claude.json default_has_rea=False setup=ready doctor=aligned
empty workaround: unset uses default; existing cwd registration remains
explicit config=profile/.claude.json default_has_rea=False setup=ready doctor=aligned
explicit-space config=profile one/.claude.json default_has_rea=False setup=ready doctor=aligned

For the empty case, dry-run planned .claude.json; apply actually wrote the file under the isolated working directory. Doctor checked that relative file and reported aligned. Uninstall removed the owned registration at the selected location and preserved the unrelated default fixture value.

  • Expected source: the installation contract, official MCP documentation and statically inspected published Claude resolver above.
  • Failure source: the executed public CLI recipe and filesystem assertions above; global directory fallback and client registration path retain the empty value and construct the relative file consumed by setup, doctor and uninstall.
  • Evidence provenance: observed REA behavior; source-derived native selection contract.
  • Local verification: reproduced through the public CLI.
  • Reproduction completeness: complete for the stated registration-only fixture.

The independent current-main build passed all three build tasks, and the existing focused checks passed 30 tests in tests/boundary/filesystem/clientConfigurationDialects.test.ts and tests/boundary/cli/clientProfileLifecycle.test.ts. Those passing tests do not catch this empty-value global registration mismatch.

Typed outcome or recovery expected

With the original empty environment value, setup should configure the supported client's selected global registration file or return a specific invalid-root diagnostic before writing or reporting readiness. Doctor must not call the working-directory file an aligned global registration while the selected home file lacks REA.

Additional context

Restoration check: rerun the same empty-value fixture and inspect actual files as well as setup/doctor results. Keep unset and explicit absolute profiles, including an ordinary space, selecting their correct files and preserving unrelated values. Keep global registration semantics distinct from skill/config-directory semantics; do not require the entire empty plan to equal the unset plan or trim a nonempty Unix pathname merely because it contains spaces.

NOT_ENOUGH_INFO: native Claude execution/tool discovery, packaged-release reproduction, other platforms and prevalence were not tested. The native selection contract above is static artifact inspection only. No last-known-good revision is established.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: An explicitly empty environment value causes approved setup to write the global registration to a different file from the supported client's ordinary selector, and doctor reports misleading alignment. The effect is bounded to this selection; native tool unavailability, exposure, data loss and prevalence were not established.
  • Workaround status: available
  • Workaround basis: Removing the variable was executed through public setup, doctor and uninstall and restored the intended default home registration while preserving the unrelated fixture value. Existing working-directory artifacts require separate cleanup under their original selection.
Originally created by @coygeek on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4262 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Setup, packaging, or upgrade ### Observed behavior With `CLAUDE_CONFIG_DIR` explicitly set to the empty string, approved `setup --client claude_code --skill=false --yes --json` writes `mcpServers.rea` into the working directory's `.claude.json` and reports `ready`. Scoped doctor then reports that registration `aligned`, although `HOME/.claude.json` still lacks REA. Static inspection of the published Claude global-file resolver selects the home file for this ordinary fixture. The REA write and diagnosis were executed; Claude itself was not launched. ### Reproduction From the root of the source checkout identified below, with locked dependencies installed and `npm run build:cached` completed, set `NODE_FOR_REPRO` to the absolute pinned Node executable (for example, `export NODE_FOR_REPRO="$(command -v node)"`) and run this recipe. It uses disposable homes and working directories, a sparse environment with no ambient client overrides or credentials, and checks planned write containment before apply. Skill installation is disabled to isolate global registration routing. The commands saved in registrations are configuration text and are not launched. ```sh python3 <<'PYTHON' import json, os, pathlib, subprocess, tempfile checkout = pathlib.Path.cwd() node = os.environ['NODE_FOR_REPRO'] cli = checkout / 'scripts' / 'rea.mjs' with tempfile.TemporaryDirectory(prefix='rea-profile-') as trial: root = pathlib.Path(trial).resolve() for name in ['unset', 'empty', 'explicit', 'explicit-space']: case = root / name home, cwd = case / 'home', case / 'cwd' profile = case / ('profile one' if name == 'explicit-space' else 'profile') for path in [home, cwd, profile, case / 'temp']: path.mkdir(parents=True) env = {'PATH': str(pathlib.Path(node).parent), 'HOME': str(home), 'USERPROFILE': str(home), 'TMPDIR': str(case / 'temp')} for key, directory in [('XDG_CONFIG_HOME', 'config'), ('XDG_CACHE_HOME', 'cache'), ('XDG_DATA_HOME', 'data'), ('XDG_STATE_HOME', 'state'), ('XDG_RUNTIME_DIR', 'runtime'), ('npm_config_cache', 'npm-cache'), ('npm_config_prefix', 'npm-prefix')]: env[key] = str(case / directory) env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = str(case / 'system/settings.json') env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH'] = str(case / 'system/defaults.json') if name == 'empty': env['CLAUDE_CONFIG_DIR'] = '' elif name.startswith('explicit'): env['CLAUDE_CONFIG_DIR'] = str(profile) default = home / '.claude.json' default.write_text('{"fixture":"keep","mcpServers":{}}\n') def run(args, selected=env, exit_code=0): p = subprocess.run([node, str(cli), *args, '--json'], env=selected, cwd=cwd, capture_output=True, text=True, timeout=40) assert p.returncode == exit_code, (p.returncode, p.stderr, p.stdout) return json.loads(p.stdout) setup = ['setup', '--client', 'claude_code', '--skill=false'] def checked_plan(selected): plan = run(setup + ['--dry-run'], selected) assert len(plan['plannedActions']) == 1 action = plan['plannedActions'][0] assert action['kind'] == 'configure_client' target = pathlib.Path(action['target']) target = (target if target.is_absolute() else cwd / target).resolve() assert target.is_relative_to(root) for state in plan['clientStates']: for value in state['client'].get('configPaths', [state['client']['configPath']]): path = pathlib.Path(value) assert (path if path.is_absolute() else cwd / path).resolve().is_relative_to(root) return target target = checked_plan(env) applied = run(setup + ['--yes']) doctor = run(['doctor', '--client', 'claude_code']) registration = doctor['identity']['registrations'][0] assert registration['state'] == 'aligned' assert 'rea' in json.loads(target.read_text())['mcpServers'] print(name, 'config=' + str(target.relative_to(case)), 'default_has_rea=' + str('rea' in json.loads(default.read_text())['mcpServers']), 'setup=' + applied['status'], 'doctor=' + registration['state']) if name == 'empty': unset = dict(env) del unset['CLAUDE_CONFIG_DIR'] missing = run(['doctor', '--client', 'claude_code'], unset, 1) assert missing['identity']['registrations'][0]['state'] == 'missing' assert checked_plan(unset) == default run(setup + ['--yes'], unset) run(['doctor', '--client', 'claude_code'], unset) assert 'rea' in json.loads(default.read_text())['mcpServers'] run(['uninstall'], unset) assert 'rea' not in json.loads(default.read_text())['mcpServers'] assert 'rea' in json.loads(target.read_text())['mcpServers'] print('empty workaround: unset uses default; existing cwd registration remains') removed = run(['uninstall']) assert removed['status'] == 'complete' assert 'rea' not in json.loads(target.read_text()).get('mcpServers', {}) assert json.loads(default.read_text()) == {'fixture': 'keep', 'mcpServers': {}} PYTHON ``` The recipe ran successfully after rebuilding current main. The empty-value failure reproduced in that run and the prior independent baseline verification; each run used fresh isolated fixtures. The home fixture contains only an unrelated marker and an empty `mcpServers` object, with no legacy `.config.json`, project MCP file or custom OAuth selection. Fixture directories are removed on exit. ### Expected behavior REA's user-level registration should target the global file selected by the supported Claude client for the same environment, and doctor should assess that registration. The [installation contract](https://github.com/morluto/rea/blob/2202e3f0ba556d40cc6e598df1ecbdf1eda56c13/docs/installation.md#L155-L192) documents user registration and separate profiles. Claude's [user-scope MCP documentation](https://code.claude.com/docs/en/mcp#user-scope) identifies the global `.claude.json` as the user registration store. The empty-value rule is source-derived from static inspection of the official [Claude 2.1.301 artifact](https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-arm64/-/claude-code-darwin-arm64-2.1.301.tgz), whose archive SHA-512 matched its registry integrity. Its embedded global-file selector at byte offset 195276846 is ``function b(){let t=`.claude${R9()}.json`;return I(process.env.CLAUDE_CONFIG_DIR||W_(),t)}``. The imports bind `I` to `path.join` and `W_` to `os.homedir`. The ordinary `getGlobalClaudeFile()` delegates to `v_()`, which uses this selected file when no legacy config-directory `.config.json` exists; `R9()` returns an empty production suffix without custom OAuth. For this fixture, unset and empty select `HOME/.claude.json`, while a nonempty absolute profile selects `PROFILE/.claude.json`. Claude's separate configuration-directory selector uses nullish fallback and retains an empty string. This report establishes no equivalent defect for settings, markers or skill roots and does not prescribe whitespace trimming or a shared-root refactor. A specific invalid-root refusal before writes or successful readiness would also avoid the observed false success. ### Impact and scope P2, supported: an explicitly empty override causes a bounded global registration path mismatch and misleading doctor result. Unset and explicit absolute controls, including a path with an ordinary space, select their expected registration files and preserve unrelated fixture values. No exposure, data loss, accidental commit or native-session failure was observed. Workaround: remove `CLAUDE_CONFIG_DIR` from the environment before setup and subsequent default-profile checks. The executed recipe verifies that unset doctor first reports the home registration missing, public setup then configures it, and doctor becomes aligned. Unsetting does not remove an existing working-directory registration; the recipe removes that earlier registration separately under its original selection. Setting the override to `HOME/.claude` is not the unset workaround: it selects `HOME/.claude/.claude.json`. ### REA version Source build at current-main revision `2202e3f0ba556d40cc6e598df1ecbdf1eda56c13`, rebuilt with locked dependencies; launcher reports 6.5.0. This is executed source-build evidence, not a claim that the published 6.5.0 artifact reproduces it. ### Runtime and platform Node.js v24.18.0 and npm 11.16.0; macOS arm64. Native selection evidence is static inspection of `@anthropic-ai/claude-code-darwin-arm64` 2.1.301, not execution of its native binary. No analysis engine or client login is required. ### Target and artifact context Synthetic Claude Code user-level MCP registration, isolated default JSON fixture and separate working directory. No analysis target is involved. ### Interface CLI ### Earliest failure stage Setup or provider admission ### Relevant output or evidence The exact recipe printed: ```text unset config=home/.claude.json default_has_rea=True setup=ready doctor=aligned empty config=cwd/.claude.json default_has_rea=False setup=ready doctor=aligned empty workaround: unset uses default; existing cwd registration remains explicit config=profile/.claude.json default_has_rea=False setup=ready doctor=aligned explicit-space config=profile one/.claude.json default_has_rea=False setup=ready doctor=aligned ``` For the empty case, dry-run planned `.claude.json`; apply actually wrote the file under the isolated working directory. Doctor checked that relative file and reported `aligned`. Uninstall removed the owned registration at the selected location and preserved the unrelated default fixture value. - Expected source: the installation contract, official MCP documentation and statically inspected published Claude resolver above. - Failure source: the executed public CLI recipe and filesystem assertions above; [global directory fallback](https://github.com/morluto/rea/blob/2202e3f0ba556d40cc6e598df1ecbdf1eda56c13/src/application/SupportedClients.ts#L161-L165) and [client registration path](https://github.com/morluto/rea/blob/2202e3f0ba556d40cc6e598df1ecbdf1eda56c13/src/application/SupportedClients.ts#L487-L494) retain the empty value and construct the relative file consumed by setup, doctor and uninstall. - Evidence provenance: observed REA behavior; source-derived native selection contract. - Local verification: reproduced through the public CLI. - Reproduction completeness: complete for the stated registration-only fixture. The independent current-main build passed all three build tasks, and the existing focused checks passed 30 tests in `tests/boundary/filesystem/clientConfigurationDialects.test.ts` and `tests/boundary/cli/clientProfileLifecycle.test.ts`. Those passing tests do not catch this empty-value global registration mismatch. ### Typed outcome or recovery expected With the original empty environment value, setup should configure the supported client's selected global registration file or return a specific invalid-root diagnostic before writing or reporting readiness. Doctor must not call the working-directory file an aligned global registration while the selected home file lacks REA. ### Additional context Restoration check: rerun the same empty-value fixture and inspect actual files as well as setup/doctor results. Keep unset and explicit absolute profiles, including an ordinary space, selecting their correct files and preserving unrelated values. Keep global registration semantics distinct from skill/config-directory semantics; do not require the entire empty plan to equal the unset plan or trim a nonempty Unix pathname merely because it contains spaces. NOT_ENOUGH_INFO: native Claude execution/tool discovery, packaged-release reproduction, other platforms and prevalence were not tested. The native selection contract above is static artifact inspection only. No last-known-good revision is established. ### Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: An explicitly empty environment value causes approved setup to write the global registration to a different file from the supported client's ordinary selector, and doctor reports misleading alignment. The effect is bounded to this selection; native tool unavailability, exposure, data loss and prevalence were not established. - Workaround status: available - Workaround basis: Removing the variable was executed through public setup, doctor and uninstall and restored the intended default home registration while preserving the unrelated fixture value. Existing working-directory artifacts require separate cleanup under their original selection.
Author
Owner

@morluto commented on GitHub (Oct 16, 2026):

Fixed on main in 79e83cbd6. An empty Claude configuration override now uses the home registration and personal skill roots consistently, while preserving explicit nonempty choices.

Setup, doctor and uninstall CLI controls cover unset, empty and explicit paths, backups and the absence of working-directory writes; Claude itself was not launched.

Hosted main CI is still pending; this closure is based on the implemented fix and the scoped regression evidence.

<!-- gh-comment-id:6206033849 --> @morluto commented on GitHub (Oct 16, 2026): Fixed on `main` in [79e83cbd6](https://github.com/morluto/rea/commit/79e83cbd67ca9fd78b5469c3d44101e5e93f8a2b). An empty Claude configuration override now uses the home registration and personal skill roots consistently, while preserving explicit nonempty choices. Setup, doctor and uninstall CLI controls cover unset, empty and explicit paths, backups and the absence of working-directory writes; Claude itself was not launched. Hosted main CI is still pending; this closure is based on the implemented fix and the scoped regression evidence.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1686
No description provided.