diff --git a/docs/providers/openclaw.md b/docs/providers/openclaw.md index cd0f29071..9d6d391f8 100644 --- a/docs/providers/openclaw.md +++ b/docs/providers/openclaw.md @@ -8,14 +8,30 @@ OpenClaw, plus the older Clawdbot / Moltbot / Moldbot lineage. ## Where it reads from -Four directories, all checked on every run (`openclaw.ts`): +When set, `OPENCLAW_STATE_DIR/agents` is checked first. The environment variable +names the state directory, so CodeBurn appends `agents` itself. Whitespace is +trimmed, relative paths resolve from the current working directory, and a leading +`~` expands against OpenClaw's effective home: `OPENCLAW_HOME`, then `HOME`, +`USERPROFILE`, the Termux home fallback, and finally the OS home. Blank home +values and the literal strings `undefined` and `null` are treated as unset. +If no home candidate resolves, OpenClaw falls back to the current working +directory, and CodeBurn uses that same fallback. +This follows OpenClaw's [state directory resolver](https://github.com/openclaw/openclaw/blob/main/src/config/state-dir.ts) +and [home path resolver](https://github.com/openclaw/openclaw/blob/main/src/infra/home-dir.ts). + +Four historical directories are also checked on every run (`openclaw.ts`), under +that same effective home: - `~/.openclaw/agents` - `~/.clawdbot/agents` - `~/.moltbot/agents` - `~/.moldbot/agents` -The legacy directories are kept for users who upgraded from older builds. +The legacy directories are kept for users who upgraded from older builds. An +override that resolves to one of these roots is scanned once. Discovery and root +probes use the same list, so relocated sessions also receive watcher coverage. +The explicit `createOpenClawProvider(agentsDirectory)` constructor still selects +only its supplied agents directory, taking precedence over environment settings. ## Storage format @@ -28,7 +44,15 @@ The SQLite source path carries the session id after the database path (`: 0 ? [...pending] : undefined } -function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache { +function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean; watermarkTrusted?: boolean; openclawEnvFingerprint?: unknown }): DailyCache { const pendingRederive = pendingRederiveFor(parsed.version, parsed) return { ...(pendingRederive ? { pendingRederive } : {}), version: DAILY_CACHE_VERSION, savingsConfigHash: parsed.savingsConfigHash ?? '', + ...(typeof parsed.openclawEnvFingerprint === 'string' ? { openclawEnvFingerprint: parsed.openclawEnvFingerprint } : {}), tzKey: parsed.tzKey, lastComputedDate: typeof parsed.lastComputedDate === 'string' && DATE_KEY_RE.test(parsed.lastComputedDate) ? parsed.lastComputedDate @@ -962,6 +969,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate return { version: DAILY_CACHE_VERSION, savingsConfigHash: cache.savingsConfigHash, + ...(cache.openclawEnvFingerprint !== undefined ? { openclawEnvFingerprint: cache.openclawEnvFingerprint } : {}), tzKey: cache.tzKey, lastComputedDate: nextLast, days: applyRetention(merged, newestDate), @@ -1790,7 +1798,7 @@ export async function ensureCacheHydrated( c = { ...c, lastComputedDate: toDateString(pulledBack) } } - // Three reasons to re-derive the whole retention window: + // Four reasons to re-derive the whole retention window: // 1. Savings config changed — cached `savingsUSD` totals are stale. // 2. The cache was never finalized against a COMPLETE session parse (an old // pre-marker cache, an adoption from older cache files, or one frozen @@ -1799,6 +1807,8 @@ export async function ensureCacheHydrated( // TZ change mis-buckets every cached day. Only invalidate when a tzKey is // present and differs (a cache written before this field, or a test // fixture, has none → left alone rather than force a spurious rebuild). + // 4. OpenClaw's selected roots changed since the last complete backfill. + // Session-cache invalidation alone cannot revisit finalized days. // // Re-derive, NOT discard. Session files are ephemeral; a cached day whose // sources are gone exists nowhere else, so the old days stay as a baseline @@ -1808,7 +1818,9 @@ export async function ensureCacheHydrated( // into permanently lost history. const tzKey = currentTzKey() const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey - if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) { + const openclawEnvFingerprint = computeEnvFingerprint('openclaw') + const openclawEnvChanged = c.openclawEnvFingerprint !== undefined && c.openclawEnvFingerprint !== openclawEnvFingerprint + if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged || openclawEnvChanged) { const baseline = c.days const priorWatermark = c.lastComputedDate const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS) @@ -1902,6 +1914,7 @@ export async function ensureCacheHydrated( version: DAILY_CACHE_VERSION, savingsConfigHash, tzKey, + ...(parseWasComplete ? { openclawEnvFingerprint } : c.openclawEnvFingerprint !== undefined ? { openclawEnvFingerprint: c.openclawEnvFingerprint } : {}), // Spent: this re-derivation was the one the migration owed those // providers. A PARTIAL parse never got to use it (its fresh data only // filled gaps), so the entitlement is kept for the next complete run. @@ -1976,7 +1989,7 @@ export async function ensureCacheHydrated( // the same reason as the re-derive path above: a partial parse cannot // vouch for the days it never read, and gapStart is the only thing that // will ever bring them back. - c = { ...c, days: applyRetention(merged, yesterdayStr), lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete } + c = { ...c, ...(parseWasComplete ? { openclawEnvFingerprint } : {}), days: applyRetention(merged, yesterdayStr), lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete } await saveDailyCache(c) } else if (c.complete !== true && sessionComplete()) { // No gap to fill (already current through yesterday) but not yet marked — diff --git a/src/openclaw-paths.ts b/src/openclaw-paths.ts new file mode 100644 index 000000000..421df7e4e --- /dev/null +++ b/src/openclaw-paths.ts @@ -0,0 +1,55 @@ +import { join, resolve } from 'path' +import { homedir } from 'os' + +function normalizeHomeDir(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed && trimmed !== 'undefined' && trimmed !== 'null' ? trimmed : undefined +} + +export function getOpenClawDirs(): string[] { + let osHome = normalizeHomeDir(process.env['HOME']) ?? normalizeHomeDir(process.env['USERPROFILE']) + // OpenClaw also supports Termux when neither conventional home variable is + // set. A generic PREFIX alone is not enough to select this fallback. + if (!osHome) { + const prefix = normalizeHomeDir(process.env['PREFIX']) + if (prefix && normalizeHomeDir(process.env['ANDROID_DATA']) + && /(?:^|\/)com\.termux\/files\/usr\/?$/.test(prefix.replace(/\\/g, '/'))) { + osHome = resolve(prefix, '..', 'home') + } + } + if (!osHome) { + try { + osHome = normalizeHomeDir(homedir()) + } catch { + // OpenClaw's required-home resolver falls back to cwd when the OS + // lookup cannot provide a home. Apply the same rule without aborting. + } + } + const expandHome = (path: string, home: string): string => resolve(path.replace(/^~(?=$|[\\/])/, () => home)) + // OpenClaw resolves state overrides against its effective home. Trim before + // expanding so a quoted "~/state" works just like its own path resolver. + const configuredHome = normalizeHomeDir(process.env['OPENCLAW_HOME']) + let effectiveHome = configuredHome ?? osHome + if (configuredHome && /^~(?=$|[\\/])/.test(configuredHome)) { + // An unresolved tilde override has no effective home. OpenClaw falls back + // to cwd itself, rather than expanding the remaining suffix under cwd. + effectiveHome = osHome ? expandHome(configuredHome, osHome) : undefined + } + const home = resolve(effectiveHome ?? process.cwd()) + const stateDir = process.env['OPENCLAW_STATE_DIR']?.trim() + const roots = [ + ...(stateDir ? [join(expandHome(stateDir, home), 'agents')] : []), + join(home, '.openclaw', 'agents'), + join(home, '.clawdbot', 'agents'), + join(home, '.moltbot', 'agents'), + join(home, '.moldbot', 'agents'), + ] + // Keep the historical roots for pre-migration sessions, but scan an override + // pointing at one of those roots only once (including case aliases on Windows). + const unique = new Map() + for (const root of roots) { + const key = process.platform === 'win32' ? root.toLowerCase() : root + if (!unique.has(key)) unique.set(key, root) + } + return [...unique.values()] +} diff --git a/src/providers/openclaw.ts b/src/providers/openclaw.ts index dc82f78fb..a37009312 100644 --- a/src/providers/openclaw.ts +++ b/src/providers/openclaw.ts @@ -1,6 +1,6 @@ import { readdir, readFile, stat } from 'fs/promises' import { basename, join } from 'path' -import { homedir } from 'os' +import { getOpenClawDirs } from '../openclaw-paths.js' import { createHash } from 'node:crypto' import zlib from 'zlib' @@ -221,16 +221,6 @@ async function* finalizeCalls( } } -function getOpenClawDirs(): string[] { - const home = homedir() - return [ - join(home, '.openclaw', 'agents'), - join(home, '.clawdbot', 'agents'), - join(home, '.moltbot', 'agents'), - join(home, '.moldbot', 'agents'), - ] -} - function extractTools(content: Array<{ type?: string; name?: string; arguments?: Record }> | undefined): { tools: string[]; bashCommands: string[] } { const tools: string[] = [] const bashCommands: string[] = [] diff --git a/src/session-cache.ts b/src/session-cache.ts index 65c65ba4f..757bb898a 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -9,6 +9,7 @@ import { acquireCacheRefreshLock, releaseOwnedRefreshLocksForExit } from './cach import { parseBillingMode, type BillingMode } from './models.js' import type { ToolCall } from './types.js' import { isWslUncPath } from './wsl.js' +import { getOpenClawDirs } from './openclaw-paths.js' // ── Types ────────────────────────────────────────────────────────────── @@ -315,6 +316,7 @@ export const PROVIDER_ENV_VARS: Record = { 'cursor-agent': ['XDG_DATA_HOME'], 'open-design': ['CODEBURN_OPEN_DESIGN_DIR', 'APPDATA'], openclaude: ['CODEBURN_OPENCLAUDE_DIR'], + openclaw: ['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME', 'HOME', 'USERPROFILE', 'PREFIX', 'ANDROID_DATA'], opencode: ['XDG_DATA_HOME', 'OPENCODE_DATA_DIR', 'OPENCODE_DB_PREFIX'], goose: ['XDG_DATA_HOME', 'GOOSE_PATH_ROOT'], grok: ['GROK_HOME'], @@ -524,7 +526,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // sqlite-store-v1: id-less dedup keys now carry an occurrence index // (`h::`) instead of the bare payload hash; cached turns hold the // old keys, so without this bump they would suppress the re-parsed calls. - openclaw: 'reported-cost-v1-sqlite-store-v1', + openclaw: 'reported-cost-v1-sqlite-store-v1-state-dir-v1', // sessions-db-v1: usage now comes from sessions.db message_nodes, one // source per session keyed by request_id; transcripts are read only when the // database is unusable. The legacy metadata.metrics path no longer carves @@ -1036,6 +1038,10 @@ export function computeEnvFingerprint(provider: string): string { const parts = vars.map(v => `${v}=${process.env[v] ?? ''}`) const parseVersion = PROVIDER_PARSE_VERSIONS[provider] if (parseVersion) parts.push(`parser=${parseVersion}`) + // Relative overrides and the no-home fallback resolve from cwd. Hash the + // actual roots so changing cwd invalidates OpenClaw without invalidating + // absolute-root installations on every launch from another project. + if (provider === 'openclaw') parts.push(`roots=${JSON.stringify(getOpenClawDirs())}`) return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16) } diff --git a/tests/openclaw-discovery-cache.test.ts b/tests/openclaw-discovery-cache.test.ts new file mode 100644 index 000000000..13ae26346 --- /dev/null +++ b/tests/openclaw-discovery-cache.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { computeEnvFingerprint } from '../src/session-cache.js' +import { currentTzKey, ensureCacheHydrated, toDateString } from '../src/daily-cache.js' +import { aggregateProjectsIntoDays } from '../src/day-aggregator.js' + +afterEach(() => { + clearSessionCache() + vi.unstubAllEnvs() +}) + +async function seed(state: string, id: string, input: number, timestamp: string): Promise { + const sessions = join(state, 'agents', 'fixture-agent', 'sessions') + await mkdir(sessions, { recursive: true }) + const events = [ + { type: 'session', version: 3, id, timestamp, cwd: '/fixture/project' }, + { type: 'message', id: `${id}-user`, timestamp, message: { role: 'user', content: [{ type: 'text', text: 'Fix the fixture' }] } }, + { type: 'message', id: `${id}-reply`, timestamp, message: { + role: 'assistant', model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'Done' }], + usage: { input, output: 20, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }, + } }, + ] + await writeFile(join(sessions, `${id}.jsonl`), events.map(event => JSON.stringify(event)).join('\n') + '\n') +} + +describe('OpenClaw discovery cache invalidation', () => { + it.each(['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME', 'HOME', 'USERPROFILE', 'PREFIX', 'ANDROID_DATA'])('fingerprints changes to %s', name => { + const before = computeEnvFingerprint('openclaw') + vi.stubEnv(name, '/fixture/relocated') + expect(computeEnvFingerprint('openclaw')).not.toBe(before) + }) + + it('rediscovers the selected state directory after a warm parse', async () => { + const root = await mkdtemp(join(tmpdir(), 'openclaw-warm-root-')) + try { + vi.stubEnv('CODEBURN_CACHE_DIR', join(root, 'cache')) + const first = join(root, 'first') + const second = join(root, 'second') + const timestamp = '2026-04-20T10:00:00Z' + await seed(first, 'first', 100, timestamp) + await seed(second, 'second', 200, timestamp) + vi.stubEnv('OPENCLAW_STATE_DIR', first) + const initial = aggregateProjectsIntoDays(await parseAllSessions(undefined, 'openclaw')) + expect(initial[0]).toMatchObject({ calls: 1, inputTokens: 100, outputTokens: 20 }) + vi.stubEnv('OPENCLAW_STATE_DIR', second) + // A new CLI invocation has no in-process report memo, but retains the + // disk session cache produced under the previous environment. + clearSessionCache() + const changed = aggregateProjectsIntoDays(await parseAllSessions(undefined, 'openclaw')) + expect(changed[0]).toMatchObject({ calls: 1, inputTokens: 200, outputTokens: 20 }) + expect(changed).toHaveLength(1) + } finally { + clearSessionCache() + await rm(root, { recursive: true, force: true }) + } + }) + + it.each(['OPENCLAW_STATE_DIR', 'OPENCLAW_HOME', 'cwd'])('refreshes finalized history after a same-version root change (%s) without losing archived usage', async rootSetting => { + const root = await mkdtemp(join(tmpdir(), 'openclaw-finalized-root-')) + let cwd: ReturnType | undefined + try { + vi.stubEnv('CODEBURN_CACHE_DIR', join(root, 'cache')) + const first = join(root, 'first') + const second = join(root, 'second') + const timestamp = new Date(Date.now() - 30 * 86400000).toISOString() + const date = toDateString(new Date(timestamp)) + const state = (base: string) => rootSetting === 'OPENCLAW_HOME' ? join(base, '.openclaw') : rootSetting === 'cwd' ? join(base, 'relative-state') : base + await seed(state(first), 'first', 100, timestamp) + await seed(state(second), 'second', 200, timestamp) + const selectRoot = (base: string) => { + if (rootSetting === 'cwd') { + vi.stubEnv('OPENCLAW_STATE_DIR', 'relative-state') + if (!cwd) cwd = vi.spyOn(process, 'cwd') + cwd.mockReturnValue(base) + } else vi.stubEnv(rootSetting, base) + } + selectRoot(first) + let includeArchivedUsage = true + const parse = async () => { + const projects = await parseAllSessions(undefined, 'openclaw') + if (!includeArchivedUsage) return projects + const archived = structuredClone(projects) + for (const project of archived) for (const session of project.sessions) for (const turn of session.turns) for (const call of turn.assistantCalls) { + call.provider = 'claude' + call.usage.inputTokens = 50 + call.costUSD = 0.02 + } + return [...projects, ...archived] + } + const initial = await ensureCacheHydrated(parse, aggregateProjectsIntoDays) + expect(initial.complete).toBe(true) + expect(initial.days.find(day => day.date === date)).toMatchObject({ calls: 2, inputTokens: 150 }) + + includeArchivedUsage = false + selectRoot(second) + clearSessionCache() + const partial = await ensureCacheHydrated(parse, aggregateProjectsIntoDays, '', () => false) + expect(partial.complete).toBe(false) + expect(partial.days.find(day => day.date === date)).toMatchObject({ calls: 2, inputTokens: 150 }) + const refreshed = await ensureCacheHydrated(parse, aggregateProjectsIntoDays) + const day = refreshed.days.find(day => day.date === date)! + expect(day).toMatchObject({ calls: 2, inputTokens: 250, outputTokens: 40 }) + expect(day.providers.openclaw).toMatchObject({ calls: 1, inputTokens: 200, cost: 0.01 }) + expect(day.providers.claude).toMatchObject({ calls: 1, inputTokens: 50, cost: 0.02 }) + const warmParse = vi.fn(parse) + await ensureCacheHydrated(warmParse, aggregateProjectsIntoDays) + expect(warmParse).not.toHaveBeenCalled() + } finally { + cwd?.mockRestore() + clearSessionCache() + await rm(root, { recursive: true, force: true }) + } + }) + + // Pin released versions: deriving this from DAILY_CACHE_VERSION would stop + // detecting a reused version when another provider's migration lands first. + it.each([51, 73, 74, 75])('backfills relocated history from a finalized v%i daily cache and retains archived usage', async version => { + const root = await mkdtemp(join(tmpdir(), 'openclaw-daily-backfill-')) + try { + const cache = join(root, 'cache') + const state = join(root, 'state') + vi.stubEnv('CODEBURN_CACHE_DIR', cache) + vi.stubEnv('OPENCLAW_STATE_DIR', state) + const timestamp = new Date(Date.now() - 4 * 86400000).toISOString() + const date = toDateString(new Date(timestamp)) + await seed(state, 'relocated', 100, timestamp) + const parse = () => parseAllSessions(undefined, 'openclaw') + const fresh = await parse() + expect(aggregateProjectsIntoDays(fresh)[0]).toMatchObject({ date, calls: 1, inputTokens: 100 }) + + // Simulate an old finalized day with another provider's unavailable log. + const archived = structuredClone(fresh) + for (const project of archived) for (const session of project.sessions) for (const turn of session.turns) for (const call of turn.assistantCalls) { + call.provider = 'claude' + call.usage.inputTokens = 50 + call.costUSD = 0.02 + } + await mkdir(cache, { recursive: true }) + await writeFile(join(cache, `daily-cache.v${version}.json`), JSON.stringify({ + version, savingsConfigHash: '', tzKey: currentTzKey(), + lastComputedDate: toDateString(new Date(Date.now() - 86400000)), + days: aggregateProjectsIntoDays(archived), complete: true, watermarkTrusted: true, + })) + const hydrated = await ensureCacheHydrated(parse, aggregateProjectsIntoDays) + const day = hydrated.days.find(d => d.date === date)! + expect(day).toMatchObject({ calls: 2, inputTokens: 150, outputTokens: 40 }) + expect(day.providers.openclaw).toMatchObject({ calls: 1, inputTokens: 100, cost: 0.01 }) + expect(day.providers.claude).toMatchObject({ calls: 1, inputTokens: 50, cost: 0.02 }) + } finally { + clearSessionCache() + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/provider-env-declarations.test.ts b/tests/provider-env-declarations.test.ts index 7c585e021..1bb60489b 100644 --- a/tests/provider-env-declarations.test.ts +++ b/tests/provider-env-declarations.test.ts @@ -45,6 +45,7 @@ const FILE_PROVIDERS: Record = { // The ENV_DIR const (open-design.ts:10) resolves to CODEBURN_OPEN_DESIGN_DIR. 'open-design.ts': ['open-design'], 'openclaude.ts': ['openclaude'], + 'openclaw.ts': ['openclaw'], 'opencode.ts': ['opencode'], 'goose.ts': ['goose'], 'grok.ts': ['grok'], diff --git a/tests/providers/openclaw.test.ts b/tests/providers/openclaw.test.ts index 703ae489c..9076c8eb0 100644 --- a/tests/providers/openclaw.test.ts +++ b/tests/providers/openclaw.test.ts @@ -1,14 +1,25 @@ -import { describe, it, expect, afterAll, vi } from 'vitest' +import { describe, it, expect, afterAll, beforeEach, afterEach, vi } from 'vitest' import { createOpenClawProvider } from '../../src/providers/openclaw.js' import { isSqliteBusyError } from '../../src/sqlite.js' -import { writeFile, mkdir, rm, stat } from 'fs/promises' +import { writeFile, mkdir, rm, stat, mkdtemp } from 'fs/promises' import { mkdirSync } from 'node:fs' -import { join } from 'path' -import { tmpdir } from 'os' +import { join, relative } from 'path' +import { tmpdir, homedir } from 'os' import { createRequire } from 'node:module' import zlib from 'node:zlib' let sqliteRuntimeAvailable = true +const homeResolver = vi.hoisted(() => ({ value: undefined as string | undefined, throws: false })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: () => { + if (homeResolver.throws) throw new Error('fixture home lookup failed') + return homeResolver.value ?? actual.homedir() + }, + } +}) vi.mock('../../src/sqlite.js', async (importOriginal) => { const actual = await importOriginal() return { @@ -670,3 +681,195 @@ describe('openclaw provider', () => { await rm(baseDir, { recursive: true, force: true }) }) }) + +describe('OpenClaw state directory discovery', () => { + let root: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'codeburn-openclaw-state-')) + vi.stubEnv('HOME', join(root, 'home')) + vi.stubEnv('USERPROFILE', join(root, 'home')) + }) + + afterEach(async () => { + homeResolver.value = undefined + homeResolver.throws = false + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + }) + + it('discovers and parses JSONL beneath OPENCLAW_STATE_DIR/agents', async () => { + const state = join(root, 'custom state') + const path = await setupFixture(join(state, 'agents'), 'custom-agent', 'custom-session', SESSION_LINES) + vi.stubEnv('OPENCLAW_STATE_DIR', state) + const provider = createOpenClawProvider() + const sources = await provider.discoverSessions() + expect(sources.map(s => s.path)).toEqual([path]) + expect(await provider.probeRoots!()).toContainEqual({ path: join(state, 'agents'), label: 'agents' }) + const calls = await parseAll(provider, sources[0], new Set()) + expect(calls).toHaveLength(2) + expect(calls[0].inputTokens).toBe(500) + expect(calls[1].outputTokens).toBe(200) + }) + + it('discovers the SQLite store under the same relocated agents root', async () => { + const state = join(root, 'sqlite-state') + const events = sessionEvents() + const db = createAgentDb(join(state, 'agents'), 'custom-agent', [{ + sessionId: 'test-sess-1', + rows: [events.session, events.modelChange, events.user, events.assistant].map((event, seq) => ({ + seq, event, createdAt: Date.parse('2026-04-20T10:00:03Z'), + })), + }]) + vi.stubEnv('OPENCLAW_STATE_DIR', state) + const provider = createOpenClawProvider() + const sources = await provider.discoverSessions() + expect(sources.map(s => s.path)).toEqual([`${db}:test-sess-1`]) + expect(await parseAll(provider, sources[0], new Set())).toHaveLength(1) + }) + + it('keeps legacy home roots alongside the relocated state directory', async () => { + const state = join(root, 'custom-state') + const custom = await setupFixture(join(state, 'agents'), 'custom', 'custom', SESSION_LINES) + const legacy = await setupFixture(join(homedir(), '.clawdbot', 'agents'), 'old', 'old', SESSION_LINES) + vi.stubEnv('OPENCLAW_STATE_DIR', state) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([custom, legacy]) + }) + + it.each(['', ' '])('uses default roots when the override is blank (%j)', async value => { + vi.stubEnv('OPENCLAW_STATE_DIR', value) + const path = await setupFixture(join(homedir(), '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([path]) + expect(await createOpenClawProvider().probeRoots!()).toHaveLength(4) + }) + + it('trims the override and expands a leading home prefix', async () => { + vi.stubEnv('OPENCLAW_STATE_DIR', ' ~/custom-state ') + const path = await setupFixture(join(homedir(), 'custom-state', 'agents'), 'custom', 'custom', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([path]) + }) + + it('resolves relative overrides consistently for discovery and probing', async () => { + const state = join(root, 'relative-state') + vi.stubEnv('OPENCLAW_STATE_DIR', relative(process.cwd(), state)) + const path = await setupFixture(join(state, 'agents'), 'custom', 'custom', SESSION_LINES) + const provider = createOpenClawProvider() + expect((await provider.discoverSessions()).map(s => s.path)).toEqual([path]) + expect((await provider.probeRoots!())[0].path).toBe(join(state, 'agents')) + }) + + it('deduplicates an override that resolves to the default state root', async () => { + vi.stubEnv('OPENCLAW_STATE_DIR', join(homedir(), '.openclaw', '.')) + const path = await setupFixture(join(homedir(), '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + const provider = createOpenClawProvider() + expect((await provider.discoverSessions()).map(s => s.path)).toEqual([path]) + expect(await provider.probeRoots!()).toHaveLength(4) + }) + + it('preserves the explicit agents-directory constructor override', async () => { + vi.stubEnv('OPENCLAW_STATE_DIR', join(root, 'ignored-state')) + const agents = join(root, 'explicit-agents') + const path = await setupFixture(agents, 'explicit', 'explicit', SESSION_LINES) + const provider = createOpenClawProvider(agents) + expect((await provider.discoverSessions()).map(s => s.path)).toEqual([path]) + expect(await provider.probeRoots!()).toEqual([{ path: agents, label: 'agents' }]) + }) + + it('counts a session mirrored in custom and legacy roots only once', async () => { + const state = join(root, 'custom-state') + vi.stubEnv('OPENCLAW_STATE_DIR', state) + await setupFixture(join(state, 'agents'), 'custom', 'mirror', SESSION_LINES) + await setupFixture(join(homedir(), '.clawdbot', 'agents'), 'legacy', 'mirror', SESSION_LINES) + const provider = createOpenClawProvider() + const seen = new Set() + const counts: number[] = [] + for (const source of await provider.discoverSessions()) { + counts.push((await parseAll(provider, source, seen)).length) + } + expect(counts).toEqual([2, 0]) + }) + + it.skipIf(process.platform !== 'win32')('deduplicates Windows case aliases of the default state root', async () => { + const state = join(homedir(), '.openclaw') + vi.stubEnv('OPENCLAW_STATE_DIR', state.toUpperCase()) + await setupFixture(join(state, 'agents'), 'default', 'default', SESSION_LINES) + const provider = createOpenClawProvider() + expect(await provider.probeRoots!()).toHaveLength(4) + expect(await provider.discoverSessions()).toHaveLength(1) + }) + + it('honors OPENCLAW_HOME for the default state root and home-relative override', async () => { + const home = join(root, 'openclaw-home') + vi.stubEnv('OPENCLAW_HOME', home) + const defaultPath = await setupFixture(join(home, '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([defaultPath]) + vi.stubEnv('OPENCLAW_STATE_DIR', '~/relocated') + const custom = await setupFixture(join(home, 'relocated', 'agents'), 'custom', 'custom', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([custom, defaultPath]) + }) + + it('prefers HOME to USERPROFILE when OpenClaw resolves its home', async () => { + const home = join(root, 'shell-home') + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', join(root, 'profile-home')) + const path = await setupFixture(join(home, '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([path]) + vi.stubEnv('OPENCLAW_STATE_DIR', '~/relocated') + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(home, 'relocated', 'agents')) + }) + + it.each(['', ' ', ' undefined ', ' null '])('treats an unset OPENCLAW_HOME placeholder (%j) as absent', async value => { + vi.stubEnv('OPENCLAW_HOME', value) + const path = await setupFixture(join(homedir(), '.openclaw', 'agents'), 'default', 'default', SESSION_LINES) + expect((await createOpenClawProvider().discoverSessions()).map(s => s.path)).toEqual([path]) + }) + + it('expands OPENCLAW_HOME against the OS home fallback chain', async () => { + const home = join(root, 'shell-home') + vi.stubEnv('HOME', home) + vi.stubEnv('OPENCLAW_HOME', ' ~/service ') + vi.stubEnv('OPENCLAW_STATE_DIR', '~/state') + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(home, 'service', 'state', 'agents')) + }) + + it.each(['undefined', 'null', ''])('falls back to USERPROFILE for an unset HOME value (%j)', async value => { + const profile = join(root, 'profile-home') + vi.stubEnv('HOME', value) + vi.stubEnv('USERPROFILE', profile) + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(profile, '.openclaw', 'agents')) + }) + + it('supports OpenClaw\'s Termux home fallback before the OS resolver', async () => { + vi.stubEnv('HOME', '') + vi.stubEnv('USERPROFILE', '') + const termux = join(root, 'com.termux', 'files') + vi.stubEnv('PREFIX', join(termux, 'usr')) + vi.stubEnv('ANDROID_DATA', '/data') + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(termux, 'home', '.openclaw', 'agents')) + }) + + it.each(['null', 'undefined', ''])('uses cwd when every home candidate is unset, including the OS value (%j)', async value => { + vi.stubEnv('HOME', value) + vi.stubEnv('USERPROFILE', '') + homeResolver.value = value + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(process.cwd(), '.openclaw', 'agents')) + }) + + it('uses cwd when the OS home lookup fails and no home override exists', async () => { + vi.stubEnv('HOME', '') + vi.stubEnv('USERPROFILE', '') + homeResolver.throws = true + expect((await createOpenClawProvider().probeRoots!())[0].path).toBe(join(process.cwd(), '.openclaw', 'agents')) + }) + + it.each(['~', '~/service', '~\\service'])('falls back to cwd when OPENCLAW_HOME %j has no home to expand', async value => { + vi.stubEnv('HOME', '') + vi.stubEnv('USERPROFILE', '') + vi.stubEnv('OPENCLAW_HOME', value) + homeResolver.throws = true + const provider = createOpenClawProvider() + expect((await provider.probeRoots!())[0].path).toBe(join(process.cwd(), '.openclaw', 'agents')) + vi.stubEnv('OPENCLAW_STATE_DIR', '~/relocated') + expect((await provider.probeRoots!())[0].path).toBe(join(process.cwd(), 'relocated', 'agents')) + }) +}) diff --git a/tests/setup/env-isolation-vars.ts b/tests/setup/env-isolation-vars.ts index 0407b1b6c..47b455ac9 100644 --- a/tests/setup/env-isolation-vars.ts +++ b/tests/setup/env-isolation-vars.ts @@ -47,6 +47,10 @@ export const CLEARED = [ 'LINGTAI_TUI_HOME', 'MUX_ROOT', 'OPENCODE_DATA_DIR', + 'OPENCLAW_STATE_DIR', + 'OPENCLAW_HOME', + 'PREFIX', + 'ANDROID_DATA', 'OPENCODE_DB_PREFIX', 'QUICKWORK_HOME', 'QWEN_DATA_DIR',