fix: rename .claude.json to .openclaude.json with legacy fallback (#582)

* fix: rename .claude.json to .openclaude.json with legacy fallback

Rename the global config file from ~/.claude.json to ~/.openclaude.json,
following the same migration pattern as the config directory
(~/.claude → ~/.openclaude).

- getGlobalClaudeFile() now prefers .openclaude.json; falls back to
  .claude.json only if the legacy file exists and the new one does not
- Add .openclaude.json to filesystem permissions allowlist (keep
  .claude.json for legacy file protection)
- Update all comment/string references from ~/.claude.json to
  ~/.openclaude.json across 12 files

New installs get .openclaude.json from the start. Existing users
continue using .claude.json until they rename it (or a future explicit
migration).

* test: add unit tests for getGlobalClaudeFile migration branches

Covers the three cases:
- new install (neither file exists) → .openclaude.json
- existing user (only legacy .claude.json exists) → .claude.json
- migrated user (both files exist) → .openclaude.json

---------

Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com>
This commit is contained in:
Zartris
2026-04-20 11:13:09 +02:00
committed by GitHub
parent fdef4a1b4c
commit 4d4fb2880e
15 changed files with 96 additions and 20 deletions

View File

@@ -19,7 +19,7 @@ async function _temp() {
logForDebugging("Showing marketplace config save failure notification");
notifs.push({
key: "marketplace-config-save-failed",
jsx: <Text color="error">Failed to save marketplace retry info · Check ~/.claude.json permissions</Text>,
jsx: <Text color="error">Failed to save marketplace retry info · Check ~/.openclaude.json permissions</Text>,
priority: "immediate",
timeoutMs: 10000
});

View File

@@ -12,7 +12,7 @@ import {
* One-shot migration: clear skipAutoPermissionPrompt for users who accepted
* the old 2-option AutoModeOptInDialog but don't have auto as their default.
* Re-surfaces the dialog so they see the new "make it my default mode" option.
* Guard lives in GlobalConfig (~/.claude.json), not settings.json, so it
* Guard lives in GlobalConfig (~/.openclaude.json), not settings.json, so it
* survives settings resets and doesn't re-arm itself.
*
* Only runs when tengu_auto_mode_config.enabled === 'enabled'. For 'opt-in'

View File

@@ -3873,7 +3873,7 @@ export function REPL({
// empty to non-empty, not on every length change -- otherwise a render loop
// (concurrent onQuery thrashing, etc.) spams saveGlobalConfig, which hits
// ELOCKED under concurrent sessions and falls back to unlocked writes.
// That write storm is the primary trigger for ~/.claude.json corruption
// That write storm is the primary trigger for ~/.openclaude.json corruption
// (GH #3117).
const hasCountedQueueUseRef = useRef(false);
useEffect(() => {

View File

@@ -334,7 +334,7 @@ async function processRemoteEvalPayload(
// Empty object is truthy — without the length check, `{features: {}}`
// (transient server bug, truncated response) would pass, clear the maps
// below, return true, and syncRemoteEvalToDisk would wholesale-write `{}`
// to disk: total flag blackout for every process sharing ~/.claude.json.
// to disk: total flag blackout for every process sharing ~/.openclaude.json.
if (!payload?.features || Object.keys(payload.features).length === 0) {
return false
}

View File

@@ -59,7 +59,7 @@ export function generatePrompt(): string {
## Configurable settings list
The following settings are available for you to change:
### Global Settings (stored in ~/.claude.json)
### Global Settings (stored in ~/.openclaude.json)
${globalSettings.join('\n')}
### Project Settings (stored in settings.json)

View File

@@ -693,7 +693,7 @@ export function refreshAwsAuth(awsAuthRefresh: string): Promise<boolean> {
'AWS auth refresh timed out after 3 minutes. Run your auth command manually in a separate terminal.',
)
: chalk.red(
'Error running awsAuthRefresh (in settings or ~/.claude.json):',
'Error running awsAuthRefresh (in settings or ~/.openclaude.json):',
)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(message)
@@ -771,7 +771,7 @@ async function getAwsCredsFromCredentialExport(): Promise<{
}
} catch (e) {
const message = chalk.red(
'Error getting AWS credentials from awsCredentialExport (in settings or ~/.claude.json):',
'Error getting AWS credentials from awsCredentialExport (in settings or ~/.openclaude.json):',
)
if (e instanceof Error) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
@@ -961,7 +961,7 @@ export function refreshGcpAuth(gcpAuthRefresh: string): Promise<boolean> {
'GCP auth refresh timed out after 3 minutes. Run your auth command manually in a separate terminal.',
)
: chalk.red(
'Error running gcpAuthRefresh (in settings or ~/.claude.json):',
'Error running gcpAuthRefresh (in settings or ~/.openclaude.json):',
)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(message)
@@ -1959,7 +1959,7 @@ export async function validateForceLoginOrg(): Promise<OrgValidationResult> {
// Always fetch the authoritative org UUID from the profile endpoint.
// Even keychain-sourced tokens verify server-side: the cached org UUID
// in ~/.claude.json is user-writable and cannot be trusted.
// in ~/.openclaude.json is user-writable and cannot be trusted.
const { source } = getAuthTokenSource()
const isEnvVarToken =
source === 'CLAUDE_CODE_OAUTH_TOKEN' ||

View File

@@ -28,7 +28,7 @@ import { getSettingsForSource } from './settings/settings.js'
* is lazy-initialized) and ensure Node.js compatibility.
*
* This is safe to call before the trust dialog because we only read from
* user-controlled files (~/.claude/settings.json and ~/.claude.json),
* user-controlled files (~/.claude/settings.json and ~/.openclaude.json),
* not from project-level settings.
*/
export function applyExtraCACertsFromConfig(): void {
@@ -52,7 +52,7 @@ export function applyExtraCACertsFromConfig(): void {
* after the trust dialog. But we need the CA cert early to establish the TLS
* connection to an HTTPS proxy during init().
*
* We read from global config (~/.claude.json) and user settings
* We read from global config (~/.openclaude.json) and user settings
* (~/.claude/settings.json). These are user-controlled files that don't
* require trust approval.
*/

View File

@@ -355,7 +355,7 @@ exec ${command}
*
* Only positive detections are persisted. A negative result from the
* filesystem scan is not cached, because it may come from a machine that
* shares ~/.claude.json but has no local Chrome (e.g. a remote dev
* shares ~/.openclaude.json but has no local Chrome (e.g. a remote dev
* environment using the bridge), and caching it would permanently poison
* auto-enable for every session on every machine that reads that config.
*/

View File

@@ -918,7 +918,7 @@ let configCacheHits = 0
let configCacheMisses = 0
// Session-total count of actual disk writes to the global config file.
// Exposed for internal-only dev diagnostics (see inc-4552) so anomalous write
// rates surface in the UI before they corrupt ~/.claude.json.
// rates surface in the UI before they corrupt ~/.openclaude.json.
let globalConfigWriteCount = 0
export function getGlobalConfigWriteCount(): number {
@@ -1257,7 +1257,7 @@ function saveConfigWithLock<A extends object>(
const currentConfig = getConfig(file, createDefault)
if (file === getGlobalClaudeFile() && wouldLoseAuthState(currentConfig)) {
logForDebugging(
'saveConfigWithLock: re-read config is missing auth that cache has; refusing to write to avoid wiping ~/.claude.json. See GH #3117.',
'saveConfigWithLock: re-read config is missing auth that cache has; refusing to write to avoid wiping ~/.openclaude.json. See GH #3117.',
{ level: 'error' },
)
logEvent('tengu_config_auth_loss_prevented', {})

View File

@@ -253,7 +253,7 @@ async function resolveClaudePath(): Promise<string> {
* Check whether the OS-level protocol handler is already registered AND
* points at the expected `claude` binary. Reads the registration artifact
* directly (symlink target, .desktop Exec line, registry value) rather than
* a cached flag in ~/.claude.json, so:
* a cached flag in ~/.openclaude.json, so:
* - the check is per-machine (config can sync across machines; OS state can't)
* - stale paths self-heal (install-method change → re-register next session)
* - deleted artifacts self-heal
@@ -311,7 +311,7 @@ export async function ensureDeepLinkProtocolRegistered(): Promise<void> {
// EACCES/ENOSPC are deterministic — retrying next session won't help.
// Throttle to once per 24h so a read-only ~/.local/share/applications
// doesn't generate a failure event on every startup. Marker lives in
// ~/.claude (per-machine, not synced) rather than ~/.claude.json (can sync).
// ~/.claude (per-machine, not synced) rather than ~/.openclaude.json (can sync).
const failureMarkerPath = path.join(
getClaudeConfigHomeDir(),
'.deep-link-register-failed',

62
src/utils/env.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
const originalEnv = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
CLAUDE_CODE_CUSTOM_OAUTH_URL: process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL,
USER_TYPE: process.env.USER_TYPE,
}
let tempDir: string
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-env-test-'))
process.env.CLAUDE_CONFIG_DIR = tempDir
delete process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL
delete process.env.USER_TYPE
})
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
if (originalEnv.CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalEnv.CLAUDE_CONFIG_DIR
}
if (originalEnv.CLAUDE_CODE_CUSTOM_OAUTH_URL === undefined) {
delete process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL
} else {
process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL = originalEnv.CLAUDE_CODE_CUSTOM_OAUTH_URL
}
if (originalEnv.USER_TYPE === undefined) {
delete process.env.USER_TYPE
} else {
process.env.USER_TYPE = originalEnv.USER_TYPE
}
})
async function importFreshEnvModule() {
return import(`./env.js?ts=${Date.now()}-${Math.random()}`)
}
// getGlobalClaudeFile — three migration branches
test('getGlobalClaudeFile: new install returns .openclaude.json when neither file exists', async () => {
const { getGlobalClaudeFile } = await importFreshEnvModule()
expect(getGlobalClaudeFile()).toBe(join(tempDir, '.openclaude.json'))
})
test('getGlobalClaudeFile: existing user keeps .claude.json when only legacy file exists', async () => {
writeFileSync(join(tempDir, '.claude.json'), '{}')
const { getGlobalClaudeFile } = await importFreshEnvModule()
expect(getGlobalClaudeFile()).toBe(join(tempDir, '.claude.json'))
})
test('getGlobalClaudeFile: migrated user uses .openclaude.json when both files exist', async () => {
writeFileSync(join(tempDir, '.claude.json'), '{}')
writeFileSync(join(tempDir, '.openclaude.json'), '{}')
const { getGlobalClaudeFile } = await importFreshEnvModule()
expect(getGlobalClaudeFile()).toBe(join(tempDir, '.openclaude.json'))
})

View File

@@ -21,8 +21,21 @@ export const getGlobalClaudeFile = memoize((): string => {
return join(getClaudeConfigHomeDir(), '.config.json')
}
const filename = `.claude${fileSuffixForOauthConfig()}.json`
return join(process.env.CLAUDE_CONFIG_DIR || homedir(), filename)
const oauthSuffix = fileSuffixForOauthConfig()
const configDir = process.env.CLAUDE_CONFIG_DIR || homedir()
// Default to .openclaude.json. Fall back to .claude.json only if the new
// file doesn't exist yet and the legacy one does (same migration pattern
// as resolveClaudeConfigHomeDir for the config directory).
const newFilename = `.openclaude${oauthSuffix}.json`
const legacyFilename = `.claude${oauthSuffix}.json`
if (
!getFsImplementation().existsSync(join(configDir, newFilename)) &&
getFsImplementation().existsSync(join(configDir, legacyFilename))
) {
return join(configDir, legacyFilename)
}
return join(configDir, newFilename)
})
const hasInternetAccess = memoize(async (): Promise<boolean> => {

View File

@@ -24,7 +24,7 @@ type CachedParse = { ok: true; value: unknown } | { ok: false }
// lodash memoize default resolver = first arg only).
// Skip caching above this size — the LRU stores the full string as the key,
// so a 200KB config file would pin ~10MB in #keyList across 50 slots. Large
// inputs like ~/.claude.json also change between reads (numStartups bumps on
// inputs like ~/.openclaude.json also change between reads (numStartups bumps on
// every CC startup), so the cache never hits anyway.
const PARSE_CACHE_MAX_KEY_BYTES = 8 * 1024

View File

@@ -131,7 +131,7 @@ export function applySafeConfigEnvironmentVariables(): void {
: null
}
// Global config (~/.claude.json) is user-controlled. In CCD mode,
// Global config (~/.openclaude.json) is user-controlled. In CCD mode,
// filterSettingsEnv strips keys that were in the spawn env snapshot so
// the desktop host's operational vars (OTEL, etc.) are not overridden.
Object.assign(process.env, filterSettingsEnv(getGlobalConfig().env))

View File

@@ -64,6 +64,7 @@ export const DANGEROUS_FILES = [
'.profile',
'.ripgreprc',
'.mcp.json',
'.openclaude.json',
'.claude.json',
] as const