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:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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', {})
|
||||
|
||||
@@ -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
62
src/utils/env.test.ts
Normal 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'))
|
||||
})
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -64,6 +64,7 @@ export const DANGEROUS_FILES = [
|
||||
'.profile',
|
||||
'.ripgreprc',
|
||||
'.mcp.json',
|
||||
'.openclaude.json',
|
||||
'.claude.json',
|
||||
] as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user