fix: prefer $HOME over os.homedir() for path resolution

os.homedir() reads from /etc/passwd which can return a stale path
after a Linux user rename (usermod -l). Prefer the $HOME environment
variable which reflects the current session.

Closes #82

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Steve
2026-02-14 11:47:35 -04:00
parent a58f0166fa
commit 14e5b644b5
6 changed files with 51 additions and 11 deletions
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveHome } from '../homedir.js'
import { resolveClawdbotDefaultWorkspace, resolveClawdbotSkillRoots } from './clawdbotConfig.js'
const originalEnv = { ...process.env }
@@ -177,6 +178,31 @@ describe('resolveClawdbotSkillRoots', () => {
expect(labels[resolve(openclawStateDir, 'skills')]).toBe('OpenClaw: Shared skills')
})
it('uses $HOME over os.homedir() for tilde expansion', async () => {
const base = await mkdtemp(join(tmpdir(), 'clawhub-home-override-'))
const customHome = join(base, 'custom-home')
const stateDir = join(base, 'state')
const configPath = join(base, 'clawdbot.json')
const openclawStateDir = join(base, 'openclaw-state')
process.env.HOME = customHome
process.env.CLAWDBOT_STATE_DIR = stateDir
process.env.CLAWDBOT_CONFIG_PATH = configPath
process.env.OPENCLAW_STATE_DIR = openclawStateDir
process.env.OPENCLAW_CONFIG_PATH = join(openclawStateDir, 'openclaw.json')
const config = `{
agents: {
defaults: { workspace: "~/my-workspace" },
},
}`
await writeFile(configPath, config, 'utf8')
const workspace = await resolveClawdbotDefaultWorkspace()
expect(workspace).toBe(resolve(customHome, 'my-workspace'))
expect(resolveHome()).toBe(customHome)
})
it('supports OpenClaw configuration files', async () => {
const base = await mkdtemp(join(tmpdir(), 'clawhub-openclaw-'))
const stateDir = join(base, 'openclaw-state')
+4 -4
View File
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import JSON5 from 'json5'
import { resolveHome } from '../homedir.js'
type ClawdbotConfig = {
agent?: { workspace?: string }
@@ -95,7 +95,7 @@ export async function resolveClawdbotDefaultWorkspace(): Promise<string | null>
function resolveClawdbotStateDir() {
const override = process.env.CLAWDBOT_STATE_DIR?.trim()
if (override) return resolveUserPath(override)
return join(homedir(), '.clawdbot')
return join(resolveHome(), '.clawdbot')
}
function resolveClawdbotConfigPath() {
@@ -107,7 +107,7 @@ function resolveClawdbotConfigPath() {
function resolveOpenclawStateDir() {
const override = process.env.OPENCLAW_STATE_DIR?.trim()
if (override) return resolveUserPath(override)
return join(homedir(), '.openclaw')
return join(resolveHome(), '.openclaw')
}
function resolveOpenclawConfigPath() {
@@ -120,7 +120,7 @@ function resolveUserPath(input: string) {
const trimmed = input.trim()
if (!trimmed) return ''
if (trimmed.startsWith('~')) {
return resolve(trimmed.replace(/^~(?=$|[\\/])/, homedir()))
return resolve(trimmed.replace(/^~(?=$|[\\/])/, resolveHome()))
}
return resolve(trimmed)
}
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto'
import { realpath } from 'node:fs/promises'
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { resolveHome } from '../../homedir.js'
import { isCancel, multiselect } from '@clack/prompts'
import semver from 'semver'
import { apiRequest, downloadZip } from '../../http.js'
@@ -338,7 +338,7 @@ export function printSection(title: string, body?: string) {
}
function abbreviatePath(value: string) {
const home = homedir()
const home = resolveHome()
if (value.startsWith(home)) return `~${value.slice(home.length)}`
return value
}
@@ -348,7 +348,7 @@ function rootTelemetryId(value: string) {
}
function formatRootLabel(value: string) {
const home = homedir()
const home = resolveHome()
if (value === home) return '~'
const normalized = value.replaceAll('\\', '/')
+2 -2
View File
@@ -1,6 +1,6 @@
import { readdir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import { resolveHome } from '../homedir.js'
import { sanitizeSlug, titleCase } from './slug.js'
export type SkillFolder = {
@@ -30,7 +30,7 @@ export async function findSkillFolders(root: string): Promise<SkillFolder[]> {
}
export function getFallbackSkillRoots(workdir: string) {
const home = homedir()
const home = resolveHome()
const roots = [
// adjacent repo installs
resolve(workdir, '..', 'clawdis', 'skills'),
+2 -2
View File
@@ -1,7 +1,7 @@
import { existsSync } from 'node:fs'
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { resolveHome } from './homedir.js'
import { type GlobalConfig, GlobalConfigSchema, parseArk } from './schema/index.js'
/**
@@ -27,7 +27,7 @@ export function getGlobalConfigPath() {
process.env.CLAWHUB_CONFIG_PATH?.trim() ?? process.env.CLAWDHUB_CONFIG_PATH?.trim()
if (override) return resolve(override)
const home = homedir()
const home = resolveHome()
if (process.platform === 'darwin') {
return resolveConfigPath(join(home, 'Library', 'Application Support'))
+14
View File
@@ -0,0 +1,14 @@
import { homedir } from 'node:os'
/**
* Resolve the user's home directory, preferring environment variables over
* os.homedir(). On Linux, os.homedir() reads from /etc/passwd which can
* return a stale path after a user rename (usermod -l). The $HOME env var
* is set by the login process and reflects the current session.
*/
export function resolveHome(): string {
if (process.platform === 'win32') {
return process.env.USERPROFILE?.trim() || process.env.HOME?.trim() || homedir()
}
return process.env.HOME?.trim() || homedir()
}